@viamrobotics/motion-tools 1.19.1 → 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.
Files changed (53) hide show
  1. package/dist/FrameConfigUpdater.svelte.d.ts +0 -1
  2. package/dist/FrameConfigUpdater.svelte.js +6 -24
  3. package/dist/buf/draw/v1/metadata_pb.d.ts +39 -0
  4. package/dist/buf/draw/v1/metadata_pb.js +55 -0
  5. package/dist/buf/draw/v1/service_connect.d.ts +34 -1
  6. package/dist/buf/draw/v1/service_connect.js +34 -1
  7. package/dist/buf/draw/v1/service_pb.d.ts +136 -0
  8. package/dist/buf/draw/v1/service_pb.js +201 -0
  9. package/dist/components/Entities/Arrows/ArrowGroups.svelte +1 -0
  10. package/dist/components/Entities/Arrows/Arrows.svelte +1 -1
  11. package/dist/components/Entities/Points.svelte +23 -23
  12. package/dist/components/Entities/Pose.svelte +18 -13
  13. package/dist/components/Entities/hooks/useEntityEvents.svelte.js +18 -1
  14. package/dist/components/FileDrop/FileDrop.svelte +8 -1
  15. package/dist/components/FileDrop/useFileDrop.svelte.js +16 -2
  16. package/dist/components/PCD.svelte +9 -1
  17. package/dist/components/PCD.svelte.d.ts +2 -0
  18. package/dist/components/PointerMissBox.svelte +1 -1
  19. package/dist/components/Scene.svelte +2 -0
  20. package/dist/components/SceneProviders.svelte +4 -0
  21. package/dist/components/SelectedTransformControls.svelte +227 -0
  22. package/dist/components/SelectedTransformControls.svelte.d.ts +3 -0
  23. package/dist/components/Snapshot.svelte +12 -7
  24. package/dist/components/StaticGeometries.svelte +3 -56
  25. package/dist/components/overlay/AddRelationship.svelte +25 -3
  26. package/dist/components/overlay/Details.svelte +290 -229
  27. package/dist/components/overlay/dashboard/Button.svelte +4 -2
  28. package/dist/components/overlay/dashboard/Button.svelte.d.ts +1 -1
  29. package/dist/components/overlay/dashboard/Dashboard.svelte +43 -33
  30. package/dist/draw.d.ts +22 -9
  31. package/dist/draw.js +71 -41
  32. package/dist/ecs/relations.js +1 -1
  33. package/dist/ecs/traits.d.ts +17 -0
  34. package/dist/ecs/traits.js +9 -0
  35. package/dist/editing/FrameEditSession.d.ts +37 -0
  36. package/dist/editing/FrameEditSession.js +178 -0
  37. package/dist/hooks/useDrawService.svelte.d.ts +2 -0
  38. package/dist/hooks/useDrawService.svelte.js +139 -20
  39. package/dist/hooks/useFrameEditSession.svelte.d.ts +15 -0
  40. package/dist/hooks/useFrameEditSession.svelte.js +36 -0
  41. package/dist/hooks/useFrames.svelte.js +37 -2
  42. package/dist/hooks/usePartConfig.svelte.js +10 -0
  43. package/dist/hooks/useRelationships.svelte.d.ts +12 -0
  44. package/dist/hooks/useRelationships.svelte.js +78 -0
  45. package/dist/hooks/useSettings.svelte.d.ts +1 -2
  46. package/dist/hooks/useSettings.svelte.js +1 -2
  47. package/dist/hooks/useWorldState.svelte.js +10 -4
  48. package/dist/metadata.d.ts +7 -3
  49. package/dist/metadata.js +26 -2
  50. package/dist/snapshot.d.ts +6 -1
  51. package/dist/snapshot.js +10 -5
  52. package/dist/transform.js +13 -0
  53. package/package.json +7 -4
@@ -5,13 +5,17 @@ import { useThrelte } from '@threlte/core';
5
5
  import {} from 'koota';
6
6
  import { getContext, setContext } from 'svelte';
7
7
  import { UuidTool } from 'uuid-tool';
8
+ import { writeBufferGeometryRange } from '../attribute';
8
9
  import { DrawService } from '../buf/draw/v1/service_connect';
9
- import { EntityChangeType, StreamEntityChangesResponse } from '../buf/draw/v1/service_pb';
10
- import { drawDrawing, drawTransform, updateDrawing, updateTransform, } from '../draw';
10
+ import { CreateRelationshipRequest, DeleteRelationshipRequest, EntityChangeType, StreamEntityChangesResponse, } from '../buf/draw/v1/service_pb';
11
+ import { asFloat32Array, inMeters, STRIDE } from '../buffer';
12
+ import { drawDrawing, drawTransform, updateDrawing, updateModel, updateTransform, uuidStringToBytes, } from '../draw';
11
13
  import { traits, useWorld } from '../ecs';
12
14
  import { useCameraControls } from './useControls.svelte';
13
15
  import { useDrawConnectionConfig } from './useDrawConnectionConfig.svelte';
16
+ import { useRelationships } from './useRelationships.svelte';
14
17
  const DRAW_SERVICE_KEY = Symbol('draw-service-context');
18
+ const FLOAT32_SIZE = 4;
15
19
  const ConnectionStatus = {
16
20
  CONNECTED: 'connected',
17
21
  DISCONNECTED: 'disconnected',
@@ -22,6 +26,7 @@ export function provideDrawService() {
22
26
  const world = useWorld();
23
27
  const cameraControls = useCameraControls();
24
28
  const drawConnectionConfig = useDrawConnectionConfig();
29
+ const relationships = useRelationships();
25
30
  let connectionStatus = $state(ConnectionStatus.DISCONNECTED);
26
31
  const url = $derived(drawConnectionConfig.current?.backendIP
27
32
  ? `http://${drawConnectionConfig.current.backendIP}:3030`
@@ -30,6 +35,9 @@ export function provideDrawService() {
30
35
  const drawingEntities = new Map();
31
36
  let pendingEvents = [];
32
37
  let flushScheduled = false;
38
+ let activeClient;
39
+ let activeSignal;
40
+ const activeChunkPulls = new Set();
33
41
  const destroyTransform = (uuidStr) => {
34
42
  const entity = transformEntities.get(uuidStr);
35
43
  if (!entity)
@@ -39,13 +47,11 @@ export function provideDrawService() {
39
47
  transformEntities.delete(uuidStr);
40
48
  };
41
49
  const destroyDrawing = (uuidStr) => {
42
- const entities = drawingEntities.get(uuidStr);
43
- if (!entities)
50
+ const entity = drawingEntities.get(uuidStr);
51
+ if (!entity)
44
52
  return;
45
- for (const entity of entities) {
46
- if (world.has(entity))
47
- entity.destroy();
48
- }
53
+ if (world.has(entity))
54
+ entity.destroy();
49
55
  drawingEntities.delete(uuidStr);
50
56
  };
51
57
  const processEvent = (event) => {
@@ -62,7 +68,9 @@ export function provideDrawService() {
62
68
  if (changeType === EntityChangeType.ADDED) {
63
69
  if (!transformEntities.has(uuid)) {
64
70
  const spawned = drawTransform(world, transform, traits.DrawServiceAPI);
65
- transformEntities.set(uuid, spawned);
71
+ relationships.apply(spawned.entity, spawned.relationships);
72
+ transformEntities.set(uuid, spawned.entity);
73
+ relationships.flush(uuid);
66
74
  }
67
75
  }
68
76
  else if (changeType === EntityChangeType.REMOVED) {
@@ -71,11 +79,76 @@ export function provideDrawService() {
71
79
  else if (changeType === EntityChangeType.UPDATED) {
72
80
  const existing = transformEntities.get(uuid);
73
81
  if (existing) {
74
- updateTransform(existing, transform);
82
+ const updated = updateTransform(existing, transform);
83
+ relationships.apply(updated.entity, updated.relationships);
75
84
  }
76
85
  else {
77
86
  const spawned = drawTransform(world, transform, traits.DrawServiceAPI);
78
- transformEntities.set(uuid, spawned);
87
+ relationships.apply(spawned.entity, spawned.relationships);
88
+ transformEntities.set(uuid, spawned.entity);
89
+ relationships.flush(uuid);
90
+ }
91
+ }
92
+ };
93
+ const isChunkedDrawing = (drawing) => {
94
+ return drawing.metadata?.chunks !== undefined && drawing.metadata.chunks.total > 0;
95
+ };
96
+ const getChunkInfo = (drawing) => {
97
+ const meta = drawing.metadata?.chunks;
98
+ if (!meta || meta.total === 0)
99
+ return undefined;
100
+ const shape = drawing.physicalObject?.geometryType;
101
+ if (shape?.case === 'points') {
102
+ const chunkElements = shape.value.positions.length / (STRIDE.POSITIONS * FLOAT32_SIZE);
103
+ return {
104
+ total: meta.total,
105
+ firstEnd: chunkElements,
106
+ };
107
+ }
108
+ return undefined;
109
+ };
110
+ const pullChunks = async (client, uuid, uuidBytes, entity, totalElements, firstChunkEnd, signal) => {
111
+ if (activeChunkPulls.has(uuid))
112
+ return;
113
+ activeChunkPulls.add(uuid);
114
+ try {
115
+ let nextStart = firstChunkEnd;
116
+ while (!signal.aborted) {
117
+ const response = await client.getEntityChunk({ uuid: uuidBytes, start: nextStart }, { signal });
118
+ // done with no payload is the server's "past end" sentinel (startByte >= posLen), not the final real chunk
119
+ if (response.done && !response.entity.value)
120
+ break;
121
+ const drawing = response.entity.case === 'drawing' ? response.entity.value : undefined;
122
+ if (!drawing)
123
+ break;
124
+ const shape = drawing.physicalObject?.geometryType;
125
+ if (shape?.case !== 'points')
126
+ break;
127
+ const buffer = entity.get(traits.BufferGeometry);
128
+ if (!buffer)
129
+ break;
130
+ const positions = asFloat32Array(shape.value.positions, inMeters);
131
+ const metadata = drawing.metadata;
132
+ if (!metadata)
133
+ break;
134
+ writeBufferGeometryRange(buffer, positions, response.start, metadata);
135
+ const chunkElements = positions.length / 3;
136
+ nextStart = response.start + chunkElements;
137
+ entity.set(traits.ChunkProgress, { loaded: nextStart, total: totalElements });
138
+ invalidate();
139
+ if (response.done)
140
+ break;
141
+ }
142
+ }
143
+ catch (error) {
144
+ if (!signal.aborted) {
145
+ console.error(`Chunk pull failed for entity ${uuid}:`, error);
146
+ }
147
+ }
148
+ finally {
149
+ activeChunkPulls.delete(uuid);
150
+ if (world.has(entity)) {
151
+ entity.remove(traits.ChunkProgress);
79
152
  }
80
153
  }
81
154
  };
@@ -83,7 +156,17 @@ export function provideDrawService() {
83
156
  if (changeType === EntityChangeType.ADDED) {
84
157
  if (!drawingEntities.has(uuid)) {
85
158
  const spawned = drawDrawing(world, drawing, traits.DrawServiceAPI);
86
- drawingEntities.set(uuid, spawned);
159
+ relationships.apply(spawned.entity, spawned.relationships);
160
+ drawingEntities.set(uuid, spawned.entity);
161
+ relationships.flush(uuid);
162
+ if (isChunkedDrawing(drawing) && activeClient && activeSignal) {
163
+ const chunk = getChunkInfo(drawing);
164
+ if (chunk) {
165
+ spawned.entity.add(traits.ChunkProgress({ loaded: chunk.firstEnd, total: chunk.total }));
166
+ const uuidBytes = drawing.uuid ?? new Uint8Array();
167
+ void pullChunks(activeClient, uuid, uuidBytes, spawned.entity, chunk.total, chunk.firstEnd, activeSignal);
168
+ }
169
+ }
87
170
  }
88
171
  }
89
172
  else if (changeType === EntityChangeType.REMOVED) {
@@ -92,12 +175,18 @@ export function provideDrawService() {
92
175
  else if (changeType === EntityChangeType.UPDATED) {
93
176
  const existing = drawingEntities.get(uuid);
94
177
  if (existing) {
95
- const next = updateDrawing(world, existing, drawing, traits.DrawServiceAPI);
96
- drawingEntities.set(uuid, next);
178
+ const isModel = drawing.physicalObject?.geometryType?.case === 'model';
179
+ const result = isModel
180
+ ? updateModel(world, existing, drawing, traits.DrawServiceAPI)
181
+ : updateDrawing(world, existing, drawing);
182
+ relationships.apply(result.entity, result.relationships);
183
+ drawingEntities.set(uuid, result.entity);
97
184
  }
98
185
  else {
99
186
  const spawned = drawDrawing(world, drawing, traits.DrawServiceAPI);
100
- drawingEntities.set(uuid, spawned);
187
+ relationships.apply(spawned.entity, spawned.relationships);
188
+ drawingEntities.set(uuid, spawned.entity);
189
+ relationships.flush(uuid);
101
190
  }
102
191
  }
103
192
  };
@@ -201,38 +290,68 @@ export function provideDrawService() {
201
290
  }
202
291
  }
203
292
  };
293
+ const createRelationship = async (sourceUuid, targetUuid, type, indexMapping) => {
294
+ if (!activeClient)
295
+ return;
296
+ const rel = {
297
+ targetUuid: uuidStringToBytes(targetUuid),
298
+ type,
299
+ };
300
+ if (indexMapping !== undefined) {
301
+ rel.indexMapping = indexMapping;
302
+ }
303
+ await activeClient.createRelationship(new CreateRelationshipRequest({
304
+ sourceUuid: uuidStringToBytes(sourceUuid),
305
+ relationship: rel,
306
+ }));
307
+ };
308
+ const deleteRelationship = async (sourceUuid, targetUuid) => {
309
+ if (!activeClient)
310
+ return;
311
+ await activeClient.deleteRelationship(new DeleteRelationshipRequest({
312
+ sourceUuid: uuidStringToBytes(sourceUuid),
313
+ targetUuid: uuidStringToBytes(targetUuid),
314
+ }));
315
+ };
204
316
  $effect(() => {
205
317
  if (!url) {
206
318
  connectionStatus = ConnectionStatus.DISCONNECTED;
319
+ activeClient = undefined;
207
320
  return;
208
321
  }
209
322
  const controller = new AbortController();
210
323
  connectionStatus = ConnectionStatus.CONNECTING;
211
324
  const transport = createConnectTransport({ baseUrl: url });
212
325
  const client = createClient(DrawService, transport);
326
+ activeClient = client;
327
+ activeSignal = controller.signal;
213
328
  void streamEntityChanges(client, controller.signal);
214
329
  void streamSceneChanges(client, controller.signal);
215
330
  return () => {
216
331
  controller.abort();
332
+ activeClient = undefined;
333
+ activeSignal = undefined;
217
334
  connectionStatus = ConnectionStatus.DISCONNECTED;
335
+ activeClient = undefined;
218
336
  for (const entity of transformEntities.values()) {
219
337
  if (world.has(entity))
220
338
  entity.destroy();
221
339
  }
222
340
  transformEntities.clear();
223
- for (const entities of drawingEntities.values()) {
224
- for (const entity of entities) {
225
- if (world.has(entity))
226
- entity.destroy();
227
- }
341
+ for (const entity of drawingEntities.values()) {
342
+ if (world.has(entity))
343
+ entity.destroy();
228
344
  }
229
345
  drawingEntities.clear();
346
+ relationships.clear();
230
347
  };
231
348
  });
232
349
  setContext(DRAW_SERVICE_KEY, {
233
350
  get connectionStatus() {
234
351
  return connectionStatus;
235
352
  },
353
+ createRelationship,
354
+ deleteRelationship,
236
355
  });
237
356
  }
238
357
  export function useDrawService() {
@@ -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
  }
@@ -0,0 +1,12 @@
1
+ import type { Entity } from 'koota';
2
+ import type { Relationship } from '../metadata';
3
+ export declare const provideRelationships: () => {
4
+ apply(entity: Entity, relationships: Relationship[] | undefined): void;
5
+ flush(targetUuid: string): void;
6
+ clear(): void;
7
+ };
8
+ export declare const useRelationships: () => {
9
+ apply(entity: Entity, relationships: Relationship[] | undefined): void;
10
+ flush(targetUuid: string): void;
11
+ clear(): void;
12
+ };
@@ -0,0 +1,78 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import { uuidBytesToString } from '../draw';
3
+ import { relations, traits, useQuery } from '../ecs';
4
+ const RELATIONSHIPS_CONTEXT_KEY = Symbol('relationships');
5
+ export const provideRelationships = () => {
6
+ const uuids = useQuery(traits.UUID);
7
+ const pending = new Map();
8
+ const addPending = (targetUuid, relationship) => {
9
+ const next = pending.get(targetUuid) ?? [];
10
+ next.push(relationship);
11
+ pending.set(targetUuid, next);
12
+ };
13
+ return setContext(RELATIONSHIPS_CONTEXT_KEY, {
14
+ apply(entity, relationships) {
15
+ const desired = relationships ?? [];
16
+ const currentTargets = entity.targetsFor(relations.SubEntityLink);
17
+ const desiredByUuid = new Map();
18
+ for (const rel of desired) {
19
+ const targetUUID = uuidBytesToString(rel.targetUuid);
20
+ if (!targetUUID)
21
+ continue;
22
+ desiredByUuid.set(targetUUID, rel);
23
+ }
24
+ for (const target of currentTargets) {
25
+ if (!target.isAlive())
26
+ continue;
27
+ const targetUuid = target.get(traits.UUID);
28
+ if (!targetUuid || !desiredByUuid.has(targetUuid)) {
29
+ entity.remove(relations.SubEntityLink(target));
30
+ }
31
+ }
32
+ for (const [uuid, relationship] of desiredByUuid) {
33
+ const targetEntity = uuids.current.find((e) => e.get(traits.UUID) === uuid);
34
+ if (!targetEntity) {
35
+ addPending(uuid, {
36
+ entity: entity,
37
+ type: relationship.type,
38
+ indexMapping: relationship.indexMapping,
39
+ });
40
+ continue;
41
+ }
42
+ const existing = entity.get(relations.SubEntityLink(targetEntity));
43
+ if (existing &&
44
+ existing.type === relationship.type &&
45
+ existing.indexMapping === (relationship.indexMapping ?? 'index')) {
46
+ continue;
47
+ }
48
+ entity.add(relations.SubEntityLink(targetEntity, {
49
+ type: relationship.type,
50
+ indexMapping: relationship.indexMapping ?? 'index',
51
+ }));
52
+ }
53
+ },
54
+ flush(targetUuid) {
55
+ const relationship = pending.get(targetUuid);
56
+ if (!relationship)
57
+ return;
58
+ pending.delete(targetUuid);
59
+ const targetEntity = uuids.current.find((e) => e.get(traits.UUID) === targetUuid);
60
+ if (!targetEntity)
61
+ return;
62
+ for (const { entity, type, indexMapping } of relationship) {
63
+ if (!entity.isAlive())
64
+ continue;
65
+ entity.add(relations.SubEntityLink(targetEntity, {
66
+ type,
67
+ indexMapping: indexMapping ?? 'index',
68
+ }));
69
+ }
70
+ },
71
+ clear() {
72
+ pending.clear();
73
+ },
74
+ });
75
+ };
76
+ export const useRelationships = () => {
77
+ return getContext(RELATIONSHIPS_CONTEXT_KEY);
78
+ };
@@ -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,
@@ -9,6 +9,7 @@ import { isPointCloud } from '../geometry';
9
9
  import { metadataFromStruct } from '../metadata';
10
10
  import { createPose } from '../transform';
11
11
  import { usePartID } from './usePartID.svelte';
12
+ import { useRelationships } from './useRelationships.svelte';
12
13
  export const provideWorldStates = () => {
13
14
  const partID = usePartID();
14
15
  const resourceNames = useResourceNames(() => partID.current, 'world_state_store');
@@ -83,6 +84,7 @@ const decodeWorldStateChunk = (response, fallbackStart) => {
83
84
  const createWorldState = (client) => {
84
85
  const { invalidate } = useThrelte();
85
86
  const world = useWorld();
87
+ const relationships = useRelationships();
86
88
  const entities = new Map();
87
89
  const chunkLoader = createChunkLoader({
88
90
  world,
@@ -105,10 +107,12 @@ const createWorldState = (client) => {
105
107
  if (entities.has(transform.uuidString)) {
106
108
  return;
107
109
  }
108
- const entity = drawTransform(world, transform, traits.WorldStateStoreAPI, { removable: false });
109
- entities.set(transform.uuidString, entity);
110
+ const spawned = drawTransform(world, transform, traits.WorldStateStoreAPI, { removable: false });
111
+ entities.set(transform.uuidString, spawned.entity);
112
+ relationships.apply(spawned.entity, spawned.relationships);
110
113
  const parsedMetadata = metadataFromStruct(transform.metadata?.fields);
111
- chunkLoader.start(transform.uuidString, entity, parsedMetadata);
114
+ chunkLoader.start(transform.uuidString, spawned.entity, parsedMetadata);
115
+ relationships.flush(transform.uuidString);
112
116
  if (isPointCloud(transform.physicalObject?.geometryType))
113
117
  invalidate();
114
118
  };
@@ -140,9 +144,11 @@ const createWorldState = (client) => {
140
144
  }
141
145
  }
142
146
  if (metadataDirty) {
143
- updateMetadata(entity, metadataFromStruct(transform.metadata?.fields), {
147
+ const parsedMetadata = metadataFromStruct(transform.metadata?.fields);
148
+ updateMetadata(entity, parsedMetadata, {
144
149
  pointCloud: isPointCloud(transform.physicalObject?.geometryType),
145
150
  });
151
+ relationships.apply(entity, parsedMetadata.relationships);
146
152
  }
147
153
  };
148
154
  let initialized = false;
@@ -1,7 +1,11 @@
1
1
  import type { PlainMessage, Struct } from '@viamrobotics/sdk';
2
- import { Metadata as MetadataProto } from './buf/draw/v1/metadata_pb';
3
- /** Metadata for a `Drawing` or `Transform`. */
4
- export type Metadata = PlainMessage<MetadataProto>;
2
+ import { Metadata as MetadataProto, type Relationship as RelationshipProto } from './buf/draw/v1/metadata_pb';
3
+ /** Metadata for a `Drawing` or `Transform`. Relationships default to empty. */
4
+ export type Metadata = Omit<PlainMessage<MetadataProto>, 'relationships'> & {
5
+ relationships?: PlainMessage<MetadataProto>['relationships'];
6
+ };
7
+ /** Plain-object representation of a Relationship, usable outside proto classes. */
8
+ export type Relationship = PlainMessage<RelationshipProto>;
5
9
  /** Type guard that checks whether a string is a recognised metadata wire key. */
6
10
  export declare const isMetadataField: (key: string) => boolean;
7
11
  /**
package/dist/metadata.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ColorFormat, Metadata as MetadataProto } from './buf/draw/v1/metadata_pb';
1
+ import { ColorFormat, Metadata as MetadataProto, } from './buf/draw/v1/metadata_pb';
2
2
  /** Type guard that checks whether a string is a recognised metadata wire key. */
3
3
  export const isMetadataField = (key) => {
4
4
  return (key === 'colors' ||
@@ -6,7 +6,8 @@ export const isMetadataField = (key) => {
6
6
  key === 'opacities' ||
7
7
  key === 'show_axes_helper' ||
8
8
  key === 'invisible' ||
9
- key === 'chunks');
9
+ key === 'chunks' ||
10
+ key === 'relationships');
10
11
  };
11
12
  /**
12
13
  * Extracts typed {@link Metadata} from a proto `Struct` fields map.
@@ -77,6 +78,29 @@ export const metadataFromStruct = (fields = {}) => {
77
78
  }
78
79
  break;
79
80
  }
81
+ case 'relationships': {
82
+ if (Array.isArray(unwrappedValue)) {
83
+ json.relationships = unwrappedValue
84
+ .filter((item) => typeof item === 'object' && item !== null)
85
+ .map((item) => {
86
+ const targetUuidStr = item['target_uuid'];
87
+ let targetUuid = new Uint8Array();
88
+ if (typeof targetUuidStr === 'string' && targetUuidStr.length > 0) {
89
+ const binary = atob(targetUuidStr);
90
+ targetUuid = new Uint8Array(binary.length);
91
+ for (let i = 0; i < binary.length; i++) {
92
+ targetUuid[i] = binary.charCodeAt(i);
93
+ }
94
+ }
95
+ return {
96
+ targetUuid,
97
+ type: typeof item['type'] === 'string' ? item['type'] : '',
98
+ indexMapping: typeof item['index_mapping'] === 'string' ? item['index_mapping'] : undefined,
99
+ };
100
+ });
101
+ }
102
+ break;
103
+ }
80
104
  }
81
105
  }
82
106
  return json;
@@ -2,6 +2,11 @@ import type { Entity, World } from 'koota';
2
2
  import type { Snapshot } from './buf/draw/v1/snapshot_pb';
3
3
  import type { Settings } from './hooks/useSettings.svelte';
4
4
  import { type SceneMetadata } from './buf/draw/v1/scene_pb';
5
+ import type { Relationship } from './metadata';
6
+ export type SnapshotEntity = {
7
+ entity: Entity;
8
+ relationships: Relationship[] | undefined;
9
+ };
5
10
  /**
6
11
  * Merges scene-level metadata (grid, camera, point/line settings) into the
7
12
  * current viewer settings. Millimeter values from the proto are converted
@@ -18,4 +23,4 @@ export declare const applySceneMetadata: (settings: Settings, metadata: SceneMet
18
23
  *
19
24
  * @returns The spawned entities
20
25
  */
21
- export declare const spawnSnapshotEntities: (world: World, snapshot: Snapshot) => Entity[];
26
+ export declare const spawnSnapshotEntities: (world: World, snapshot: Snapshot) => SnapshotEntity[];