@viamrobotics/motion-tools 1.18.1 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -94,7 +94,6 @@ export const providePartConfig = (partID, params) => {
94
94
  const updatePartFrame = (componentName, referenceFrame, pose, geometry) => {
95
95
  const newConfig = getCurrent();
96
96
  const component = newConfig.components?.find(({ name }) => name === componentName);
97
- console.log('hi', newConfig, componentName);
98
97
  if (!component) {
99
98
  return;
100
99
  }
@@ -162,6 +161,9 @@ export const providePartConfig = (partID, params) => {
162
161
  get isDirty() {
163
162
  return config.isDirty;
164
163
  },
164
+ get hasPendingSave() {
165
+ return config.hasPendingSave;
166
+ },
165
167
  get hasEditPermissions() {
166
168
  return config.hasEditPermissions;
167
169
  },
@@ -194,6 +196,8 @@ export const providePartConfig = (partID, params) => {
194
196
  },
195
197
  save: () => config.save?.(),
196
198
  discardChanges: () => config.discardChanges?.(),
199
+ clearPendingSave: () => config.clearPendingSave(),
200
+ setPendingSave: () => config.setPendingSave(),
197
201
  });
198
202
  };
199
203
  export const usePartConfig = () => {
@@ -202,6 +206,7 @@ export const usePartConfig = () => {
202
206
  const useEmbeddedPartConfig = (props) => {
203
207
  return {
204
208
  hasEditPermissions: true,
209
+ hasPendingSave: false,
205
210
  get isDirty() {
206
211
  return props.isDirty;
207
212
  },
@@ -215,6 +220,8 @@ const useEmbeddedPartConfig = (props) => {
215
220
  const struct = Struct.fromJson(config);
216
221
  return props.setLocalPartConfig(struct);
217
222
  },
223
+ clearPendingSave() { },
224
+ setPendingSave() { },
218
225
  };
219
226
  };
220
227
  const useStandalonePartConfig = (partID) => {
@@ -222,21 +229,25 @@ const useStandalonePartConfig = (partID) => {
222
229
  refetchInterval: false,
223
230
  });
224
231
  const partName = $derived(partQuery.data?.part?.name);
232
+ // Use part.robotConfig (the stored Struct config) as the authoritative source.
233
+ // configJson is the compiled running config from the robot daemon and may be empty
234
+ // even when the stored config exists and the API key has edit permissions.
235
+ let networkPartConfig = $derived(partQuery.data?.part?.robotConfig);
236
+ let current = $state.raw();
237
+ let isDirty = $state(false);
238
+ let hasPendingSave = $state(false);
239
+ const hasEditPermissions = $derived(networkPartConfig !== undefined);
225
240
  const configJSON = $derived.by(() => {
226
- if (!partQuery.data?.configJson) {
241
+ if (!networkPartConfig) {
227
242
  return undefined;
228
243
  }
229
244
  try {
230
- return JSON.parse(partQuery.data.configJson);
245
+ return networkPartConfig.toJson();
231
246
  }
232
247
  catch {
233
248
  return undefined;
234
249
  }
235
250
  });
236
- let networkPartConfig = $derived(configJSON ? Struct.fromJson(configJSON) : undefined);
237
- let current = $state.raw();
238
- let isDirty = $state(false);
239
- const hasEditPermissions = $derived(networkPartConfig !== undefined);
240
251
  const fragmentQueries = $derived((configJSON?.fragments ?? []).map((fragmentId) => {
241
252
  const id = typeof fragmentId === 'string' ? fragmentId : fragmentId.id;
242
253
  return createAppQuery('getFragment', () => [id], { refetchInterval: false });
@@ -263,8 +274,7 @@ const useStandalonePartConfig = (partID) => {
263
274
  return results;
264
275
  });
265
276
  $effect.pre(() => {
266
- if (!networkPartConfig) {
267
- // no config returned here indicates this api key has no permission to update config
277
+ if (!networkPartConfig || isDirty) {
268
278
  return;
269
279
  }
270
280
  current = networkPartConfig;
@@ -277,6 +287,9 @@ const useStandalonePartConfig = (partID) => {
277
287
  get isDirty() {
278
288
  return isDirty;
279
289
  },
290
+ get hasPendingSave() {
291
+ return hasPendingSave;
292
+ },
280
293
  get hasEditPermissions() {
281
294
  return hasEditPermissions;
282
295
  },
@@ -294,10 +307,17 @@ const useStandalonePartConfig = (partID) => {
294
307
  networkPartConfig = current;
295
308
  await updateRobotPartMutation.mutateAsync([partID(), partName, current]);
296
309
  isDirty = false;
310
+ hasPendingSave = true;
297
311
  },
298
312
  discardChanges() {
299
313
  current = networkPartConfig;
300
314
  isDirty = false;
301
315
  },
316
+ clearPendingSave() {
317
+ hasPendingSave = false;
318
+ },
319
+ setPendingSave() {
320
+ hasPendingSave = true;
321
+ },
302
322
  };
303
323
  };
@@ -5,7 +5,6 @@ import { createBufferGeometry, updateBufferGeometry } from '../attribute';
5
5
  import { ColorFormat } from '../buf/draw/v1/metadata_pb';
6
6
  import { RefetchRates } from '../components/overlay/RefreshRate.svelte';
7
7
  import { traits, useWorld } from '../ecs';
8
- import { updateGeometryTrait } from '../ecs/traits';
9
8
  import { parsePcdInWorker } from '../lib';
10
9
  import { createPose } from '../transform';
11
10
  import { useEnvironment } from './useEnvironment.svelte';
@@ -162,20 +161,18 @@ export const providePointcloudObjects = (partID) => {
162
161
  const existing = entities.get(geometryLabel);
163
162
  if (existing) {
164
163
  existing.set(traits.Center, center);
165
- updateGeometryTrait(existing, geometry);
164
+ traits.updateGeometryTrait(existing, geometry);
166
165
  }
167
166
  else {
168
167
  const entityTraits = [
169
168
  traits.Name(geometryLabel),
169
+ ...traits.getParentTrait(geometriesInFrame.referenceFrame),
170
170
  traits.Center(center),
171
171
  traits.GeometriesAPI,
172
172
  traits.Geometry(geometry),
173
173
  traits.Opacity(0.2),
174
174
  traits.Color({ r: 0, g: 1, b: 0 }),
175
175
  ];
176
- if (geometriesInFrame.referenceFrame) {
177
- entityTraits.push(traits.Parent(geometriesInFrame.referenceFrame));
178
- }
179
176
  const entity = world.spawn(...entityTraits);
180
177
  entities.set(geometryLabel, entity);
181
178
  }
@@ -1,11 +1,12 @@
1
1
  import { useThrelte } from '@threlte/core';
2
- import { TransformChangeType, WorldStateStoreClient, } from '@viamrobotics/sdk';
2
+ import { Struct, TransformChangeType, WorldStateStoreClient, } from '@viamrobotics/sdk';
3
3
  import { createResourceClient, createResourceQuery, createResourceStream, useResourceNames, } from '@viamrobotics/svelte-sdk';
4
- import { drawTransform } from '../draw';
4
+ import { asFloat32Array, inMeters } from '../buffer';
5
+ import { createChunkLoader } from '../chunking';
6
+ import { drawTransform, updateMetadata } from '../draw';
5
7
  import { traits, useWorld } from '../ecs';
6
- import { createBox, createCapsule, createSphere } from '../geometry';
7
8
  import { isPointCloud } from '../geometry';
8
- import { parsePlyInput } from '../ply';
9
+ import { metadataFromStruct } from '../metadata';
9
10
  import { createPose } from '../transform';
10
11
  import { usePartID } from './usePartID.svelte';
11
12
  export const provideWorldStates = () => {
@@ -24,16 +25,90 @@ export const provideWorldStates = () => {
24
25
  };
25
26
  });
26
27
  };
28
+ const decodeBase64 = (encoded) => {
29
+ const binary = atob(encoded);
30
+ const bytes = new Uint8Array(binary.length);
31
+ for (let i = 0; i < binary.length; i++) {
32
+ bytes[i] = binary.charCodeAt(i);
33
+ }
34
+ return bytes;
35
+ };
36
+ /**
37
+ * Unpacks a `get_entity_chunk` DoCommand response into the shape the shared
38
+ * chunk loader expects. The world-state store sends binary buffers as base64
39
+ * strings inside a JSON `Struct`, which is why this adapter exists.
40
+ *
41
+ * Request:
42
+ * { "command": "get_entity_chunk", "uuid": "<uuid-string>", "start": <element-offset> }
43
+ *
44
+ * Response:
45
+ * {
46
+ * "entity": {
47
+ * "metadata": {
48
+ * "colors": "<base64 Uint8Array>" (optional),
49
+ * "opacities": "<base64 Uint8Array>" (optional)
50
+ * },
51
+ * "physical_object": {
52
+ * "points": { "positions": "<base64 Float32Array>" }
53
+ * }
54
+ * },
55
+ * "start": <number>,
56
+ * "done": <boolean>
57
+ * }
58
+ */
59
+ const decodeWorldStateChunk = (response, fallbackStart) => {
60
+ const fields = response;
61
+ const done = fields['done'] === true;
62
+ const start = typeof fields['start'] === 'number' ? fields['start'] : fallbackStart;
63
+ const chunkEntity = fields['entity'];
64
+ if (!chunkEntity)
65
+ return null;
66
+ const physicalObject = chunkEntity['physical_object'];
67
+ const points = physicalObject?.['points'];
68
+ const encodedPositions = points?.['positions'];
69
+ if (typeof encodedPositions !== 'string' || encodedPositions.length === 0)
70
+ return null;
71
+ const positions = asFloat32Array(decodeBase64(encodedPositions), inMeters);
72
+ const metadata = chunkEntity['metadata'];
73
+ const encodedColors = metadata?.['colors'];
74
+ const colors = typeof encodedColors === 'string' && encodedColors.length > 0
75
+ ? decodeBase64(encodedColors)
76
+ : undefined;
77
+ const encodedOpacities = metadata?.['opacities'];
78
+ const opacities = typeof encodedOpacities === 'string' && encodedOpacities.length > 0
79
+ ? decodeBase64(encodedOpacities)
80
+ : undefined;
81
+ return { start, positions, colors, opacities, done };
82
+ };
27
83
  const createWorldState = (client) => {
28
84
  const { invalidate } = useThrelte();
29
85
  const world = useWorld();
30
86
  const entities = new Map();
87
+ const chunkLoader = createChunkLoader({
88
+ world,
89
+ invalidate,
90
+ fetchChunk: async (uuid, start, signal) => {
91
+ const activeClient = client.current;
92
+ if (!activeClient)
93
+ return null;
94
+ const response = await activeClient.doCommand(Struct.fromJson({
95
+ command: 'get_entity_chunk',
96
+ uuid,
97
+ start,
98
+ }));
99
+ if (signal.aborted)
100
+ return null;
101
+ return decodeWorldStateChunk(response, start);
102
+ },
103
+ });
31
104
  const spawnEntity = (transform) => {
32
105
  if (entities.has(transform.uuidString)) {
33
106
  return;
34
107
  }
35
108
  const entity = drawTransform(world, transform, traits.WorldStateStoreAPI, { removable: false });
36
109
  entities.set(transform.uuidString, entity);
110
+ const parsedMetadata = metadataFromStruct(transform.metadata?.fields);
111
+ chunkLoader.start(transform.uuidString, entity, parsedMetadata);
37
112
  if (isPointCloud(transform.physicalObject?.geometryType))
38
113
  invalidate();
39
114
  };
@@ -50,28 +125,25 @@ const createWorldState = (client) => {
50
125
  const entity = entities.get(transform.uuidString);
51
126
  if (!entity)
52
127
  return;
128
+ let metadataDirty = false;
53
129
  for (const path of changes) {
54
130
  if (typeof path === 'string') {
55
131
  if (path.startsWith('poseInObserverFrame.pose')) {
56
132
  entity.set(traits.Pose, transform.poseInObserverFrame?.pose ?? createPose());
57
133
  }
58
134
  else if (path.startsWith('physicalObject') && transform.physicalObject) {
59
- const { geometryType } = transform.physicalObject;
60
- if (geometryType.case === 'box') {
61
- entity.set(traits.Box, createBox(geometryType.value));
62
- }
63
- else if (geometryType.case === 'capsule') {
64
- entity.set(traits.Capsule, createCapsule(geometryType.value));
65
- }
66
- else if (geometryType.case === 'sphere') {
67
- entity.set(traits.Sphere, createSphere(geometryType.value));
68
- }
69
- else if (geometryType.case === 'mesh') {
70
- entity.set(traits.BufferGeometry, parsePlyInput(geometryType.value.mesh));
71
- }
135
+ traits.updateGeometryTrait(entity, transform.physicalObject);
136
+ }
137
+ else if (path.startsWith('metadata')) {
138
+ metadataDirty = true;
72
139
  }
73
140
  }
74
141
  }
142
+ if (metadataDirty) {
143
+ updateMetadata(entity, metadataFromStruct(transform.metadata?.fields), {
144
+ pointCloud: isPointCloud(transform.physicalObject?.geometryType),
145
+ });
146
+ }
75
147
  };
76
148
  let initialized = false;
77
149
  let flushScheduled = false;
@@ -176,6 +248,7 @@ const createWorldState = (client) => {
176
248
  scheduleFlush();
177
249
  });
178
250
  return () => {
251
+ chunkLoader.dispose();
179
252
  for (const [, entity] of entities) {
180
253
  if (world.has(entity)) {
181
254
  entity.destroy();
package/dist/metadata.js CHANGED
@@ -5,7 +5,8 @@ export const isMetadataField = (key) => {
5
5
  key === 'color_format' ||
6
6
  key === 'opacities' ||
7
7
  key === 'show_axes_helper' ||
8
- key === 'invisible');
8
+ key === 'invisible' ||
9
+ key === 'chunks');
9
10
  };
10
11
  /**
11
12
  * Extracts typed {@link Metadata} from a proto `Struct` fields map.
@@ -65,6 +66,17 @@ export const metadataFromStruct = (fields = {}) => {
65
66
  }
66
67
  break;
67
68
  }
69
+ case 'chunks': {
70
+ if (typeof unwrappedValue === 'object' && unwrappedValue !== null) {
71
+ const obj = unwrappedValue;
72
+ json.chunks = {
73
+ chunkSize: typeof obj['chunk_size'] === 'number' ? obj['chunk_size'] : 0,
74
+ total: typeof obj['total'] === 'number' ? obj['total'] : 0,
75
+ stride: typeof obj['stride'] === 'number' ? obj['stride'] : 0,
76
+ };
77
+ }
78
+ break;
79
+ }
68
80
  }
69
81
  }
70
82
  return json;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/motion-tools",
3
- "version": "1.18.1",
3
+ "version": "1.19.1",
4
4
  "description": "Motion visualization with Viam",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -165,8 +165,8 @@
165
165
  "test:draw": "go test ./draw/... -count=1",
166
166
  "test": "pnpm test:unit -- --run",
167
167
  "test:coverage": "npx vitest run --coverage",
168
- "test:e2e": "playwright test",
169
- "test:e2e-ui": "playwright test --ui",
168
+ "test:e2e": "./e2e/setup.sh && playwright test",
169
+ "test:e2e-ui": "./e2e/setup.sh && playwright test --ui",
170
170
  "vet:draw": "go vet ./draw/...",
171
171
  "vet:client": "go vet ./client/...",
172
172
  "vet": "pnpm vet:draw && pnpm vet:client",