@viamrobotics/motion-tools 1.0.2 → 1.0.3

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 (29) hide show
  1. package/dist/components/App.svelte +1 -1
  2. package/dist/components/Entities.svelte +7 -0
  3. package/dist/components/FileDrop/FileDrop.svelte +96 -0
  4. package/dist/components/FileDrop/FileDrop.svelte.d.ts +4 -0
  5. package/dist/components/FileDrop/file-dropper.d.ts +36 -0
  6. package/dist/components/FileDrop/file-dropper.js +6 -0
  7. package/dist/components/FileDrop/file-names.d.ts +27 -0
  8. package/dist/components/FileDrop/file-names.js +85 -0
  9. package/dist/components/FileDrop/pcd-dropper.d.ts +2 -0
  10. package/dist/components/FileDrop/pcd-dropper.js +27 -0
  11. package/dist/components/FileDrop/ply-dropper.d.ts +2 -0
  12. package/dist/components/FileDrop/ply-dropper.js +27 -0
  13. package/dist/components/FileDrop/snapshot-dropper.d.ts +2 -0
  14. package/dist/components/FileDrop/snapshot-dropper.js +96 -0
  15. package/dist/components/FileDrop/useFileDrop.svelte.d.ts +9 -0
  16. package/dist/components/FileDrop/useFileDrop.svelte.js +97 -0
  17. package/dist/components/GLTF.svelte +4 -4
  18. package/dist/components/Geometry2.svelte +15 -10
  19. package/dist/components/Line.svelte +5 -1
  20. package/dist/components/Tree/Tree.svelte +33 -26
  21. package/dist/components/Tree/Tree.svelte.d.ts +1 -1
  22. package/dist/components/Tree/TreeContainer.svelte +6 -3
  23. package/dist/components/Tree/buildTree.d.ts +5 -1
  24. package/dist/components/Tree/buildTree.js +5 -4
  25. package/dist/ecs/traits.d.ts +4 -0
  26. package/dist/ecs/traits.js +4 -0
  27. package/package.json +2 -1
  28. package/dist/components/FileDrop.svelte +0 -144
  29. package/dist/components/FileDrop.svelte.d.ts +0 -3
@@ -13,7 +13,7 @@
13
13
  import Dashboard from './dashboard/Dashboard.svelte'
14
14
  import { domPortal } from '../portal'
15
15
  import { provideSettings } from '../hooks/useSettings.svelte'
16
- import FileDrop from './FileDrop.svelte'
16
+ import FileDrop from './FileDrop/FileDrop.svelte'
17
17
  import { provideWeblabs } from '../hooks/useWeblabs.svelte'
18
18
  import { providePartConfig } from '../hooks/usePartConfig.svelte'
19
19
  import { useViamClient } from '@viamrobotics/svelte-sdk'
@@ -13,6 +13,7 @@
13
13
  const points = useQuery(traits.PointsGeometry)
14
14
  const lines = useQuery(traits.LineGeometry)
15
15
  const gltfs = useQuery(traits.GLTF)
16
+ const droppedMeshes = useQuery(traits.DroppedFile, traits.BufferGeometry)
16
17
  const drawnMeshes = useQuery(
17
18
  traits.DrawAPI,
18
19
  Or(traits.Box, traits.Capsule, traits.Sphere, traits.BufferGeometry, traits.ReferenceFrame)
@@ -35,6 +36,12 @@
35
36
  </Frame>
36
37
  {/each}
37
38
 
39
+ {#each droppedMeshes.current as entity (entity)}
40
+ <Frame {entity}>
41
+ <Label text={entity.get(traits.Name)} />
42
+ </Frame>
43
+ {/each}
44
+
38
45
  {#each points.current as entity (entity)}
39
46
  <Pointcloud {entity}>
40
47
  <Label text={entity.get(traits.Name)} />
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements'
3
+ import { useToast, ToastVariant } from '@viamrobotics/prime-core'
4
+ import { useFileDrop } from './useFileDrop.svelte'
5
+ import { useWorld } from '../../ecs/useWorld'
6
+ import type { FileDropperSuccess } from './file-dropper'
7
+ import { traits } from '../../ecs'
8
+ import { parseMetadata } from '../../WorldObject.svelte'
9
+ import type { Snapshot } from '../../draw/v1/snapshot_pb'
10
+
11
+ const props: HTMLAttributes<HTMLDivElement> = $props()
12
+
13
+ const world = useWorld()
14
+ const toast = useToast()
15
+
16
+ const addSnapshotToWorld = (snapshot: Snapshot) => {
17
+ for (const transform of snapshot.transforms) {
18
+ const entity = world.spawn(
19
+ traits.Name(transform.referenceFrame),
20
+ traits.Pose(transform.poseInObserverFrame?.pose),
21
+ traits.Parent(transform.poseInObserverFrame?.referenceFrame)
22
+ )
23
+
24
+ if (transform.physicalObject) {
25
+ entity.add(traits.Geometry(transform.physicalObject))
26
+ }
27
+
28
+ if (transform.metadata) {
29
+ const metadata = parseMetadata(transform.metadata.fields)
30
+ if (metadata.color) {
31
+ entity.add(traits.Color(metadata.color))
32
+ }
33
+ }
34
+ }
35
+
36
+ for (const drawing of snapshot.drawings) {
37
+ world.spawn(
38
+ traits.Name(drawing.referenceFrame),
39
+ traits.Pose(drawing.poseInObserverFrame?.pose),
40
+ traits.Parent(drawing.poseInObserverFrame?.referenceFrame)
41
+ // TODO: Add shape
42
+ )
43
+
44
+ if (drawing.metadata) {
45
+ // add shape colors
46
+ }
47
+ }
48
+ }
49
+
50
+ const fileDrop = useFileDrop(
51
+ (result: FileDropperSuccess) => {
52
+ switch (result.type) {
53
+ case 'snapshot': {
54
+ addSnapshotToWorld(result.snapshot)
55
+ break
56
+ }
57
+ case 'pcd':
58
+ world.spawn(
59
+ traits.Name(result.name),
60
+ traits.PointsGeometry(result.pcd.positions),
61
+ result.pcd.colors ? traits.VertexColors(result.pcd.colors) : traits.Color,
62
+ traits.DroppedFile
63
+ )
64
+ break
65
+ case 'ply':
66
+ world.spawn(
67
+ traits.Name(result.name),
68
+ traits.BufferGeometry(result.ply),
69
+ traits.DroppedFile
70
+ )
71
+ break
72
+ }
73
+
74
+ toast({ message: `${result.name} loaded.`, variant: ToastVariant.Success })
75
+ },
76
+ (message) => toast({ message, variant: ToastVariant.Danger })
77
+ )
78
+ </script>
79
+
80
+ <svelte:window
81
+ ondragenter={fileDrop.ondragenter}
82
+ ondragleave={fileDrop.ondragleave}
83
+ ondragover={fileDrop.ondragover}
84
+ />
85
+
86
+ <div
87
+ class={{
88
+ 'fixed inset-0 z-9999': true,
89
+ 'pointer-events-none': fileDrop.dropState === 'inactive',
90
+ 'bg-black/10': fileDrop.dropState !== 'inactive',
91
+ }}
92
+ role="region"
93
+ aria-label="File drop zone"
94
+ ondrop={fileDrop.ondrop}
95
+ {...props}
96
+ ></div>
@@ -0,0 +1,4 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ declare const FileDrop: import("svelte").Component<HTMLAttributes<HTMLDivElement>, {}, "">;
3
+ type FileDrop = ReturnType<typeof FileDrop>;
4
+ export default FileDrop;
@@ -0,0 +1,36 @@
1
+ import type { Snapshot } from '../../draw/v1/snapshot_pb';
2
+ import type { SuccessMessage } from '../../loaders/pcd/worker';
3
+ import type { BufferGeometry } from 'three';
4
+ interface FileDropSuccess {
5
+ success: true;
6
+ name: string;
7
+ }
8
+ export interface SnapshotFileDropSuccess extends FileDropSuccess {
9
+ type: 'snapshot';
10
+ snapshot: Snapshot;
11
+ }
12
+ export interface PointcloudFileDropSuccess extends FileDropSuccess {
13
+ type: 'pcd';
14
+ pcd: SuccessMessage;
15
+ }
16
+ export interface PlyFileDropSuccess extends FileDropSuccess {
17
+ type: 'ply';
18
+ ply: BufferGeometry;
19
+ }
20
+ export declare class FileDropperError extends Error {
21
+ constructor(message: string, options?: ErrorOptions);
22
+ }
23
+ export type FileDropperSuccess = SnapshotFileDropSuccess | PointcloudFileDropSuccess | PlyFileDropSuccess;
24
+ export interface FileDropperFailure {
25
+ success: false;
26
+ error: FileDropperError;
27
+ }
28
+ export type FileDropperResult = FileDropperSuccess | FileDropperFailure;
29
+ export type FileDropperParams = {
30
+ name: string;
31
+ extension: string;
32
+ prefix: string | undefined;
33
+ content: string | ArrayBuffer | null | undefined;
34
+ };
35
+ export type FileDropper = (params: FileDropperParams) => Promise<FileDropperResult>;
36
+ export {};
@@ -0,0 +1,6 @@
1
+ export class FileDropperError extends Error {
2
+ constructor(message, options) {
3
+ super(message, options);
4
+ this.name = 'FileDropperError';
5
+ }
6
+ }
@@ -0,0 +1,27 @@
1
+ import type { ValueOf } from 'type-fest';
2
+ export declare const Extensions: {
3
+ readonly JSON: "json";
4
+ readonly PCD: "pcd";
5
+ readonly PLY: "ply";
6
+ readonly PB: "pb";
7
+ readonly PB_GZ: "pb.gz";
8
+ };
9
+ export declare const Prefixes: {
10
+ readonly Snapshot: "snapshot";
11
+ };
12
+ declare class FileNameError extends Error {
13
+ constructor(message: string, options?: ErrorOptions);
14
+ }
15
+ interface ParseFileSuccess {
16
+ success: true;
17
+ extension: ValueOf<typeof Extensions>;
18
+ prefix: ValueOf<typeof Prefixes> | undefined;
19
+ }
20
+ interface ParseFileError {
21
+ success: false;
22
+ error: FileNameError;
23
+ }
24
+ type ParseFileResult = ParseFileSuccess | ParseFileError;
25
+ export declare const parseFileName: (filename: string) => ParseFileResult;
26
+ export declare const readFile: (file: File, reader: FileReader, extension: ValueOf<typeof Extensions> | undefined) => void;
27
+ export {};
@@ -0,0 +1,85 @@
1
+ export const Extensions = {
2
+ JSON: 'json',
3
+ PCD: 'pcd',
4
+ PLY: 'ply',
5
+ PB: 'pb',
6
+ PB_GZ: 'pb.gz',
7
+ };
8
+ export const Prefixes = {
9
+ Snapshot: 'snapshot',
10
+ };
11
+ class FileNameError extends Error {
12
+ constructor(message, options) {
13
+ super(message, options);
14
+ this.name = 'FileNameError';
15
+ }
16
+ }
17
+ const isExtension = (extension) => {
18
+ return Object.values(Extensions).includes(extension);
19
+ };
20
+ const isPrefix = (prefix) => {
21
+ if (!prefix)
22
+ return false;
23
+ return Object.values(Prefixes).includes(prefix);
24
+ };
25
+ const validatePrefix = (extension, prefix) => {
26
+ switch (prefix) {
27
+ case Prefixes.Snapshot:
28
+ if (extension !== Extensions.JSON &&
29
+ extension !== Extensions.PB &&
30
+ extension !== Extensions.PB_GZ) {
31
+ return new FileNameError(`Only ${Extensions.JSON}, ${Extensions.PB} and ${Extensions.PB_GZ} snapshot files are supported.`);
32
+ }
33
+ break;
34
+ }
35
+ return undefined;
36
+ };
37
+ export const parseFileName = (filename) => {
38
+ const [name, ...extensions] = filename.split('.');
39
+ const suffix = extensions.at(-1);
40
+ if (!suffix) {
41
+ return {
42
+ success: false,
43
+ error: new FileNameError('Could not determine file extension.'),
44
+ };
45
+ }
46
+ const nested = extensions.at(-2);
47
+ let extension = suffix;
48
+ if (nested) {
49
+ const nestedExtension = `${nested}.${suffix}`;
50
+ if (isExtension(nestedExtension)) {
51
+ extension = nestedExtension;
52
+ }
53
+ }
54
+ if (!isExtension(extension)) {
55
+ return {
56
+ success: false,
57
+ error: new FileNameError(`Only ${Object.values(Extensions).join(', ')} files are supported.`),
58
+ };
59
+ }
60
+ const prefix = name.split('_').at(0);
61
+ if (isPrefix(prefix)) {
62
+ const error = validatePrefix(extension, prefix);
63
+ if (error) {
64
+ return {
65
+ success: false,
66
+ error,
67
+ };
68
+ }
69
+ return { success: true, extension, prefix };
70
+ }
71
+ return { success: true, extension, prefix: undefined };
72
+ };
73
+ export const readFile = (file, reader, extension) => {
74
+ if (!extension)
75
+ return;
76
+ switch (extension) {
77
+ case Extensions.JSON:
78
+ return reader.readAsText(file);
79
+ case Extensions.PCD:
80
+ case Extensions.PLY:
81
+ case Extensions.PB:
82
+ case Extensions.PB_GZ:
83
+ return reader.readAsArrayBuffer(file);
84
+ }
85
+ };
@@ -0,0 +1,2 @@
1
+ import { type FileDropper } from './file-dropper';
2
+ export declare const pcdDropper: FileDropper;
@@ -0,0 +1,27 @@
1
+ import { isArrayBuffer } from 'lodash-es';
2
+ import { FileDropperError } from './file-dropper';
3
+ import { parsePcdInWorker } from '../../loaders/pcd';
4
+ export const pcdDropper = async (params) => {
5
+ const { name, content } = params;
6
+ if (!isArrayBuffer(content)) {
7
+ return {
8
+ success: false,
9
+ error: new FileDropperError(`${name} failed to load.`),
10
+ };
11
+ }
12
+ try {
13
+ const result = await parsePcdInWorker(new Uint8Array(content));
14
+ return {
15
+ success: true,
16
+ name,
17
+ type: 'pcd',
18
+ pcd: result,
19
+ };
20
+ }
21
+ catch (error) {
22
+ return {
23
+ success: false,
24
+ error: new FileDropperError(`${name} failed to parse.`, { cause: error }),
25
+ };
26
+ }
27
+ };
@@ -0,0 +1,2 @@
1
+ import { type FileDropper } from './file-dropper';
2
+ export declare const plyDropper: FileDropper;
@@ -0,0 +1,27 @@
1
+ import { isArrayBuffer } from 'lodash-es';
2
+ import { FileDropperError } from './file-dropper';
3
+ import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
4
+ export const plyDropper = async (params) => {
5
+ const { name, content } = params;
6
+ if (!isArrayBuffer(content)) {
7
+ return {
8
+ success: false,
9
+ error: new FileDropperError(`${name} failed to load.`),
10
+ };
11
+ }
12
+ try {
13
+ const geometry = new PLYLoader().parse(new TextDecoder().decode(content));
14
+ return {
15
+ success: true,
16
+ name,
17
+ type: 'ply',
18
+ ply: geometry,
19
+ };
20
+ }
21
+ catch (error) {
22
+ return {
23
+ success: false,
24
+ error: new FileDropperError(`${name} failed to parse.`, { cause: error }),
25
+ };
26
+ }
27
+ };
@@ -0,0 +1,2 @@
1
+ import { type FileDropper } from './file-dropper';
2
+ export declare const snapshotDropper: FileDropper;
@@ -0,0 +1,96 @@
1
+ import { Snapshot } from '../../draw/v1/snapshot_pb';
2
+ import { isArrayBuffer, isString } from 'lodash-es';
3
+ import { FileDropperError, } from './file-dropper';
4
+ import { Extensions } from './file-names';
5
+ const decodeJson = (params) => {
6
+ const { name, content } = params;
7
+ if (!isString(content)) {
8
+ return {
9
+ success: false,
10
+ error: new FileDropperError(`${name} failed to load.`),
11
+ };
12
+ }
13
+ try {
14
+ const snapshot = Snapshot.fromJsonString(content);
15
+ return {
16
+ success: true,
17
+ name,
18
+ type: 'snapshot',
19
+ snapshot,
20
+ };
21
+ }
22
+ catch (error) {
23
+ return {
24
+ success: false,
25
+ error: new FileDropperError(`${name} failed to parse.`, { cause: error }),
26
+ };
27
+ }
28
+ };
29
+ const decodeBinary = (params) => {
30
+ const { name, content } = params;
31
+ if (!isArrayBuffer(content)) {
32
+ return {
33
+ success: false,
34
+ error: new FileDropperError(`${name} failed to load.`),
35
+ };
36
+ }
37
+ try {
38
+ const snapshot = Snapshot.fromBinary(new Uint8Array(content));
39
+ return {
40
+ success: true,
41
+ name,
42
+ type: 'snapshot',
43
+ snapshot,
44
+ };
45
+ }
46
+ catch (error) {
47
+ return {
48
+ success: false,
49
+ error: new FileDropperError(`${name} failed to parse.`, { cause: error }),
50
+ };
51
+ }
52
+ };
53
+ const decodeGzip = async (params) => {
54
+ const { name, content } = params;
55
+ if (!isArrayBuffer(content)) {
56
+ return {
57
+ success: false,
58
+ error: new FileDropperError(`${name} failed to load.`),
59
+ };
60
+ }
61
+ try {
62
+ const decompressor = new DecompressionStream('gzip');
63
+ const blob = new Blob([content]);
64
+ const stream = blob.stream().pipeThrough(decompressor);
65
+ const response = await new Response(stream).blob();
66
+ const buffer = await response.arrayBuffer();
67
+ const snapshot = Snapshot.fromBinary(new Uint8Array(buffer));
68
+ return {
69
+ success: true,
70
+ name,
71
+ type: 'snapshot',
72
+ snapshot,
73
+ };
74
+ }
75
+ catch (error) {
76
+ return {
77
+ success: false,
78
+ error: new FileDropperError(`${name} failed to parse.`, { cause: error }),
79
+ };
80
+ }
81
+ };
82
+ export const snapshotDropper = async (params) => {
83
+ switch (params.extension) {
84
+ case 'json':
85
+ return decodeJson(params);
86
+ case 'pb':
87
+ return decodeBinary(params);
88
+ case 'pb.gz':
89
+ return decodeGzip(params);
90
+ default:
91
+ return {
92
+ success: false,
93
+ error: new FileDropperError(`Only ${Extensions.JSON}, ${Extensions.PB} and ${Extensions.PB_GZ} snapshot files are supported.`),
94
+ };
95
+ }
96
+ };
@@ -0,0 +1,9 @@
1
+ import type { FileDropperSuccess } from './file-dropper';
2
+ export type DropStates = 'inactive' | 'hovering' | 'loading';
3
+ export declare const useFileDrop: (onSuccess: (result: FileDropperSuccess) => void, onError: (message: string) => void) => {
4
+ readonly dropState: DropStates;
5
+ ondrop: (event: DragEvent) => void;
6
+ ondragenter: (event: DragEvent) => void;
7
+ ondragover: (event: DragEvent) => void;
8
+ ondragleave: (event: DragEvent) => void;
9
+ };
@@ -0,0 +1,97 @@
1
+ import { Extensions, parseFileName, Prefixes, readFile } from './file-names';
2
+ import { pcdDropper } from './pcd-dropper';
3
+ import { plyDropper } from './ply-dropper';
4
+ import { snapshotDropper } from './snapshot-dropper';
5
+ const createFileDropper = (extension, prefix) => {
6
+ switch (prefix) {
7
+ case Prefixes.Snapshot:
8
+ return snapshotDropper;
9
+ }
10
+ switch (extension) {
11
+ case Extensions.PCD:
12
+ return pcdDropper;
13
+ case Extensions.PLY:
14
+ return plyDropper;
15
+ }
16
+ return undefined;
17
+ };
18
+ export const useFileDrop = (onSuccess, onError) => {
19
+ let dropState = $state('inactive');
20
+ // prevent default to allow drop
21
+ const ondragenter = (event) => {
22
+ event.preventDefault();
23
+ dropState = 'hovering';
24
+ };
25
+ // prevent default to allow drop
26
+ const ondragover = (event) => {
27
+ event.preventDefault();
28
+ };
29
+ const ondragleave = (event) => {
30
+ // only deactivate if really leaving the window
31
+ if (event.relatedTarget === null) {
32
+ dropState = 'inactive';
33
+ }
34
+ };
35
+ const handleError = (error) => {
36
+ onError(error);
37
+ dropState = 'inactive';
38
+ };
39
+ const ondrop = (event) => {
40
+ event.preventDefault();
41
+ if (event.dataTransfer === null)
42
+ return;
43
+ const { files } = event.dataTransfer;
44
+ let completed = 0;
45
+ for (const file of files) {
46
+ const fileName = parseFileName(file.name);
47
+ if (!fileName.success) {
48
+ handleError(fileName.error.message);
49
+ continue;
50
+ }
51
+ const { extension, prefix } = fileName;
52
+ const reader = new FileReader();
53
+ reader.addEventListener('loadend', () => {
54
+ completed += 1;
55
+ if (completed === files.length) {
56
+ dropState = 'inactive';
57
+ }
58
+ });
59
+ reader.addEventListener('error', (event) => {
60
+ const error = event.target?.error?.message;
61
+ console.error(`${file.name} failed to load.`, error);
62
+ handleError(`${file.name} failed to load.`);
63
+ });
64
+ reader.addEventListener('load', async (event) => {
65
+ const content = event.target?.result;
66
+ const dropper = createFileDropper(extension, prefix);
67
+ if (!dropper) {
68
+ handleError(`${file.name} is not a supported file type.`);
69
+ return;
70
+ }
71
+ const result = await dropper({
72
+ name: file.name,
73
+ extension,
74
+ prefix,
75
+ content,
76
+ });
77
+ if (!result.success) {
78
+ handleError(result.error.message);
79
+ }
80
+ else {
81
+ onSuccess(result);
82
+ }
83
+ });
84
+ readFile(file, reader, extension);
85
+ dropState = 'loading';
86
+ }
87
+ };
88
+ return {
89
+ get dropState() {
90
+ return dropState;
91
+ },
92
+ ondrop,
93
+ ondragenter,
94
+ ondragover,
95
+ ondragleave,
96
+ };
97
+ };
@@ -20,8 +20,8 @@
20
20
  const objectProps = useObjectEvents(() => entity)
21
21
  </script>
22
22
 
23
- {#if gltf.current?.scene}
24
- <Portal id={parent.current}>
23
+ <Portal id={parent.current}>
24
+ {#if gltf.current?.scene}
25
25
  <T
26
26
  is={gltf.current.scene as Object3D}
27
27
  name={name.current}
@@ -32,5 +32,5 @@
32
32
 
33
33
  <PortalTarget id={name.current} />
34
34
  </T>
35
- </Portal>
36
- {/if}
35
+ {/if}
36
+ </Portal>
@@ -48,7 +48,6 @@
48
48
  const sphere = useTrait(() => entity, traits.Sphere)
49
49
  const bufferGeometry = useTrait(() => entity, traits.BufferGeometry)
50
50
  const lineGeometry = useTrait(() => entity, traits.LineGeometry)
51
- const pointsGeometry = useTrait(() => entity, traits.PointsGeometry)
52
51
  const center = useTrait(() => entity, traits.Center)
53
52
 
54
53
  const geometryType = $derived.by(() => {
@@ -57,7 +56,6 @@
57
56
  if (sphere.current) return 'sphere'
58
57
  if (bufferGeometry.current) return 'buffer'
59
58
  if (lineGeometry.current) return 'line'
60
- if (pointsGeometry.current) return 'points'
61
59
  })
62
60
 
63
61
  const color = $derived.by(() => {
@@ -80,7 +78,7 @@
80
78
 
81
79
  const result = new Mesh()
82
80
 
83
- if (geometryType === 'buffer' || geometryType === 'points' || geometryType === 'line') {
81
+ if (geometryType === 'line') {
84
82
  result.raycast = meshBounds
85
83
  }
86
84
 
@@ -108,6 +106,18 @@
108
106
  const oncreate = (ref: BufferGeometry) => {
109
107
  geo = ref
110
108
  }
109
+
110
+ $effect.pre(() => {
111
+ if (mesh && bufferGeometry.current) {
112
+ mesh.geometry = bufferGeometry.current
113
+ oncreate(bufferGeometry.current)
114
+
115
+ return () => {
116
+ geo = undefined
117
+ mesh.geometry.dispose()
118
+ }
119
+ }
120
+ })
111
121
  </script>
112
122
 
113
123
  <Portal id={parent.current}>
@@ -124,19 +134,14 @@
124
134
  <T
125
135
  is={mesh}
126
136
  name={name.current}
127
- bvh={{ enabled: bufferGeometry.current !== undefined }}
137
+ bvh={{ enabled: geometryType === 'buffer' }}
128
138
  >
129
139
  {#if model && renderMode.includes('model')}
130
140
  <T is={model} />
131
141
  {/if}
132
142
 
133
143
  {#if !model || renderMode.includes('colliders')}
134
- {#if bufferGeometry.current}
135
- <T
136
- is={bufferGeometry.current}
137
- {oncreate}
138
- />
139
- {:else if lineGeometry.current}
144
+ {#if lineGeometry.current}
140
145
  <MeshLineGeometry points={lineGeometry.current} />
141
146
  {:else if box.current}
142
147
  {@const { x, y, z } = box.current ?? { x: 0, y: 0, z: 0 }}
@@ -22,7 +22,11 @@
22
22
  <Frame {entity} />
23
23
 
24
24
  {#if dotColor.current && points.current}
25
- <InstancedMesh frustumCulled={false}>
25
+ <InstancedMesh
26
+ frustumCulled={false}
27
+ bvh={{ enabled: false }}
28
+ raycast={() => null}
29
+ >
26
30
  <T.SphereGeometry />
27
31
  <T.MeshBasicMaterial color={[dotColor.current.r, dotColor.current.g, dotColor.current.b]} />
28
32
 
@@ -1,27 +1,26 @@
1
1
  <script lang="ts">
2
2
  import * as tree from '@zag-js/tree-view'
3
3
  import { useMachine, normalizeProps } from '@zag-js/svelte'
4
- import { untrack } from 'svelte'
5
4
  import { ChevronRight, Eye, EyeOff } from 'lucide-svelte'
6
5
  import { useVisibility } from '../../hooks/useVisibility.svelte'
7
6
  import type { TreeNode } from './buildTree'
8
- import { useExpanded } from './useExpanded.svelte'
9
7
  import { VirtualList } from 'svelte-virtuallists'
10
- import { observe } from '@threlte/core'
11
8
  import { Icon } from '@viamrobotics/prime-core'
12
9
  import { traits } from '../../ecs'
10
+ import { useSelectedEntity } from '../../hooks/useSelection.svelte'
11
+ import { SvelteSet } from 'svelte/reactivity'
13
12
 
13
+ const selected = useSelectedEntity()
14
14
  const visibility = useVisibility()
15
- const expanded = useExpanded()
16
15
 
17
16
  interface Props {
18
17
  rootNode: TreeNode
19
- selections: string[]
18
+ nodeMap: Record<string, TreeNode | undefined>
20
19
  dragElement?: HTMLElement
21
20
  onSelectionChange?: (event: tree.SelectionChangeDetails) => void
22
21
  }
23
22
 
24
- let { rootNode, selections, onSelectionChange, dragElement = $bindable() }: Props = $props()
23
+ let { rootNode, nodeMap, onSelectionChange, dragElement = $bindable() }: Props = $props()
25
24
 
26
25
  const collection = $derived(
27
26
  tree.collection<TreeNode>({
@@ -31,39 +30,47 @@
31
30
  })
32
31
  )
33
32
 
33
+ const selectedValue = $derived(selected.current ? [`${selected.current}`] : [])
34
+ const expandedValues = new SvelteSet<string>()
35
+
36
+ $effect(() => {
37
+ let name = selected.current?.get(traits.Name)
38
+ let node = nodeMap[name ?? '']
39
+ while (node) {
40
+ expandedValues.add(`${node.entity}`)
41
+ node = node.parent
42
+ }
43
+ })
44
+
34
45
  const id = $props.id()
35
- const service = useMachine(tree.machine, {
46
+ const service = useMachine(tree.machine, () => ({
36
47
  id,
37
- get collection() {
38
- return collection
39
- },
48
+ collection,
49
+ selectedValue,
50
+ expandedValue: [...expandedValues],
40
51
  onSelectionChange(details) {
41
52
  onSelectionChange?.(details)
42
53
  },
43
54
  onExpandedChange(details) {
44
- expanded.clear()
55
+ expandedValues.clear()
45
56
  for (const value of details.expandedValue) {
46
- expanded.add(value)
57
+ expandedValues.add(value)
47
58
  }
48
59
  },
49
- })
60
+ }))
50
61
 
51
62
  const api = $derived(tree.connect(service, normalizeProps))
63
+ const rootChildren = $derived(collection.rootNode.children ?? [])
52
64
 
53
- observe(
54
- () => [selections],
55
- () =>
56
- untrack(() => {
57
- api.setSelectedValue(selections)
58
- })
59
- )
60
-
61
- observe(
62
- () => [expanded],
63
- () => untrack(() => api.setExpandedValue([...expanded]))
64
- )
65
+ $effect(() => {
66
+ const element = document.querySelector(
67
+ `[data-scope="tree-view"][data-value="${selected.current}"]`
68
+ )
65
69
 
66
- const rootChildren = $derived(collection.rootNode.children ?? [])
70
+ requestAnimationFrame(() => {
71
+ element?.scrollIntoView({ block: 'nearest' })
72
+ })
73
+ })
67
74
  </script>
68
75
 
69
76
  {#snippet treeNode({
@@ -2,7 +2,7 @@ import * as tree from '@zag-js/tree-view';
2
2
  import type { TreeNode } from './buildTree';
3
3
  interface Props {
4
4
  rootNode: TreeNode;
5
- selections: string[];
5
+ nodeMap: Record<string, TreeNode | undefined>;
6
6
  dragElement?: HTMLElement;
7
7
  onSelectionChange?: (event: tree.SelectionChangeDetails) => void;
8
8
  }
@@ -35,14 +35,17 @@
35
35
 
36
36
  const worldEntity = world.spawn(IsExcluded, traits.Name('World'))
37
37
 
38
- let children = $state<TreeNode[]>([])
38
+ let children = $state.raw<TreeNode[]>([])
39
+ let nodeMap = $state.raw<Record<string, TreeNode | undefined>>({})
39
40
 
40
41
  let pending = false
41
42
  const flush = () => {
42
43
  if (pending) return
43
44
  pending = true
44
45
  window.setTimeout(() => {
45
- children = buildTreeNodes(world.query(traits.Name))
46
+ const results = buildTreeNodes(world.query(traits.Name))
47
+ children = results.rootNodes
48
+ nodeMap = results.nodeMap
46
49
  pending = false
47
50
  })
48
51
  }
@@ -86,8 +89,8 @@
86
89
  >
87
90
  <Tree
88
91
  {rootNode}
92
+ {nodeMap}
89
93
  bind:dragElement
90
- selections={selectedEntity.current ? [`${selectedEntity.current}`] : []}
91
94
  onSelectionChange={(event) => {
92
95
  const value = event.selectedValue[0]
93
96
 
@@ -1,9 +1,13 @@
1
1
  import type { Entity, QueryResult, Trait } from 'koota';
2
2
  export interface TreeNode {
3
3
  entity: Entity;
4
+ parent?: TreeNode;
4
5
  children?: TreeNode[];
5
6
  }
6
7
  /**
7
8
  * Creates a tree representing parent child / relationships from a set of frames.
8
9
  */
9
- export declare const buildTreeNodes: (entities: QueryResult<[Trait]>) => TreeNode[];
10
+ export declare const buildTreeNodes: (entities: QueryResult<[Trait]>) => {
11
+ rootNodes: TreeNode[];
12
+ nodeMap: Record<string, TreeNode | undefined>;
13
+ };
@@ -3,14 +3,14 @@ import { traits } from '../../ecs';
3
3
  * Creates a tree representing parent child / relationships from a set of frames.
4
4
  */
5
5
  export const buildTreeNodes = (entities) => {
6
- const nodeMap = new Map();
6
+ const nodeMap = {};
7
7
  const rootNodes = [];
8
8
  const childNodes = [];
9
9
  for (const entity of entities) {
10
10
  const parent = entity.get(traits.Parent);
11
11
  const name = entity.get(traits.Name) ?? '';
12
12
  const node = { entity };
13
- nodeMap.set(name, node);
13
+ nodeMap[name] = node;
14
14
  if (!parent || parent === 'world') {
15
15
  rootNodes.push(node);
16
16
  }
@@ -21,12 +21,13 @@ export const buildTreeNodes = (entities) => {
21
21
  for (const node of childNodes) {
22
22
  const parent = node.entity.get(traits.Parent);
23
23
  if (parent) {
24
- const parentNode = nodeMap.get(parent);
24
+ const parentNode = nodeMap[parent];
25
+ node.parent = parentNode;
25
26
  if (parentNode) {
26
27
  parentNode.children ??= [];
27
28
  parentNode.children?.push(node);
28
29
  }
29
30
  }
30
31
  }
31
- return rootNodes;
32
+ return { rootNodes, nodeMap };
32
33
  };
@@ -83,6 +83,10 @@ export declare const GLTF: import("koota").Trait<() => ThreeGltf>;
83
83
  export declare const DrawAPI: import("koota").TagTrait;
84
84
  export declare const GeometriesAPI: import("koota").TagTrait;
85
85
  export declare const WorldStateStoreAPI: import("koota").TagTrait;
86
+ /**
87
+ * Marker trait for entities created from user-dropped files (PLY, PCD, etc.)
88
+ */
89
+ export declare const DroppedFile: import("koota").TagTrait;
86
90
  /**
87
91
  * An entity with data from the FrameSystemConfig() API
88
92
  */
@@ -44,6 +44,10 @@ export const GLTF = trait(() => ({}));
44
44
  export const DrawAPI = trait();
45
45
  export const GeometriesAPI = trait();
46
46
  export const WorldStateStoreAPI = trait();
47
+ /**
48
+ * Marker trait for entities created from user-dropped files (PLY, PCD, etc.)
49
+ */
50
+ export const DroppedFile = trait();
47
51
  /**
48
52
  * An entity with data from the FrameSystemConfig() API
49
53
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/motion-tools",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Motion visualization with Viam",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -138,6 +138,7 @@
138
138
  "test": "pnpm test:unit -- --run",
139
139
  "test:coverage": "npx vitest run --coverage",
140
140
  "test:e2e": "playwright test",
141
+ "test:e2e-ui": "playwright test --ui",
141
142
  "model-pipeline:run": "node scripts/model-pipeline.js",
142
143
  "release": "changeset publish"
143
144
  }
@@ -1,144 +0,0 @@
1
- <script lang="ts">
2
- import { traits, useWorld } from '../ecs'
3
- import { parsePcdInWorker } from '../lib'
4
- import { useToast, ToastVariant } from '@viamrobotics/prime-core'
5
- import { PLYLoader } from 'three/examples/jsm/Addons.js'
6
-
7
- let { ...rest } = $props()
8
-
9
- const world = useWorld()
10
-
11
- type DropStates = 'inactive' | 'hovering' | 'loading'
12
-
13
- let dropState = $state<DropStates>('inactive')
14
-
15
- // prevent default to allow drop
16
- const ondragenter = (event: DragEvent) => {
17
- event.preventDefault()
18
- dropState = 'hovering'
19
- }
20
-
21
- // prevent default to allow drop
22
- const ondragover = (event: DragEvent) => {
23
- event.preventDefault()
24
- }
25
-
26
- const ondragleave = (event: DragEvent) => {
27
- // only deactivate if really leaving the window
28
- if (event.relatedTarget === null) {
29
- dropState = 'inactive'
30
- }
31
- }
32
-
33
- const toast = useToast()
34
-
35
- const extensions = {
36
- PCD: 'pcd',
37
- PLY: 'ply',
38
- }
39
- const supportedFiles = [extensions.PCD, extensions.PLY]
40
-
41
- const ondrop = (event: DragEvent) => {
42
- event.preventDefault()
43
-
44
- if (event.dataTransfer === null) {
45
- return
46
- }
47
-
48
- let completed = 0
49
-
50
- const { files } = event.dataTransfer
51
-
52
- for (const file of files) {
53
- const ext = file.name.split('.').at(-1)
54
-
55
- if (!ext) {
56
- toast({
57
- message: `Could not determine file extension.`,
58
- variant: ToastVariant.Danger,
59
- })
60
-
61
- continue
62
- }
63
-
64
- if (!supportedFiles.includes(ext)) {
65
- toast({
66
- message: `Only ${supportedFiles.map((file) => `.${file}`).join(', ')} files are supported.`,
67
- variant: ToastVariant.Danger,
68
- })
69
-
70
- continue
71
- }
72
-
73
- const reader = new FileReader()
74
-
75
- reader.addEventListener('loadend', () => {
76
- completed += 1
77
-
78
- if (completed === files.length) {
79
- dropState = 'inactive'
80
- }
81
- })
82
-
83
- reader.addEventListener('error', () => {
84
- toast({
85
- message: `${file.name} failed to load.`,
86
- variant: ToastVariant.Danger,
87
- })
88
- })
89
-
90
- reader.addEventListener('load', async (event) => {
91
- const arrayBuffer = event.target?.result
92
-
93
- if (!arrayBuffer || typeof arrayBuffer === 'string') {
94
- toast({
95
- message: `${file.name} failed to load.`,
96
- variant: ToastVariant.Danger,
97
- })
98
-
99
- return
100
- }
101
-
102
- if (ext === extensions.PCD) {
103
- const result = await parsePcdInWorker(new Uint8Array(arrayBuffer))
104
-
105
- world.spawn(
106
- traits.Name(file.name),
107
- traits.PointsGeometry(result.positions),
108
- result.colors ? traits.VertexColors(result.colors) : traits.Color
109
- )
110
-
111
- toast({ message: `Loaded ${file.name}`, variant: ToastVariant.Success })
112
- } else if (ext === extensions.PLY) {
113
- const bufferGeometry = new PLYLoader().parse(arrayBuffer)
114
-
115
- world.spawn(traits.Name(file.name), traits.BufferGeometry(bufferGeometry))
116
-
117
- toast({ message: `Loaded ${file.name}`, variant: ToastVariant.Success })
118
- }
119
- })
120
-
121
- reader.readAsArrayBuffer(file)
122
-
123
- dropState = 'loading'
124
- }
125
- }
126
- </script>
127
-
128
- <svelte:window
129
- {ondragenter}
130
- {ondragleave}
131
- {ondragover}
132
- />
133
-
134
- <div
135
- class={{
136
- 'fixed inset-0 z-9999 ': true,
137
- 'pointer-events-none': dropState === 'inactive',
138
- 'bg-black/10': dropState !== 'inactive',
139
- }}
140
- role="region"
141
- aria-label="File drop zone"
142
- {ondrop}
143
- {...rest}
144
- ></div>
@@ -1,3 +0,0 @@
1
- declare const FileDrop: import("svelte").Component<Record<string, any>, {}, "">;
2
- type FileDrop = ReturnType<typeof FileDrop>;
3
- export default FileDrop;