@viamrobotics/motion-tools 1.18.0 → 1.19.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.
package/dist/draw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Vector3, Vector4 } from 'three';
2
2
  import { NURBSCurve } from 'three/addons/curves/NURBSCurve.js';
3
- import { createBufferGeometry, updateBufferGeometry } from './attribute';
3
+ import { createBufferGeometry, preAllocateBufferGeometry, updateBufferGeometry, writeBufferGeometryRange, } from './attribute';
4
4
  import { asFloat32Array, asOpacity, asRGB, inMeters, isSingleColor, isVertexColors, STRIDE, } from './buffer';
5
5
  import { traits } from './ecs';
6
6
  import { parsePcdInWorker } from './loaders/pcd';
@@ -38,9 +38,7 @@ export const drawTransform = (world, { referenceFrame, poseInObserverFrame, phys
38
38
  }
39
39
  if (options.removable)
40
40
  entityTraits.push(traits.Removable);
41
- const parent = poseInObserverFrame?.referenceFrame;
42
- if (parent && parent !== 'world')
43
- entityTraits.push(traits.Parent(parent));
41
+ entityTraits.push(...traits.getParentTrait(poseInObserverFrame?.referenceFrame));
44
42
  const parsedMetadata = metadataFromStruct(metadata?.fields);
45
43
  if (parsedMetadata.showAxesHelper)
46
44
  entityTraits.push(traits.ShowAxesHelper);
@@ -68,10 +66,7 @@ export const drawDrawing = (world, drawing, api, options = { removable: true })
68
66
  const { referenceFrame, poseInObserverFrame, physicalObject, metadata } = drawing;
69
67
  if (physicalObject?.geometryType?.case === 'model')
70
68
  return drawModel(world, drawing, api, options);
71
- const entity = world.spawn(traits.Name(referenceFrame), traits.Pose(createPose(poseInObserverFrame?.pose)), api);
72
- const parent = poseInObserverFrame?.referenceFrame;
73
- if (parent && parent !== 'world')
74
- entity.add(traits.Parent(parent));
69
+ const entity = world.spawn(traits.Name(referenceFrame), traits.Pose(createPose(poseInObserverFrame?.pose)), api, ...traits.getParentTrait(poseInObserverFrame?.referenceFrame));
75
70
  if (options.removable)
76
71
  entity.add(traits.Removable);
77
72
  if (metadata?.showAxesHelper)
@@ -83,9 +78,7 @@ export const drawDrawing = (world, drawing, api, options = { removable: true })
83
78
  };
84
79
  export const updateTransform = (entity, { poseInObserverFrame, physicalObject, metadata }, options = { removable: true }) => {
85
80
  entity.set(traits.Pose, createPose(poseInObserverFrame?.pose));
86
- const parent = poseInObserverFrame?.referenceFrame;
87
- if (parent && parent !== 'world')
88
- entity.set(traits.Parent, parent);
81
+ traits.setParentTrait(entity, poseInObserverFrame?.referenceFrame);
89
82
  if (physicalObject) {
90
83
  traits.updateGeometryTrait(entity, physicalObject);
91
84
  const center = physicalObject.center;
@@ -96,31 +89,33 @@ export const updateTransform = (entity, { poseInObserverFrame, physicalObject, m
96
89
  entity.remove(traits.Center);
97
90
  }
98
91
  }
99
- const parsedMetadata = metadataFromStruct(metadata?.fields);
100
- if (parsedMetadata.showAxesHelper)
92
+ updateMetadata(entity, metadataFromStruct(metadata?.fields), {
93
+ pointCloud: isPointCloud(physicalObject?.geometryType),
94
+ });
95
+ if (options.removable)
96
+ entity.add(traits.Removable);
97
+ if (!options.removable)
98
+ entity.remove(traits.Removable);
99
+ };
100
+ export const updateMetadata = (entity, metadata, { pointCloud = false } = {}) => {
101
+ if (metadata.showAxesHelper)
101
102
  entity.add(traits.ShowAxesHelper);
102
103
  else
103
104
  entity.remove(traits.ShowAxesHelper);
104
- if (parsedMetadata.invisible)
105
+ if (metadata.invisible)
105
106
  entity.add(traits.Invisible);
106
107
  else
107
108
  entity.remove(traits.Invisible);
108
- const { colors, opacities } = parsedMetadata;
109
+ const { colors, opacities } = metadata;
109
110
  if (colors) {
110
- if (isPointCloud(physicalObject?.geometryType)) {
111
- updateColors(entity, parsedMetadata);
111
+ if (pointCloud) {
112
+ updateColors(entity, metadata);
112
113
  }
113
114
  else {
114
- addColorTraits(entity, colors);
115
+ setColorTraits(entity, colors);
115
116
  }
116
117
  }
117
- const opacity = asOpacity(opacities, DEFAULT_OPACITY);
118
- if (opacity < 1)
119
- entity.add(traits.Opacity(opacity));
120
- if (options.removable)
121
- entity.add(traits.Removable);
122
- if (!options.removable)
123
- entity.remove(traits.Removable);
118
+ entity.set(traits.Opacity, asOpacity(opacities, DEFAULT_OPACITY));
124
119
  };
125
120
  export const updateDrawing = (world, entities, drawing, api, options = { removable: true }) => {
126
121
  const { poseInObserverFrame, physicalObject, metadata } = drawing;
@@ -137,9 +132,7 @@ export const updateDrawing = (world, entities, drawing, api, options = { removab
137
132
  if (!world.has(entity))
138
133
  return entities;
139
134
  entity.set(traits.Pose, createPose(poseInObserverFrame?.pose));
140
- const parent = poseInObserverFrame?.referenceFrame;
141
- if (parent && parent !== 'world')
142
- entity.set(traits.Parent, parent);
135
+ traits.setParentTrait(entity, poseInObserverFrame?.referenceFrame);
143
136
  if (metadata?.showAxesHelper)
144
137
  entity.add(traits.ShowAxesHelper);
145
138
  if (!metadata?.showAxesHelper)
@@ -181,15 +174,30 @@ const applyShape = (entity, { physicalObject, metadata }) => {
181
174
  }
182
175
  case 'points': {
183
176
  const positions = asFloat32Array(geometryType.value.positions, inMeters);
177
+ const total = metadata?.chunks?.total;
184
178
  const center = physicalObject?.center;
185
179
  if (center)
186
180
  entity.add(traits.Center(center));
187
181
  addColorTraits(entity, colors ?? DEFAULT_POINTS_COLORS);
188
182
  entity.add(traits.PointSize(geometryType.value.pointSize ?? DEFAULT_POINT_SIZE));
189
- entity.add(traits.BufferGeometry(createBufferGeometry(positions, {
190
- colors: isVertexColors(colors) ? colors : undefined,
183
+ const vertexColors = isVertexColors(colors) ? colors : undefined;
184
+ const pointsMetadata = {
185
+ colors: vertexColors,
191
186
  colorFormat: metadata?.colorFormat ?? ColorFormat.UNSPECIFIED,
192
- })));
187
+ opacities: metadata?.opacities,
188
+ };
189
+ if (total !== undefined && total > 0) {
190
+ const allocMetadata = {
191
+ ...pointsMetadata,
192
+ colors: vertexColors ? new Uint8Array(0) : undefined,
193
+ };
194
+ const geometry = preAllocateBufferGeometry(total, STRIDE.POSITIONS, allocMetadata);
195
+ writeBufferGeometryRange(geometry, positions, 0, pointsMetadata);
196
+ entity.add(traits.BufferGeometry(geometry));
197
+ }
198
+ else {
199
+ entity.add(traits.BufferGeometry(createBufferGeometry(positions, pointsMetadata)));
200
+ }
193
201
  entity.add(traits.Points);
194
202
  break;
195
203
  }
@@ -237,7 +245,6 @@ const applyShape = (entity, { physicalObject, metadata }) => {
237
245
  };
238
246
  const drawModel = (world, { referenceFrame, poseInObserverFrame, physicalObject, metadata }, api, { removable = true }) => {
239
247
  const entities = [];
240
- const parent = poseInObserverFrame?.referenceFrame;
241
248
  const geometryType = physicalObject?.geometryType;
242
249
  if (geometryType?.case !== 'model')
243
250
  return entities;
@@ -245,9 +252,8 @@ const drawModel = (world, { referenceFrame, poseInObserverFrame, physicalObject,
245
252
  traits.Name(referenceFrame),
246
253
  traits.Pose(createPose(poseInObserverFrame?.pose)),
247
254
  api,
255
+ ...traits.getParentTrait(poseInObserverFrame?.referenceFrame),
248
256
  ];
249
- if (parent && parent !== 'world')
250
- baseTraits.push(traits.Parent(parent));
251
257
  if (removable)
252
258
  baseTraits.push(traits.Removable);
253
259
  if (metadata?.invisible)
@@ -292,10 +298,19 @@ const parsePointCloud = (world, entity, pointCloud, metadata) => {
292
298
  let vertexColors = pointcloud.colors;
293
299
  if (colors && colors.length > 0)
294
300
  vertexColors = parseColors(colors, numPoints);
295
- const geometry = createBufferGeometry(pointcloud.positions, {
296
- colors: vertexColors ?? undefined,
297
- colorFormat,
298
- });
301
+ const total = metadata.chunks?.total;
302
+ const chunkMetadata = { colors: vertexColors ?? undefined, colorFormat };
303
+ let geometry;
304
+ if (total !== undefined && total > numPoints) {
305
+ geometry = preAllocateBufferGeometry(total, STRIDE.POSITIONS, {
306
+ ...chunkMetadata,
307
+ colors: vertexColors ? new Uint8Array(0) : undefined,
308
+ });
309
+ writeBufferGeometryRange(geometry, pointcloud.positions, 0, chunkMetadata);
310
+ }
311
+ else {
312
+ geometry = createBufferGeometry(pointcloud.positions, chunkMetadata);
313
+ }
299
314
  entity.add(traits.BufferGeometry(geometry));
300
315
  entity.add(traits.Points);
301
316
  });
@@ -408,7 +423,7 @@ const updateShape = (entity, { physicalObject, metadata }) => {
408
423
  }
409
424
  }
410
425
  };
411
- const addColorTraits = (entity, colors) => {
426
+ export const addColorTraits = (entity, colors) => {
412
427
  if (isVertexColors(colors)) {
413
428
  entity.add(traits.Colors(colors));
414
429
  }
@@ -416,13 +431,20 @@ const addColorTraits = (entity, colors) => {
416
431
  entity.add(traits.Color(asRGB(colors, rgb)));
417
432
  }
418
433
  };
419
- const setColorTraits = (entity, colors) => {
434
+ export const setColorTraits = (entity, colors) => {
420
435
  if (isVertexColors(colors)) {
421
- entity.set(traits.Colors, colors);
436
+ if (entity.has(traits.Colors))
437
+ entity.set(traits.Colors, colors);
438
+ else
439
+ entity.add(traits.Colors(colors));
422
440
  entity.remove(traits.Color);
423
441
  }
424
442
  else {
425
- entity.set(traits.Color, asRGB(colors, rgb));
443
+ const color = asRGB(colors, rgb);
444
+ if (entity.has(traits.Color))
445
+ entity.set(traits.Color, color);
446
+ else
447
+ entity.add(traits.Color(color));
426
448
  entity.remove(traits.Colors);
427
449
  }
428
450
  };
@@ -1,6 +1,6 @@
1
1
  import type { GLTF as ThreeGltf } from 'three/examples/jsm/loaders/GLTFLoader.js';
2
2
  import { Geometry as ViamGeometry } from '@viamrobotics/sdk';
3
- import { type Entity } from 'koota';
3
+ import { type ConfigurableTrait, type Entity } from 'koota';
4
4
  import { BufferGeometry as ThreeBufferGeometry } from 'three';
5
5
  export declare const Name: import("koota").Trait<() => string>;
6
6
  export declare const Parent: import("koota").Trait<() => string>;
@@ -170,13 +170,21 @@ export declare const DotColors: import("koota").Trait<() => Uint8Array<ArrayBuff
170
170
  */
171
171
  export declare const DotSize: import("koota").Trait<() => number>;
172
172
  export declare const ReferenceFrame: import("koota").Trait<() => boolean>;
173
+ /**
174
+ * Tracks chunk loading progress for progressively-loaded entities.
175
+ * `loaded` is the number of elements received so far; `total` is the target.
176
+ */
177
+ export declare const ChunkProgress: import("koota").Trait<{
178
+ loaded: number;
179
+ total: number;
180
+ }>;
173
181
  /**
174
182
  * Interaction layers for entities
175
183
  */
176
184
  export type InteractionLayerValue = 'selectTool';
177
185
  export declare const SelectToolInteractionLayer: import("koota").Trait<() => boolean>;
178
186
  /**
179
- * This entity can be safetly removed from the scene by the user
187
+ * This entity can be safely removed from the scene by the user
180
188
  */
181
189
  export declare const Removable: import("koota").Trait<() => boolean>;
182
190
  export declare const Geometry: (geometry: ViamGeometry) => import("koota").Trait<() => boolean> | [import("koota").Trait<{
@@ -198,4 +206,6 @@ export declare const Geometry: (geometry: ViamGeometry) => import("koota").Trait
198
206
  }>, Partial<{
199
207
  r: number;
200
208
  }>] | [import("koota").Trait<() => ThreeBufferGeometry<import("three").NormalBufferAttributes, import("three").BufferGeometryEventMap>>, ThreeBufferGeometry<import("three").NormalBufferAttributes, import("three").BufferGeometryEventMap>];
209
+ export declare const getParentTrait: (parent: string | undefined) => ConfigurableTrait[];
210
+ export declare const setParentTrait: (entity: Entity, parent: string | undefined) => void;
201
211
  export declare const updateGeometryTrait: (entity: Entity, geometry?: ViamGeometry) => void;
@@ -124,9 +124,14 @@ export const DotColors = trait(() => new Uint8Array());
124
124
  */
125
125
  export const DotSize = trait(() => 10);
126
126
  export const ReferenceFrame = trait(() => true);
127
+ /**
128
+ * Tracks chunk loading progress for progressively-loaded entities.
129
+ * `loaded` is the number of elements received so far; `total` is the target.
130
+ */
131
+ export const ChunkProgress = trait({ loaded: 0, total: 0 });
127
132
  export const SelectToolInteractionLayer = trait(() => true);
128
133
  /**
129
- * This entity can be safetly removed from the scene by the user
134
+ * This entity can be safely removed from the scene by the user
130
135
  */
131
136
  export const Removable = trait(() => true);
132
137
  export const Geometry = (geometry) => {
@@ -144,6 +149,19 @@ export const Geometry = (geometry) => {
144
149
  }
145
150
  return ReferenceFrame;
146
151
  };
152
+ export const getParentTrait = (parent) => !parent || parent === 'world' ? [] : [Parent(parent)];
153
+ export const setParentTrait = (entity, parent) => {
154
+ if (!parent || parent === 'world') {
155
+ entity.remove(Parent);
156
+ return;
157
+ }
158
+ if (entity.has(Parent)) {
159
+ entity.set(Parent, parent);
160
+ }
161
+ else {
162
+ entity.add(Parent(parent));
163
+ }
164
+ };
147
165
  export const updateGeometryTrait = (entity, geometry) => {
148
166
  if (!geometry) {
149
167
  entity.remove(Box, Capsule, Sphere, BufferGeometry);
@@ -127,9 +127,7 @@ export const provideDrawAPI = () => {
127
127
  const existing = entities.get(name);
128
128
  if (existing) {
129
129
  existing.set(traits.Pose, pose);
130
- if (parent && parent !== 'world') {
131
- existing.set(traits.Parent, parent);
132
- }
130
+ traits.setParentTrait(existing, parent);
133
131
  continue;
134
132
  }
135
133
  const geometryTrait = () => {
@@ -144,10 +142,7 @@ export const provideDrawAPI = () => {
144
142
  }
145
143
  return traits.ReferenceFrame;
146
144
  };
147
- const entityTraits = [];
148
- if (parent && parent !== 'world') {
149
- entityTraits.push(traits.Parent(parent));
150
- }
145
+ const entityTraits = [...traits.getParentTrait(parent)];
151
146
  if (frame.geometry) {
152
147
  entityTraits.push(geometryTrait());
153
148
  }
@@ -181,11 +176,15 @@ export const provideDrawAPI = () => {
181
176
  }
182
177
  return traits.ReferenceFrame;
183
178
  };
184
- const entityTraits = [];
185
- if (parent && parent !== 'world') {
186
- entityTraits.push(traits.Parent(parent));
187
- }
188
- entityTraits.push(traits.Name(data.label ?? ++geometryIndex), traits.Pose(pose), traits.Color(colorUtil.set(color)), geometryTrait(), traits.DrawAPI, traits.Removable);
179
+ const entityTraits = [
180
+ traits.Name(data.label ?? ++geometryIndex),
181
+ ...traits.getParentTrait(parent),
182
+ traits.Pose(pose),
183
+ traits.Color(colorUtil.set(color)),
184
+ geometryTrait(),
185
+ traits.DrawAPI,
186
+ traits.Removable,
187
+ ];
189
188
  const entity = world.spawn(...entityTraits);
190
189
  entities.set(name, entity);
191
190
  };
@@ -2,17 +2,18 @@ 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 { resourceNameToColor } from '../color';
5
+ import { resourceNameToColor, subtypeToColor } from '../color';
6
6
  import { traits, useWorld } from '../ecs';
7
- import { updateGeometryTrait } from '../ecs/traits';
8
7
  import { createPose } from '../transform';
9
8
  import { useConfigFrames } from './useConfigFrames.svelte';
10
9
  import { useEnvironment } from './useEnvironment.svelte';
11
10
  import { useLogs } from './useLogs.svelte';
11
+ import { usePartConfig } from './usePartConfig.svelte';
12
12
  import { useResourceByName } from './useResourceByName.svelte';
13
13
  const key = Symbol('frames-context');
14
14
  export const provideFrames = (partID) => {
15
15
  const configFrames = useConfigFrames();
16
+ const partConfig = usePartConfig();
16
17
  const environment = useEnvironment();
17
18
  const world = useWorld();
18
19
  const resourceByName = useResourceByName();
@@ -20,6 +21,7 @@ export const provideFrames = (partID) => {
20
21
  const connectionStatus = useConnectionStatus(partID);
21
22
  const machineStatus = useMachineStatus(partID);
22
23
  const logs = useLogs();
24
+ const pendingSaveKey = $derived(`viam-pending-save-revision:${partID()}`);
23
25
  let didRecentlyEdit = $state(false);
24
26
  const isEditMode = $derived(environment.current.viewerMode === 'edit');
25
27
  const query = createRobotQuery(client, 'frameSystemConfig', () => ({
@@ -37,14 +39,19 @@ export const provideFrames = (partID) => {
37
39
  });
38
40
  const frames = $derived.by(() => {
39
41
  const frames = {};
40
- for (const { frame } of query.data ?? []) {
41
- if (frame === undefined) {
42
- continue;
42
+ if (!partConfig.hasPendingSave) {
43
+ for (const { frame } of query.data ?? []) {
44
+ if (frame === undefined) {
45
+ continue;
46
+ }
47
+ frames[frame.referenceFrame] = frame;
43
48
  }
44
- frames[frame.referenceFrame] = frame;
45
49
  }
46
- // Let config frames take priority if the user has made edits
47
- if (didRecentlyEdit || connectionStatus.current === MachineConnectionEvent.DISCONNECTED) {
50
+ // Let config frames take priority if the user has made edits,
51
+ // has a pending save, or is disconnected
52
+ if (didRecentlyEdit ||
53
+ partConfig.hasPendingSave ||
54
+ connectionStatus.current === MachineConnectionEvent.DISCONNECTED) {
48
55
  const mergedFrames = {
49
56
  ...frames,
50
57
  ...configFrames.current,
@@ -71,6 +78,45 @@ export const provideFrames = (partID) => {
71
78
  untrack(() => query.refetch());
72
79
  }
73
80
  });
81
+ $effect(() => {
82
+ const key = pendingSaveKey;
83
+ const storedRevision = sessionStorage.getItem(key);
84
+ if (!storedRevision) {
85
+ return;
86
+ }
87
+ if (!revision) {
88
+ if (!partConfig.hasPendingSave) {
89
+ partConfig.setPendingSave();
90
+ }
91
+ return;
92
+ }
93
+ if (revision === storedRevision) {
94
+ if (!partConfig.hasPendingSave) {
95
+ partConfig.setPendingSave();
96
+ }
97
+ return;
98
+ }
99
+ sessionStorage.removeItem(key);
100
+ partConfig.clearPendingSave();
101
+ didRecentlyEdit = true;
102
+ });
103
+ $effect(() => {
104
+ if (partConfig.hasPendingSave && revision) {
105
+ sessionStorage.setItem(pendingSaveKey, revision);
106
+ }
107
+ });
108
+ const componentSubtypeByName = $derived.by(() => {
109
+ const result = {};
110
+ for (const { name, api } of partConfig.current.components ?? []) {
111
+ if (api) {
112
+ const subtype = api.split(':').at(-1);
113
+ if (subtype) {
114
+ result[name] = subtype;
115
+ }
116
+ }
117
+ }
118
+ return result;
119
+ });
74
120
  $effect(() => {
75
121
  if (isEditMode) {
76
122
  didRecentlyEdit = true;
@@ -79,6 +125,7 @@ export const provideFrames = (partID) => {
79
125
  $effect.pre(() => {
80
126
  const currentResourcesByName = resourceByName.current;
81
127
  const currentPartID = partID();
128
+ const currentComponentSubtypeByName = componentSubtypeByName;
82
129
  // We only want to update whenever "current" or "resourceByName.current" changes
83
130
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
84
131
  current.length;
@@ -94,25 +141,18 @@ export const provideFrames = (partID) => {
94
141
  ? createPose(frame.physicalObject.center)
95
142
  : undefined;
96
143
  const resourceName = currentResourcesByName[frame.referenceFrame];
97
- const color = resourceNameToColor(resourceName);
144
+ const color = resourceNameToColor(resourceName) ??
145
+ subtypeToColor(currentComponentSubtypeByName[frame.referenceFrame]);
98
146
  const existing = entities.get(entityKey);
99
147
  if (existing) {
100
- if (!parent || parent === 'world') {
101
- existing.remove(traits.Parent);
102
- }
103
- else if (parent && existing.has(traits.Parent)) {
104
- existing.set(traits.Parent, parent);
105
- }
106
- else {
107
- existing.add(traits.Parent(parent));
108
- }
148
+ traits.setParentTrait(existing, parent);
109
149
  if (color) {
110
150
  existing.set(traits.Color, color);
111
151
  }
112
152
  if (center) {
113
153
  existing.set(traits.Center, center);
114
154
  }
115
- updateGeometryTrait(existing, frame.physicalObject);
155
+ traits.updateGeometryTrait(existing, frame.physicalObject);
116
156
  existing.set(traits.EditedPose, pose);
117
157
  continue;
118
158
  }
@@ -122,10 +162,8 @@ export const provideFrames = (partID) => {
122
162
  traits.EditedPose(pose),
123
163
  traits.FramesAPI,
124
164
  traits.ShowAxesHelper,
165
+ ...traits.getParentTrait(parent),
125
166
  ];
126
- if (parent && parent !== 'world') {
127
- entityTraits.push(traits.Parent(parent));
128
- }
129
167
  if (color) {
130
168
  entityTraits.push(traits.Color(color));
131
169
  }
@@ -1,4 +1,4 @@
1
- import { ArmClient, CameraClient, GantryClient, GripperClient } from '@viamrobotics/sdk';
1
+ import { ArmClient, BaseClient, CameraClient, GantryClient, 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';
@@ -20,12 +20,14 @@ export const provideGeometries = (partID) => {
20
20
  const world = useWorld();
21
21
  const logs = useLogs();
22
22
  const arms = useResourceNames(partID, 'arm');
23
+ const bases = useResourceNames(partID, 'base');
23
24
  const cameras = useResourceNames(partID, 'camera');
24
25
  const grippers = useResourceNames(partID, 'gripper');
25
26
  const gantries = useResourceNames(partID, 'gantry');
26
27
  const settings = useSettings();
27
28
  const { refreshRates } = $derived(settings.current);
28
29
  const armClients = $derived(arms.current.map((arm) => createResourceClient(ArmClient, partID, () => arm.name)));
30
+ const baseClients = $derived(bases.current.map((base) => createResourceClient(BaseClient, partID, () => base.name)));
29
31
  const gripperClients = $derived(grippers.current.map((gripper) => createResourceClient(GripperClient, partID, () => gripper.name)));
30
32
  const cameraClients = $derived(cameras.current.map((camera) => createResourceClient(CameraClient, partID, () => camera.name)));
31
33
  const gantryClients = $derived(gantries.current.map((gantry) => createResourceClient(GantryClient, partID, () => gantry.name)));
@@ -35,10 +37,17 @@ export const provideGeometries = (partID) => {
35
37
  refetchInterval: interval === RefetchRates.MANUAL ? false : interval,
36
38
  });
37
39
  const armQueries = $derived(armClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
40
+ const baseQueries = $derived(baseClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
38
41
  const gripperQueries = $derived(gripperClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
39
42
  const cameraQueries = $derived(cameraClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
40
43
  const gantryQueries = $derived(gantryClients.map((client) => [client.current?.name, createResourceQuery(client, 'getGeometries', () => options)]));
41
- const queries = $derived([...armQueries, ...gripperQueries, ...cameraQueries, ...gantryQueries]);
44
+ const queries = $derived([
45
+ ...armQueries,
46
+ ...baseQueries,
47
+ ...gripperQueries,
48
+ ...cameraQueries,
49
+ ...gantryQueries,
50
+ ]);
42
51
  $effect(() => {
43
52
  if (interval === RefetchRates.FPS_30 || interval === RefetchRates.FPS_60) {
44
53
  return logs.add(`Fetching geometries every ${interval}ms...`);
@@ -44,11 +44,11 @@ export const provideLogs = () => {
44
44
  }
45
45
  if (logs.length > 200) {
46
46
  const log = logs.pop();
47
- if (log && level === 'error') {
47
+ if (log?.level === 'error') {
48
48
  errors.splice(errors.indexOf(log), 1);
49
49
  }
50
- else if (log && level === 'warn') {
51
- warnings.splice(errors.indexOf(log), 1);
50
+ else if (log?.level === 'warn') {
51
+ warnings.splice(warnings.indexOf(log), 1);
52
52
  }
53
53
  }
54
54
  });
@@ -3,6 +3,7 @@ import { type Frame } from '../frame';
3
3
  export interface PartConfig {
4
4
  components: {
5
5
  name: string;
6
+ api?: string;
6
7
  frame?: Frame;
7
8
  }[];
8
9
  fragment_mods?: {
@@ -13,6 +14,7 @@ export interface PartConfig {
13
14
  interface PartConfigContext {
14
15
  current: PartConfig;
15
16
  isDirty: boolean;
17
+ hasPendingSave: boolean;
16
18
  hasEditPermissions: boolean;
17
19
  componentNameToFragmentId: Record<string, string>;
18
20
  updateFrame: (componentName: string, referenceFrame: string, pose: Pose, geometry?: Frame['geometry']) => void;
@@ -20,6 +22,8 @@ interface PartConfigContext {
20
22
  createFrame: (componentName: string) => void;
21
23
  save: () => void;
22
24
  discardChanges: () => void;
25
+ clearPendingSave: () => void;
26
+ setPendingSave: () => void;
23
27
  }
24
28
  export declare const providePartConfig: (partID: () => string, params: () => AppEmbeddedPartConfigProps | undefined) => void;
25
29
  export declare const usePartConfig: () => PartConfigContext;
@@ -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
  };