@viamrobotics/motion-tools 1.33.1 → 1.33.2

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.
@@ -127,9 +127,19 @@
127
127
  let geometryTabIndex = $derived(geometryTypes.indexOf(geometryType))
128
128
 
129
129
  $effect(() => {
130
- // setGeometryType guards against no-ops, so this is safe to fire on every
131
- // tab-index change (whether user-initiated or trait-derived).
132
- detailConfigUpdater.setGeometryType(entity, geometryTypes[geometryTabIndex])
130
+ const nextType = geometryTypes[geometryTabIndex]
131
+
132
+ /**
133
+ * geometryTabIndex is derived from the entity's geometry traits, so on
134
+ * selection (or any trait-driven recompute) nextType already equals
135
+ * geometryType — firing then would call updateFrame, dirtying the part
136
+ * config and resetting the geometry to default dimensions. Only a user
137
+ * tab pick sets geometryTabIndex ahead of the trait, so guard on the two
138
+ * differing to fire solely for user-initiated changes.
139
+ */
140
+ if (nextType === geometryType) return
141
+
142
+ detailConfigUpdater.setGeometryType(entity, nextType)
133
143
  })
134
144
 
135
145
  let copied = $state(false)
@@ -105,12 +105,14 @@ export const providePartConfig = (partID, params) => {
105
105
  y: pose.y ?? currentPose.y,
106
106
  z: pose.z ?? currentPose.z,
107
107
  };
108
- component.frame.orientation.type = 'ov_degrees';
109
- component.frame.orientation.value = {
110
- x: pose.oX ?? currentPose.oX,
111
- y: pose.oY ?? currentPose.oY,
112
- z: pose.oZ ?? currentPose.oZ,
113
- th: pose.theta ?? currentPose.theta,
108
+ component.frame.orientation = {
109
+ type: 'ov_degrees',
110
+ value: {
111
+ x: pose.oX ?? currentPose.oX,
112
+ y: pose.oY ?? currentPose.oY,
113
+ z: pose.oZ ?? currentPose.oZ,
114
+ th: pose.theta ?? currentPose.theta,
115
+ },
114
116
  };
115
117
  if (geometry) {
116
118
  if (geometry.type === 'none') {
@@ -5,7 +5,7 @@ import { Matrix4 } from 'three';
5
5
  import { asFloat32Array, inMeters } from '../buffer';
6
6
  import { createChunkLoader } from '../chunking';
7
7
  import { drawTransform, updateMetadata } from '../draw';
8
- import { traits, useWorld } from '../ecs';
8
+ import { hierarchy, traits, useWorld } from '../ecs';
9
9
  import { isPointCloud } from '../geometry';
10
10
  import { metadataFromStruct } from '../metadata';
11
11
  import { createPose, poseToMatrix } from '../transform';
@@ -27,6 +27,10 @@ export const provideWorldStates = () => {
27
27
  };
28
28
  });
29
29
  };
30
+ // FieldMask paths are proto field names; spec-compliant backends emit
31
+ // snake_case (`pose_in_observer_frame`) while some emit camelCase. Normalize
32
+ // to camelCase so matching against the message's accessors is casing-agnostic.
33
+ const snakeToCamel = (path) => path.replaceAll(/_([a-z])/g, (_, char) => char.toUpperCase());
30
34
  const decodeBase64 = (encoded) => {
31
35
  const binary = atob(encoded);
32
36
  const bytes = new Uint8Array(binary.length);
@@ -87,6 +91,11 @@ const createWorldState = (client) => {
87
91
  const world = useWorld();
88
92
  const relationships = useRelationships();
89
93
  const entities = new Map();
94
+ // UUIDs the stream has removed; guards against a stale initial snapshot or a
95
+ // self-heal fetch re-creating an entity the server has already deleted.
96
+ const removedUUIDs = new Set();
97
+ // UUIDs with an in-flight self-heal `getTransform`, to dedupe concurrent fetches.
98
+ const pendingSpawns = new Set();
90
99
  const chunkLoader = createChunkLoader({
91
100
  world,
92
101
  invalidate,
@@ -105,7 +114,7 @@ const createWorldState = (client) => {
105
114
  },
106
115
  });
107
116
  const spawnEntity = (transform) => {
108
- if (entities.has(transform.uuidString)) {
117
+ if (entities.has(transform.uuidString) || removedUUIDs.has(transform.uuidString)) {
109
118
  return;
110
119
  }
111
120
  const spawned = drawTransform(world, transform, traits.WorldStateStoreAPI, { removable: false });
@@ -118,6 +127,7 @@ const createWorldState = (client) => {
118
127
  invalidate();
119
128
  };
120
129
  const destroyEntity = (uuid) => {
130
+ removedUUIDs.add(uuid);
121
131
  const entity = entities.get(uuid);
122
132
  if (!entity)
123
133
  return;
@@ -126,29 +136,54 @@ const createWorldState = (client) => {
126
136
  }
127
137
  entities.delete(uuid);
128
138
  };
139
+ // Spawn an entity whose UPDATE delta arrived before the initial snapshot
140
+ // created it. The delta carries only changed fields, so fetch the full
141
+ // transform; skip if it was removed or already spawned meanwhile.
142
+ const spawnFromServer = async (uuid) => {
143
+ if (entities.has(uuid) || removedUUIDs.has(uuid) || pendingSpawns.has(uuid))
144
+ return;
145
+ pendingSpawns.add(uuid);
146
+ try {
147
+ const transform = await client.current?.getTransform(uuid);
148
+ if (transform && !removedUUIDs.has(uuid)) {
149
+ spawnEntity(transform);
150
+ invalidate();
151
+ }
152
+ }
153
+ catch (error) {
154
+ console.error('World state self-heal failed for', uuid, error);
155
+ }
156
+ finally {
157
+ pendingSpawns.delete(uuid);
158
+ }
159
+ };
129
160
  const updateEntity = (transform, changes) => {
130
161
  const entity = entities.get(transform.uuidString);
131
- if (!entity)
162
+ if (!entity) {
163
+ void spawnFromServer(transform.uuidString);
132
164
  return;
165
+ }
133
166
  let metadataDirty = false;
134
- for (const path of changes) {
135
- if (typeof path === 'string') {
136
- if (path.startsWith('poseInObserverFrame.pose')) {
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
- }
167
+ for (const rawPath of changes) {
168
+ if (typeof rawPath !== 'string')
169
+ continue;
170
+ const path = snakeToCamel(rawPath);
171
+ if (path.startsWith('poseInObserverFrame')) {
172
+ const matrix = entity.get(traits.Matrix);
173
+ if (matrix) {
174
+ poseToMatrix(createPose(transform.poseInObserverFrame?.pose), matrix);
175
+ entity.changed(traits.Matrix);
145
176
  }
146
- else if (path.startsWith('physicalObject') && transform.physicalObject) {
147
- traits.updateGeometryTrait(entity, transform.physicalObject);
148
- }
149
- else if (path.startsWith('metadata')) {
150
- metadataDirty = true;
177
+ else {
178
+ entity.add(traits.Matrix(poseToMatrix(createPose(transform.poseInObserverFrame?.pose), new Matrix4())));
151
179
  }
180
+ hierarchy.setParent(entity, transform.poseInObserverFrame?.referenceFrame);
181
+ }
182
+ else if (path.startsWith('physicalObject') && transform.physicalObject) {
183
+ traits.updateGeometryTrait(entity, transform.physicalObject);
184
+ }
185
+ else if (path.startsWith('metadata')) {
186
+ metadataDirty = true;
152
187
  }
153
188
  }
154
189
  if (metadataDirty) {
@@ -170,6 +205,7 @@ const createWorldState = (client) => {
170
205
  const applyEvents = (events) => {
171
206
  for (const event of events) {
172
207
  if (event.changeType === TransformChangeType.ADDED) {
208
+ removedUUIDs.delete(event.transform.uuidString);
173
209
  spawnEntity(event.transform);
174
210
  }
175
211
  else if (event.changeType === TransformChangeType.REMOVED) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/motion-tools",
3
- "version": "1.33.1",
3
+ "version": "1.33.2",
4
4
  "description": "Motion visualization with Viam",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -85,7 +85,7 @@
85
85
  "vite-plugin-devtools-json": "1.0.0",
86
86
  "vite-plugin-glsl": "^1.5.5",
87
87
  "vite-plugin-mkcert": "1.17.9",
88
- "vitest": "3.2.4"
88
+ "vitest": "3.2.6"
89
89
  },
90
90
  "peerDependencies": {
91
91
  "@ag-grid-community/client-side-row-model": ">=32.3.0",