diff --git a/package.json b/package.json index 020e03ae..6498c84a 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,11 @@ "pnpm": { "onlyBuiltDependencies": [ "esbuild" - ] + ], + "overrides": { + "@viamrobotics/sdk": "link:../viam-typescript-sdk", + "@viamrobotics/svelte-sdk": "link:../viam-svelte-sdk" + } }, "packageManager": "pnpm@10.14.0", "svelte": "./dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eb8816a..bba3b7fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,20 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@viamrobotics/sdk': link:../viam-typescript-sdk + '@viamrobotics/svelte-sdk': link:../viam-svelte-sdk + importers: .: + dependencies: + '@viamrobotics/sdk': + specifier: link:../viam-typescript-sdk + version: link:../viam-typescript-sdk + '@viamrobotics/svelte-sdk': + specifier: link:../viam-svelte-sdk + version: link:../viam-svelte-sdk devDependencies: '@ag-grid-community/client-side-row-model': specifier: 32.3.9 @@ -104,12 +115,6 @@ importers: '@viamrobotics/prime-core': specifier: 0.1.5 version: 0.1.5(svelte@5.38.7) - '@viamrobotics/sdk': - specifier: 0.52.0 - version: 0.52.0 - '@viamrobotics/svelte-sdk': - specifier: 0.6.1 - version: 0.6.1(@tanstack/svelte-query@5.87.1(svelte@5.38.7))(@viamrobotics/sdk@0.52.0)(svelte@5.38.7) '@vitejs/plugin-basic-ssl': specifier: 2.1.0 version: 2.1.0(vite@7.1.4(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) @@ -305,9 +310,6 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} - '@bufbuild/protobuf@1.10.1': - resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} - '@changesets/apply-release-plan@7.0.12': resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} @@ -363,17 +365,6 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@connectrpc/connect-web@1.6.1': - resolution: {integrity: sha512-GVfxQOmt3TtgTaKeXLS/EA2IHa3nHxwe2BCHT7X0Q/0hohM+nP5DDnIItGEjGrGdt3LTTqWqE4s70N4h+qIMlQ==} - peerDependencies: - '@bufbuild/protobuf': ^1.10.0 - '@connectrpc/connect': 1.6.1 - - '@connectrpc/connect@1.6.1': - resolution: {integrity: sha512-KchMDNtU4CDTdkyf0qG7ugJ6qHTOR/aI7XebYn3OTCNagaDYWiZUVKgRgwH79yeMkpNgvEUaXSK7wKjaBK9b/Q==} - peerDependencies: - '@bufbuild/protobuf': ^1.10.0 - '@csstools/color-helpers@5.0.2': resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} engines: {node: '>=18'} @@ -1526,16 +1517,6 @@ packages: peerDependencies: svelte: '>=5 <6' - '@viamrobotics/sdk@0.52.0': - resolution: {integrity: sha512-47zgcT6GNHYk08TeD1W+/3FaA19uZWX97nCOxkypUIqc+UfteXFmsCneGvpVqYKZAx/BHDdHNckXvHOIgYiPiQ==} - - '@viamrobotics/svelte-sdk@0.6.1': - resolution: {integrity: sha512-7LePX4KaBp+F8MQlnrarPAy07jsMatmlE8sBtN5QfEgoUGRACI9O95uVJzcAKNnawUBI0pho+HNd61Yn8yYyWg==} - peerDependencies: - '@tanstack/svelte-query': 5.87.1 - '@viamrobotics/sdk': '>=0.51' - svelte: '>=5' - '@vitejs/plugin-basic-ssl@2.1.0': resolution: {integrity: sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1832,9 +1813,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bsonfy@1.0.2: - resolution: {integrity: sha512-/kLM6B2x/CWXdkTu2kAv65trB4YruwahBpFDxYkkSunve6Rvx7KSoSHHe1f4jsPXPgXvDG393IjkQ3Us645Eag==} - bun-types@1.2.21: resolution: {integrity: sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==} peerDependencies: @@ -2194,9 +2172,6 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - exponential-backoff@3.1.2: - resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3208,11 +3183,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - runed@0.29.2: - resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==} - peerDependencies: - svelte: ^5.7.0 - runed@0.31.1: resolution: {integrity: sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==} peerDependencies: @@ -3896,8 +3866,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@bufbuild/protobuf@1.10.1': {} - '@changesets/apply-release-plan@7.0.12': dependencies: '@changesets/config': 3.1.1 @@ -4042,15 +4010,6 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@connectrpc/connect-web@1.6.1(@bufbuild/protobuf@1.10.1)(@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.1))': - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@connectrpc/connect': 1.6.1(@bufbuild/protobuf@1.10.1) - - '@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.1)': - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -5271,21 +5230,6 @@ snapshots: prismjs: 1.30.0 svelte: 5.38.7 - '@viamrobotics/sdk@0.52.0': - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@connectrpc/connect': 1.6.1(@bufbuild/protobuf@1.10.1) - '@connectrpc/connect-web': 1.6.1(@bufbuild/protobuf@1.10.1)(@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.1)) - bsonfy: 1.0.2 - exponential-backoff: 3.1.2 - - '@viamrobotics/svelte-sdk@0.6.1(@tanstack/svelte-query@5.87.1(svelte@5.38.7))(@viamrobotics/sdk@0.52.0)(svelte@5.38.7)': - dependencies: - '@tanstack/svelte-query': 5.87.1(svelte@5.38.7) - '@viamrobotics/sdk': 0.52.0 - runed: 0.29.2(svelte@5.38.7) - svelte: 5.38.7 - '@vitejs/plugin-basic-ssl@2.1.0(vite@7.1.4(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': dependencies: vite: 7.1.4(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) @@ -5709,8 +5653,6 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.2) - bsonfy@1.0.2: {} - bun-types@1.2.21(@types/react@19.1.10): dependencies: '@types/node': 24.2.1 @@ -6075,8 +6017,6 @@ snapshots: expect-type@1.2.2: {} - exponential-backoff@3.1.2: {} - extend@3.0.2: {} extendable-error@0.1.7: {} @@ -6940,11 +6880,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.29.2(svelte@5.38.7): - dependencies: - esm-env: 1.2.2 - svelte: 5.38.7 - runed@0.31.1(svelte@5.38.7): dependencies: esm-env: 1.2.2 diff --git a/src/lib/WorldObject.svelte.ts b/src/lib/WorldObject.svelte.ts index 23ee6e7b..66f2302f 100644 --- a/src/lib/WorldObject.svelte.ts +++ b/src/lib/WorldObject.svelte.ts @@ -1,4 +1,13 @@ -import type { Geometry, Pose, TransformWithUUID } from '@viamrobotics/sdk' +import { + Struct, + type Geometry, + type PlainMessage, + type PointCloud, + type PointCloudHeader, + type Pose, + type PoseInFrame, + type TransformWithUUID, +} from '@viamrobotics/sdk' import { BatchedMesh, Box3, @@ -10,6 +19,7 @@ import { type RGB, } from 'three' import { createPose } from './transform' +import { setIn, setInUnsafe } from '@thi.ng/paths' export type PointsGeometry = { case: 'points'; value: Float32Array } export type LinesGeometry = { case: 'line'; value: Float32Array } @@ -32,31 +42,31 @@ export type Metadata = { getBoundingBoxAt?: (box: Box3) => void } -export class WorldObject { - uuid: string - name: string - referenceFrame: string - pose = $state.raw(createPose()) - geometry?: T - metadata: Metadata - - constructor(name: string, pose?: Pose, parent = 'world', geometry?: T, metadata?: Metadata) { - this.uuid = MathUtils.generateUUID() - this.name = name - this.referenceFrame = parent +const METADATA_KEYS = [ + 'color', + 'opacity', + 'gltf', + 'points', + 'pointSize', + 'lineWidth', + 'lineDotColor', + 'batched', +] as const - this.geometry = geometry - this.metadata = metadata ?? {} +export const isMetadataKey = (key: string): key is keyof Metadata => { + return METADATA_KEYS.includes(key as (typeof METADATA_KEYS)[number]) +} - if (pose) { - this.pose = pose - } - } +export interface WorldObjectUpdate { + name?: string + pose?: Pose + referenceFrame?: string + geometry?: T + metadata?: Metadata } // eslint-disable-next-line @typescript-eslint/no-explicit-any -type UnwrapValue = { kind: { case: string; value: any } } -const unwrapValue = (value: UnwrapValue): unknown => { +const unwrapValue = (value: PlainMessage): unknown => { if (!value?.kind) return value switch (value.kind.case) { @@ -65,10 +75,9 @@ const unwrapValue = (value: UnwrapValue): unknown => { case 'boolValue': return value.kind.value case 'structValue': { - // Recursively unwrap nested struct const result: Record = {} for (const [key, val] of Object.entries(value.kind.value.fields || {})) { - result[key] = unwrapValue(val as UnwrapValue) + result[key] = unwrapValue(val as PlainMessage) } return result } @@ -81,35 +90,211 @@ const unwrapValue = (value: UnwrapValue): unknown => { } } -const parseMetadata = (metadata: Record) => { +export const parseMetadata = (fields: PlainMessage['fields'] = {}) => { let json: Metadata = {} - for (const [k, v] of Object.entries(metadata)) { + for (const [k, v] of Object.entries(fields)) { + if (!isMetadataKey(k)) continue const unwrappedValue = unwrapValue(v) - // TODO: Remove special case and add better handling for metadata - if (k === 'color' && unwrappedValue && typeof unwrappedValue === 'object') { - const { r, g, b } = unwrappedValue as RGB - json[k] = new Color().setRGB(r / 255, g / 255, b / 255) - } else { - json = { ...json, [k]: unwrappedValue } + switch (k) { + case 'color': { + const raw = unwrappedValue as RGB + const r = raw.r > 1 ? raw.r / 255 : raw.r + const g = raw.g > 1 ? raw.g / 255 : raw.g + const b = raw.b > 1 ? raw.b / 255 : raw.b + json[k] = new Color().setRGB(r, g, b) + break + } + case 'opacity': { + const rawOpacity = unwrappedValue as number + const opacity = rawOpacity > 1 ? rawOpacity / 100 : rawOpacity + json[k] = opacity + break + } + case 'gltf': + json[k] = unwrappedValue as { scene: Object3D } + break + case 'points': + json[k] = unwrappedValue as Vector3[] + break + case 'pointSize': + json[k] = unwrappedValue as number + break + case 'lineWidth': + json[k] = unwrappedValue as number + break + case 'lineDotColor': { + const rawLineDotColor = unwrappedValue as RGB + const r = rawLineDotColor.r > 1 ? rawLineDotColor.r / 255 : rawLineDotColor.r + const g = rawLineDotColor.g > 1 ? rawLineDotColor.g / 255 : rawLineDotColor.g + const b = rawLineDotColor.b > 1 ? rawLineDotColor.b / 255 : rawLineDotColor.b + json[k] = new Color().setRGB(r, g, b) + break + } + case 'batched': + json[k] = unwrappedValue as { id: number; object: BatchedMesh } + break + case 'getBoundingBoxAt': + json[k] = unwrappedValue as (box: Box3) => void + break } } return json } -export const fromTransform = (transform: TransformWithUUID) => { - const metadata: Metadata = transform.metadata - ? parseMetadata(transform.metadata.fields as Record) - : {} +export type WorldObjectPhysicalObject = + TransformWithUUID extends { physicalObject?: infer P } + ? P extends Geometry + ? Omit & { geometryType: T } + : never + : never + +export type WorldObjectTransform = Omit< + TransformWithUUID, + 'metadata' | 'physicalObject' | 'uuid' +> & { + physicalObject?: WorldObjectPhysicalObject + metadata: Metadata +} + +export const fromTransform = (transform: TransformWithUUID): WorldObject => { const worldObject = new WorldObject( + transform.uuidString, transform.referenceFrame, - transform.poseInObserverFrame?.pose, - transform.poseInObserverFrame?.referenceFrame, - transform.physicalObject?.geometryType, - metadata + transform.poseInObserverFrame, + transform.physicalObject, + parseMetadata(transform.metadata?.fields) ) - worldObject.uuid = transform.uuidString + return worldObject } + +export class WorldObject { + private transform = $state.raw>({ + uuidString: MathUtils.generateUUID(), + referenceFrame: 'Unnamed World Object', + poseInObserverFrame: { + referenceFrame: 'world', + }, + physicalObject: undefined, + metadata: {}, + }) + + constructor( + uuidString: string = MathUtils.generateUUID(), + referenceFrame: string = 'Unnamed World Object', + poseInObserverFrame: PoseInFrame = { + referenceFrame: 'world', + }, + physicalObject?: WorldObjectPhysicalObject, + metadata: Metadata = {} + ) { + this.transform = { + uuidString, + referenceFrame, + poseInObserverFrame, + physicalObject, + metadata, + } + } + + get uuid() { + return this.transform.uuidString + } + + set uuid(uuid: string) { + this.transform.uuidString = uuid + } + + get name() { + return this.transform.referenceFrame + } + + get pose() { + return this.transform.poseInObserverFrame?.pose + } + + set pose(pose: Pose | undefined) { + if (!this.transform.poseInObserverFrame) { + this.transform.poseInObserverFrame = { + referenceFrame: 'world', + pose, + } + } else { + this.transform.poseInObserverFrame.pose = pose + } + } + + get referenceFrame() { + return this.transform.poseInObserverFrame?.referenceFrame ?? 'world' + } + + get geometry() { + return this.transform.physicalObject?.geometryType + } + + get metadata() { + return this.transform.metadata + } + + update = ( + changes: [ + path: readonly (string | number)[], + value: Parameters>[2], + ][] + ) => { + let next: WorldObjectTransform = { ...this.transform } + for (const [path, value] of changes) { + const key = path.join('.') + // Ignore renderer-managed point cloud payload/header changes + if ( + key.includes('physicalObject.geometryType.value.pointCloud') || + key.includes('physicalObject.geometryType.value.header') + ) { + continue + } + next = setInUnsafe(next, path, value) + } + this.transform = next + } +} + +export class PointCloudWorldObject extends WorldObject<{ case: 'pointcloud'; value: PointCloud }> { + physicalObject: WorldObjectPhysicalObject<{ case: 'pointcloud'; value: PointCloud }> + private _updates: { + header?: PointCloudHeader + data: Uint8Array + ts: number + }[] = [] + + constructor( + uuid: string = MathUtils.generateUUID(), + referenceFrame: string, + poseInObserverFrame: PoseInFrame = { + referenceFrame: 'world', + }, + physicalObject: WorldObjectPhysicalObject<{ case: 'pointcloud'; value: PointCloud }>, + metadata?: Metadata + ) { + super(uuid, referenceFrame, poseInObserverFrame, physicalObject, metadata) + this.physicalObject = physicalObject + } + + enqueueUpdate(header: PointCloudHeader | undefined, data: Uint8Array) { + this._updates.push({ header, data, ts: performance.now() }) + } + + setFull(header: PointCloudHeader, data: Uint8Array) { + this.physicalObject.geometryType.value.header = header + this.physicalObject.geometryType.value.pointCloud = data + this.enqueueUpdate(header, data) + } + + drainUpdates() { + const out = this._updates + this._updates = [] + return out + } +} diff --git a/src/lib/components/Details.svelte b/src/lib/components/Details.svelte index 8664c5c5..2018185f 100644 --- a/src/lib/components/Details.svelte +++ b/src/lib/components/Details.svelte @@ -73,7 +73,7 @@ {#if object} - {@const { geometry } = object} + {@const geometry = object.geometry}
{:else if geometry.case === 'capsule'} - {@const { value } = geometry} + {@const { radiusMm, lengthMm } = geometry.value}
dimensions
r - {value.radiusMm ? value.radiusMm.toFixed(2) : '-'} + {radiusMm ? radiusMm.toFixed(2) : '-'}
l - {value.lengthMm ? value.lengthMm.toFixed(2) : '-'} + {lengthMm ? lengthMm.toFixed(2) : '-'}
diff --git a/src/lib/components/Frame.svelte b/src/lib/components/Frame.svelte index 3987d335..bba5a606 100644 --- a/src/lib/components/Frame.svelte +++ b/src/lib/components/Frame.svelte @@ -4,19 +4,20 @@ + + + {#if geometry} + + {/if} + + diff --git a/src/lib/components/Pointcloud.svelte b/src/lib/components/Pointcloud.svelte index 4accf2dc..5fffbe44 100644 --- a/src/lib/components/Pointcloud.svelte +++ b/src/lib/components/Pointcloud.svelte @@ -7,7 +7,7 @@ OrthographicCamera, } from 'three' import { T, useTask, useThrelte } from '@threlte/core' - import type { WorldObject } from '$lib/WorldObject.svelte' + import { type WorldObject } from '$lib/WorldObject.svelte' import { useObjectEvents } from '$lib/hooks/useObjectEvents.svelte' import { poseToObject3d } from '$lib/transform' import { useSettings } from '$lib/hooks/useSettings.svelte' @@ -23,8 +23,9 @@ const { camera } = useThrelte() const settings = useSettings() - const colors = $derived(object.metadata.colors) - const pointSize = $derived(object.metadata.pointSize ?? settings.current.pointSize) + const metadata = $derived(object.metadata ?? {}) + const colors = $derived(metadata.colors) + const pointSize = $derived(metadata.pointSize ?? settings.current.pointSize) const positions = $derived(object.geometry?.value ?? new Float32Array()) const orthographic = $derived(settings.current.cameraMode === 'orthographic') @@ -38,7 +39,7 @@ }) $effect.pre(() => { - material.color.set(colors ? 0xffffff : (object.metadata.color ?? settings.current.pointColor)) + material.color.set(colors ? 0xffffff : (metadata.color ?? settings.current.pointColor)) }) $effect.pre(() => { @@ -55,7 +56,9 @@ }) $effect.pre(() => { - poseToObject3d(object.pose, points) + if (object.pose) { + poseToObject3d(object.pose, points) + } }) const events = useObjectEvents(() => object.uuid) diff --git a/src/lib/components/StaticGeometries.svelte b/src/lib/components/StaticGeometries.svelte index ccfd5db6..1d48424a 100644 --- a/src/lib/components/StaticGeometries.svelte +++ b/src/lib/components/StaticGeometries.svelte @@ -4,7 +4,7 @@ import { useStaticGeometries } from '$lib/hooks/useStaticGeometries.svelte' import { useTransformControls } from '$lib/hooks/useControls.svelte' import { PressedKeys } from 'runed' - import { quaternionToPose, scaleToDimensions, vector3ToPose } from '$lib/transform' + import { createPose, quaternionToPose, scaleToDimensions, vector3ToPose } from '$lib/transform' import { Quaternion, Vector3 } from 'three' import Frame from './Frame.svelte' import { useSettings } from '$lib/hooks/useSettings.svelte' @@ -32,10 +32,11 @@ {#each geometries.current as object (object.uuid)} + {@const pose = object.pose ?? createPose()} @@ -55,9 +56,9 @@ transformControls.setActive(false) if (mode === 'translate') { - vector3ToPose(ref.getWorldPosition(vector3), object.pose) + vector3ToPose(ref.getWorldPosition(vector3), pose) } else if (mode === 'rotate') { - quaternionToPose(ref.getWorldQuaternion(quaternion), object.pose) + quaternionToPose(ref.getWorldQuaternion(quaternion), pose) ref.quaternion.copy(quaternion) } else if (mode === 'scale' && object.geometry?.case === 'box') { scaleToDimensions(ref.scale, object.geometry) diff --git a/src/lib/components/Tree/buildTree.ts b/src/lib/components/Tree/buildTree.ts index 024378a5..6666cba8 100644 --- a/src/lib/components/Tree/buildTree.ts +++ b/src/lib/components/Tree/buildTree.ts @@ -63,6 +63,18 @@ export const buildTreeNodes = ( node.children?.push(child) } + for (const object of worldState.pointClouds) { + const child: TreeNode = { + name: object.name, + id: object.uuid, + children: [], + href: `/world-state/${worldState.name}/${object.name}`, + } + + nodeMap.set(object.name, child) + node.children?.push(child) + } + nodeMap.set(worldState.name, node) rootNodes.push(node) } diff --git a/src/lib/components/WorldObjects.svelte b/src/lib/components/WorldObjects.svelte index b408dc39..3eb12d9d 100644 --- a/src/lib/components/WorldObjects.svelte +++ b/src/lib/components/WorldObjects.svelte @@ -40,7 +40,7 @@ @@ -69,7 +69,7 @@ {/each} {#each worldStates.names as { name } (name)} - + {/each} {#each points.current as object (object.uuid)} @@ -136,6 +136,7 @@ {#each drawAPI.lines as object (object.uuid)} + {/each} diff --git a/src/lib/components/WorldState.svelte b/src/lib/components/WorldState.svelte index ca9a92fb..610cef06 100644 --- a/src/lib/components/WorldState.svelte +++ b/src/lib/components/WorldState.svelte @@ -1,18 +1,19 @@ -{#each worldObjects as object (object.uuid)} +{#each worldState.worldObjects as object (object.uuid)} {/each} + +{#each worldState.pointClouds as object (object.uuid)} + + + +{/each} diff --git a/src/lib/hooks/useDrawAPI.svelte.ts b/src/lib/hooks/useDrawAPI.svelte.ts index 12e14687..3b5c6c9b 100644 --- a/src/lib/hooks/useDrawAPI.svelte.ts +++ b/src/lib/hooks/useDrawAPI.svelte.ts @@ -5,7 +5,7 @@ import { parsePcdInWorker } from '$lib/loaders/pcd' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' import { BatchedArrow } from '$lib/three/BatchedArrow' import { WorldObject, type PointsGeometry } from '$lib/WorldObject.svelte' -import type { Geometry } from '@viamrobotics/sdk' +import { Struct, type Geometry } from '@viamrobotics/sdk' type ConnectionStatus = 'connecting' | 'open' | 'closed' @@ -101,8 +101,8 @@ export const provideDrawAPI = () => { undefined, undefined, { - case: 'points', - value: positions, + geometryType: { case: 'points', value: positions }, + label: `points ${++pointsIndex}`, }, colors ? { colors } : undefined ) @@ -135,9 +135,19 @@ export const provideDrawAPI = () => { geometry = { case: undefined, value: undefined } } - const object = new WorldObject(data.label ?? ++geometryIndex, data.center, parent, geometry, { - color, - }) + const object = new WorldObject( + undefined, + data.label ?? ++geometryIndex, + { + pose: data.center, + referenceFrame: parent ?? 'world', + }, + { + geometryType: geometry, + label: data.label ?? ++geometryIndex, + }, + { color } + ) meshes.push(object) } @@ -154,11 +164,20 @@ export const provideDrawAPI = () => { ) const curve = new NURBSCurve(data.Degree, data.Knots, controlPoints) const object = new WorldObject( + undefined, data.name, - data.pose, - data.parent, - { case: 'line', value: new Float32Array() }, - { color, points: curve.getPoints(200) } + { + pose: data.pose, + referenceFrame: data.parent ?? 'world', + }, + { + geometryType: { case: 'line', value: new Float32Array() }, + label: data.name, + }, + { + color, + points: curve.getPoints(200), + } ) nurbs.push(object) @@ -276,12 +295,12 @@ export const provideDrawAPI = () => { points.push( new WorldObject( - label, undefined, + label, undefined, { - case: 'points', - value: positions, + geometryType: { case: 'points', value: positions }, + label, }, metadata ) @@ -326,12 +345,12 @@ export const provideDrawAPI = () => { lines.push( new WorldObject( - label, undefined, + label, undefined, { - case: 'line', - value: positions, + geometryType: { case: 'line', value: positions }, + label, }, { points, @@ -380,7 +399,7 @@ export const provideDrawAPI = () => { index = poses.findIndex((p) => p.name === name) if (index !== -1) { - const id = poses[index].metadata.batched?.id + const id = poses[index].metadata?.batched?.id if (id) { batchedArrow.removeArrow(id) diff --git a/src/lib/hooks/useFrames.svelte.ts b/src/lib/hooks/useFrames.svelte.ts index 90c239a8..e8440cfb 100644 --- a/src/lib/hooks/useFrames.svelte.ts +++ b/src/lib/hooks/useFrames.svelte.ts @@ -46,10 +46,10 @@ export const provideFrames = (partID: () => string) => { objects.push( new WorldObject( + undefined, frame.referenceFrame ? frame.referenceFrame : 'Unnamed frame', - frame.poseInObserverFrame?.pose, - frame.poseInObserverFrame?.referenceFrame, - frame.physicalObject?.geometryType, + frame.poseInObserverFrame, + frame.physicalObject, resourceName ? { color: resourceColors[resourceName.subtype as keyof typeof resourceColors], diff --git a/src/lib/hooks/useStaticGeometries.svelte.ts b/src/lib/hooks/useStaticGeometries.svelte.ts index 893f8c21..185287dc 100644 --- a/src/lib/hooks/useStaticGeometries.svelte.ts +++ b/src/lib/hooks/useStaticGeometries.svelte.ts @@ -33,19 +33,19 @@ export const provideStaticGeometries = () => { }, add() { const object = new WorldObject( - `custom geometry ${geometries.length + 1}`, undefined, + `custom geometry ${geometries.length + 1}`, undefined, createGeometry({ case: 'box', value: { dimsMm: { x: 100, y: 100, z: 100 } }, - }).geometryType + }) ) geometries.push(structuredClone(object)) }, remove(name: string) { - const index = geometries.findIndex((geo) => geo.name === name) + const index = geometries.findIndex((geo) => geo.referenceFrame === name) geometries.splice(index, 1) }, }) diff --git a/src/lib/hooks/useWorldState.svelte.ts b/src/lib/hooks/useWorldState.svelte.ts index 6e006b91..8994c176 100644 --- a/src/lib/hooks/useWorldState.svelte.ts +++ b/src/lib/hooks/useWorldState.svelte.ts @@ -8,13 +8,21 @@ import { createResourceClient, createResourceQuery, createResourceStream, + streamQueryKey, useResourceNames, } from '@viamrobotics/svelte-sdk' -import { fromTransform } from '$lib/WorldObject.svelte' +import { + fromTransform, + parseMetadata, + PointCloudWorldObject, + WorldObject, +} from '$lib/WorldObject.svelte' import { usePartID } from './usePartID.svelte' -import { setInUnsafe } from '@thi.ng/paths' import type { ProcessMessage } from '$lib/world-state-messages' -import { getContext, setContext } from 'svelte' +import { getContext, setContext, untrack } from 'svelte' +import { getPointCloud, getPointCloudHeader, isPointCloud } from '$lib/point-cloud' +import { omit } from 'lodash-es' +import { useQueryClient } from '@tanstack/svelte-query' const key = Symbol('world-state-context') @@ -61,13 +69,12 @@ export const useWorldState = (resourceName: () => string) => { } const createWorldState = (partID: () => string, resourceName: () => string) => { + const queryClient = useQueryClient() const client = createResourceClient(WorldStateStoreClient, partID, resourceName) + const transforms = $state>({}) + const pointClouds = $state>({}) let initialized = $state(false) - let transforms = $state.raw>({}) - - const transformsList = $derived.by(() => Object.values(transforms)) - const worldObjectsList = $derived.by(() => transformsList.map(fromTransform)) let pendingEvents: ProcessMessage['events'] = [] let flushScheduled = false @@ -84,48 +91,65 @@ const createWorldState = (partID: () => string, resourceName: () => string) => { }) ) - const changeStream = createResourceStream(client, 'streamTransformChanges', { - refetchMode: 'replace', - }) + const changeStream = createResourceStream(client, 'streamTransformChanges') + + const addPointCloud = (transform: TransformWithUUID) => { + if (!isPointCloud(transform)) return + + const data = getPointCloud(transform) + const header = getPointCloudHeader(transform) + const pointCloud = new PointCloudWorldObject( + transform.uuidString, + transform.referenceFrame, + transform.poseInObserverFrame, + transform.physicalObject, + parseMetadata(transform.metadata?.fields) + ) + + if (data && header) pointCloud.setFull(header, data) + pointClouds[pointCloud.uuid] = pointCloud + } const initialize = (initial: TransformWithUUID[]) => { - const next = { ...transforms } for (const transform of initial) { - next[transform.uuidString] = transform + if (isPointCloud(transform)) { + addPointCloud(transform) + } else { + transforms[transform.uuidString] = fromTransform(transform) + } } - transforms = next initialized = true } const applyEvents = (events: ProcessMessage['events']) => { if (events.length === 0) return - - const next = { ...transforms } for (const event of events) { switch (event.type) { case TransformChangeType.ADDED: - next[event.uuidString] = event.transform + if (isPointCloud(event.transform)) { + addPointCloud(event.transform) + } else { + transforms[event.uuidString] = fromTransform(event.transform) + } break case TransformChangeType.REMOVED: - delete next[event.uuidString] + delete transforms[event.uuidString] + delete pointClouds[event.uuidString] break case TransformChangeType.UPDATED: { if (event.changes.length === 0) continue - let toUpdate = next[event.uuidString] - if (!toUpdate) continue - for (const [path, value] of event.changes) { - toUpdate = setInUnsafe(toUpdate, path, value) + // changes to the actual point cloud data is handled by the world object + if (pointClouds[event.uuidString]) { + pointClouds[event.uuidString].update(event.changes) + } else if (transforms[event.uuidString]) { + transforms[event.uuidString].update(event.changes) } - - next[event.uuidString] = toUpdate break } } } - - transforms = next } const scheduleFlush = () => { @@ -152,13 +176,12 @@ const createWorldState = (partID: () => string, resourceName: () => string) => { const data = queries .flatMap((query) => query?.data ?? []) .filter((transform) => transform !== undefined) as TransformWithUUID[] - if (data.length === 0) return initialize(data) }) $effect(() => { - worker.onmessage = (e: MessageEvent) => { + const onMessage = (e: MessageEvent) => { if (e.data.type !== 'process') return const { events } = e.data ?? { events: [] } @@ -168,8 +191,9 @@ const createWorldState = (partID: () => string, resourceName: () => string) => { scheduleFlush() } + worker.addEventListener('message', onMessage as unknown as EventListener) return () => { - worker.terminate() + worker.removeEventListener('message', onMessage as unknown as EventListener) } }) @@ -179,24 +203,61 @@ const createWorldState = (partID: () => string, resourceName: () => string) => { const events = changeStream.current.data.filter((event) => event.transform !== undefined) if (events.length === 0) return - worker.postMessage({ type: 'change', events }) + const transformEvents = [] + for (const event of events) { + if (isPointCloud(event.transform)) { + switch (event.changeType) { + case TransformChangeType.ADDED: + case TransformChangeType.REMOVED: + transformEvents.push(event) + break + case TransformChangeType.UPDATED: + if ( + event.transform.physicalObject.geometryType.value.pointCloud !== undefined || + event.transform.physicalObject.geometryType.value.header !== undefined + ) { + const pointCloud = pointClouds[event.transform.uuidString] + if (!pointCloud) continue + + const t = event.transform as TransformWithUUID + const data = getPointCloud(t) + const header = getPointCloudHeader(t) + if (data || header) { + pointCloud.enqueueUpdate(header, data ?? new Uint8Array(0)) + } + } + + const cleanedEvent = omit( + event, + 'transform.physicalObject.geometryType.value.pointCloud', + 'transform.physicalObject.geometryType.value.header' + ) + transformEvents.push(cleanedEvent) + break + } + } else { + transformEvents.push(event) + } + } + + if (transformEvents.length > 0) { + worker.postMessage({ type: 'change', events: transformEvents }) + queryClient.setQueryData( + streamQueryKey(partID(), resourceName(), 'streamTransformChanges'), + [] + ) + } }) return { get name() { return resourceName() }, - get transforms() { - return transformsList - }, get worldObjects() { - return worldObjectsList - }, - get listUUIDs() { - return listUUIDs.current + return Object.values(transforms) }, - get getTransforms() { - return getTransforms?.map((query) => query.current) + get pointClouds() { + return Object.values(pointClouds) }, } } diff --git a/src/lib/point-cloud.ts b/src/lib/point-cloud.ts new file mode 100644 index 00000000..51e688c2 --- /dev/null +++ b/src/lib/point-cloud.ts @@ -0,0 +1,355 @@ +import { + commonApi, + PointCloud, + type PointCloudHeader, + type TransformWithUUID, +} from '@viamrobotics/sdk' +import { + BufferGeometry, + BufferAttribute, + InterleavedBuffer, + InterleavedBufferAttribute, + type TypedArray, +} from 'three' + +type TypedArrayConstructor = { + new (buffer: ArrayBufferLike, byteOffset?: number, length?: number): T + readonly BYTES_PER_ELEMENT: number +} + +type BuiltPointCloud = { + buffer: BufferGeometry + interleaved: InterleavedBuffer + points: number + strideBytes: number + strideElements: number + structure?: PointCloudStructure + colorFieldOffset?: number +} + +type PointCloudGeometry = TransformWithUUID & { + physicalObject: { + geometryType: { + case: 'pointcloud' + value: { pointCloud: PointCloud; header?: PointCloudHeader } + } + } +} + +type PointCloudGeometryWithHeader = TransformWithUUID & { + physicalObject: { + geometryType: { + case: 'pointcloud' + value: { pointCloud: PointCloud; header: PointCloudHeader } + } + } +} + +type PointCloudStructure = { + field: string + offsetBytes: number + offsetElements: number + itemSize: number +}[] + +export const isPointCloud = (transform?: TransformWithUUID): transform is PointCloudGeometry => { + return transform?.physicalObject?.geometryType?.case === 'pointcloud' +} + +export const hasPointCloudHeader = ( + transform: TransformWithUUID +): transform is PointCloudGeometryWithHeader => { + if (!isPointCloud(transform)) return false + return transform.physicalObject.geometryType.value.header !== undefined +} + +export const getPointCloud = (transform: TransformWithUUID) => { + if (!isPointCloud(transform)) return undefined + return transform.physicalObject.geometryType.value.pointCloud +} + +export const getPointCloudHeader = (transform: TransformWithUUID) => { + if (!hasPointCloudHeader(transform)) return undefined + return transform.physicalObject.geometryType.value.header +} + +export const buildPointCloud = (data: Uint8Array, header?: PointCloudHeader): BuiltPointCloud => { + // Fallback for payloads without header: interpret as Float32 XYZ + if (!header) { + const aligned = + data.byteOffset % Float32Array.BYTES_PER_ELEMENT === 0 ? data : new Uint8Array(data) + + const array = new Float32Array( + aligned.buffer, + aligned.byteOffset, + Math.floor(aligned.byteLength / Float32Array.BYTES_PER_ELEMENT) + ) + + if (array.length % 3 !== 0) { + throw new Error('Legacy point cloud without header must be float32 XYZ (length % 3 == 0)') + } + + const points = Math.floor(array.length / 3) + const interleaved = new InterleavedBuffer(array, 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', new InterleavedBufferAttribute(interleaved, 3, 0)) + return { + buffer: geometry, + interleaved, + points, + strideBytes: 3 * Float32Array.BYTES_PER_ELEMENT, + strideElements: 3, + } + } + + const { baseType, baseSize } = assertUniformity(header) + const strideBytes = computeStrideBytes(header) + if (strideBytes <= 0) throw new Error('Invalid header: non-positive stride') + + const ArrayType = typedArrayFor(baseType, baseSize) + const points = header.width * header.height + const strideElements = Math.floor(strideBytes / ArrayType.BYTES_PER_ELEMENT) + const expectedLength = points * strideElements + const availableElementsRaw = Math.floor(data.byteLength / ArrayType.BYTES_PER_ELEMENT) + if (availableElementsRaw !== expectedLength) { + throw new Error( + `Binary size mismatch. expected elements ${expectedLength}, got ${availableElementsRaw}` + ) + } + + const aligned = data.byteOffset % ArrayType.BYTES_PER_ELEMENT === 0 ? data : new Uint8Array(data) + const array = new ArrayType(aligned.buffer, aligned.byteOffset, expectedLength) + const interleaved = new InterleavedBuffer(array, strideElements) + const geometry = new BufferGeometry() + const structure = getStructure(header) + + setPosition(geometry, interleaved, structure) + setFieldAttributes(geometry, interleaved, structure) + + const rgbField = structure.find((f) => f.field.toLowerCase() === 'rgb' && f.itemSize === 1) + let rgbFieldOffset: number | undefined + if (rgbField) { + const bytesPerElement = (interleaved.array as TypedArray).BYTES_PER_ELEMENT + if (bytesPerElement === 4) { + rgbFieldOffset = rgbField.offsetElements + const colors = extractRgbColors(interleaved, rgbFieldOffset, 0, points) + geometry.setAttribute('color', new BufferAttribute(colors, 3)) + } + } + + return { + buffer: geometry, + interleaved, + points, + strideBytes, + strideElements, + structure, + colorFieldOffset: rgbFieldOffset, + } +} + +export const updatePointCloudColors = ( + interleaved: InterleavedBuffer, + rgbFieldOffset: number | undefined, + startPoint: number, + countPoints: number, + colors?: BufferAttribute +) => { + if (!colors || countPoints <= 0 || rgbFieldOffset === undefined) return + + const bytesPerElement = (interleaved.array as TypedArray).BYTES_PER_ELEMENT + if (bytesPerElement !== 4) return + + const extracted = extractRgbColors(interleaved, rgbFieldOffset, startPoint, countPoints) + const colorOffset = startPoint * 3 + colors.array.set(extracted, colorOffset) + colors.addUpdateRange(colorOffset, countPoints * 3) + colors.needsUpdate = true +} + +export const updatePointCloud = ( + interleaved: InterleavedBuffer, + data: Uint8Array, + header?: PointCloudHeader +) => { + if (header?.start === undefined) { + console.error('Partial update requires header.start') + return + } + + const startPoint = header.start + const updatePoints = (header.width || 0) * (header.height || 1) + if (updatePoints <= 0) return + + const bytesPerElement = interleaved.array.BYTES_PER_ELEMENT + const aligned = data.byteOffset % bytesPerElement === 0 ? data : new Uint8Array(data) + const Constructor = interleaved.array.constructor as TypedArrayConstructor + const src = new Constructor( + aligned.buffer, + aligned.byteOffset, + Math.floor(aligned.byteLength / bytesPerElement) + ) + + const strideElements = interleaved.stride + const offsetElements = startPoint * strideElements + const countElements = updatePoints * strideElements + + for (let p = 0; p < updatePoints; p++) { + const srcBase = p * strideElements + const dstBase = (startPoint + p) * strideElements + for (let i = 0; i < strideElements; i++) { + interleaved.array[dstBase + i] = src[srcBase + i] + } + } + + interleaved.addUpdateRange(offsetElements, countElements) + interleaved.needsUpdate = true +} + +const assertUniformity = (header: PointCloudHeader) => { + if ( + header.fields.length !== header.size.length || + header.fields.length !== header.type.length || + header.fields.length !== header.count.length + ) { + throw new Error('Invalid header: arrays must be equal length') + } + + const baseType = header.type[0] + const baseSize = header.size[0] + for (let i = 1; i < header.fields.length; i++) { + if (header.type[i] !== baseType) { + throw new Error('Mixed field types are not supported for interleaved buffers') + } + if (header.size[i] !== baseSize) { + throw new Error('Mixed field element sizes are not supported for interleaved buffers') + } + } + + return { baseType, baseSize } +} + +const typedArrayFor = ( + dataType: commonApi.PointCloudDataType, + bytesPerElement: number +): TypedArrayConstructor => { + if (dataType === commonApi.PointCloudDataType.FLOAT) { + if (bytesPerElement === 4) return Float32Array + if (bytesPerElement === 8) return Float64Array + } + if (dataType === commonApi.PointCloudDataType.INT) { + if (bytesPerElement === 1) return Int8Array + if (bytesPerElement === 2) return Int16Array + if (bytesPerElement === 4) return Int32Array + } + if (dataType === commonApi.PointCloudDataType.UINT) { + if (bytesPerElement === 1) return Uint8Array + if (bytesPerElement === 2) return Uint16Array + if (bytesPerElement === 4) return Uint32Array + } + throw new Error(`Unsupported type/size combination: ${dataType}/${bytesPerElement}`) +} + +const computeStrideBytes = (header: PointCloudHeader) => { + let stride = 0 + for (let i = 0; i < header.fields.length; i++) { + stride += header.size[i] * header.count[i] + } + return stride +} + +const getStructure = (header: PointCloudHeader) => { + const { baseSize } = assertUniformity(header) + const bytesPerElement = baseSize + const structure: PointCloudStructure = [] + let runningOffsetBytes = 0 + for (let i = 0; i < header.fields.length; i++) { + const field = header.fields[i] + const count = header.count[i] + const size = header.size[i] + const itemSize = (size / bytesPerElement) * count + structure.push({ + field, + offsetBytes: runningOffsetBytes, + offsetElements: runningOffsetBytes / bytesPerElement, + itemSize, + }) + + runningOffsetBytes += size * count + } + + return structure +} + +const setPosition = ( + geometry: BufferGeometry, + interleaved: InterleavedBuffer, + structure: PointCloudStructure +) => { + const xIndex = structure.findIndex((f) => f.field.toLowerCase() === 'x') + if (xIndex === -1) return + + const y = structure[xIndex + 1] + const z = structure[xIndex + 2] + const x = structure[xIndex] + + if (!y || !z) return + if (y.field.toLowerCase() !== 'y' || z.field.toLowerCase() !== 'z') return + if (x.itemSize !== 1 || y.itemSize !== 1 || z.itemSize !== 1) return + if (y.offsetElements !== x.offsetElements + 1) return + if (z.offsetElements !== x.offsetElements + 2) return + + geometry.setAttribute( + 'position', + new InterleavedBufferAttribute(interleaved, 3, x.offsetElements) + ) +} + +const setFieldAttributes = ( + geometry: BufferGeometry, + interleaved: InterleavedBuffer, + structure: PointCloudStructure +) => { + for (const { field, itemSize, offsetElements } of structure) { + const name = field.toLowerCase() + if (name === 'x' || name === 'y' || name === 'z') continue + if (name === 'rgb') continue // handled specially as 'color' + geometry.setAttribute( + field, + new InterleavedBufferAttribute(interleaved, itemSize, offsetElements) + ) + } +} + +const extractRgbColors = ( + interleaved: InterleavedBuffer, + rgbOffsetElements: number, + startPoint: number, + countPoints: number +): Float32Array => { + const strideElements = interleaved.stride + const colors = new Float32Array(countPoints * 3) + const asUint32 = new Uint32Array( + interleaved.array.buffer, + interleaved.array.byteOffset, + interleaved.array.length + ) + + for (let i = 0; i < countPoints; i++) { + const pointIndex = startPoint + i + const idx = pointIndex * strideElements + rgbOffsetElements + const packed = asUint32[idx] + + // RGB extraction (0x00RRGGBB format) + const r = (packed >>> 16) & 0xff + const g = (packed >>> 8) & 0xff + const b = packed & 0xff + + const base = i * 3 + colors[base] = r / 255 + colors[base + 1] = g / 255 + colors[base + 2] = b / 255 + } + + return colors +} diff --git a/src/lib/workers/worldStateWorker.ts b/src/lib/workers/worldStateWorker.ts index 444cb840..df3ac202 100644 --- a/src/lib/workers/worldStateWorker.ts +++ b/src/lib/workers/worldStateWorker.ts @@ -1,5 +1,5 @@ -import type { ChangeMessage, ProcessMessage } from '$lib/world-state-messages' -import { getInUnsafe, toPath } from '@thi.ng/paths' +import type { ChangeMessage, ProcessMessage, UpdatedEvent } from '$lib/world-state-messages' +import { getIn, getInUnsafe, toPath } from '@thi.ng/paths' import { TransformChangeType, type TransformChangeEvent, @@ -10,7 +10,7 @@ interface DeduplicationEntry { type: TransformChangeType uuidString: string transform?: TransformWithUUID - changes?: Record + changes?: UpdatedEvent['changes'] } const createEntry = (event: TransformChangeEvent): DeduplicationEntry | undefined => { @@ -28,10 +28,11 @@ const createEntry = (event: TransformChangeEvent): DeduplicationEntry | undefine uuidString: event.transform.uuidString, } case TransformChangeType.UPDATED: { - const changes: Record = {} - const paths = toPath(event.updatedFields?.paths ?? []) + const changes: UpdatedEvent['changes'] = [] + const paths = event.updatedFields?.paths ?? [] for (const path of paths) { - changes[path.toString()] = getInUnsafe(event.transform, path) + const pathArray = toPath(path) + changes.push([pathArray, getInUnsafe(event.transform, pathArray)]) } return { @@ -76,8 +77,8 @@ self.onmessage = (e: MessageEvent) => { const paths = toPath(event.updatedFields?.paths ?? []) if (paths.length === 0) continue for (const path of paths) { - if (!existing.changes) existing.changes = {} - existing.changes[path.toString()] = getInUnsafe(entry.transform, path) + if (!existing.changes) existing.changes = [] + existing.changes.push([toPath(path), getInUnsafe(entry.transform, path)]) } existing.transform = event.transform } @@ -105,7 +106,7 @@ self.onmessage = (e: MessageEvent) => { break case TransformChangeType.UPDATED: { - const changes = Object.entries(entry.changes ?? {}) + const changes = entry.changes ?? [] if (changes.length === 0) continue processedEvents.push({ diff --git a/src/lib/world-state-messages.ts b/src/lib/world-state-messages.ts index 267ae59a..f8c503c0 100644 --- a/src/lib/world-state-messages.ts +++ b/src/lib/world-state-messages.ts @@ -23,7 +23,7 @@ export type RemovedEvent = { export type UpdatedEvent = { type: TransformChangeType.UPDATED uuidString: string - changes: [path: string, value: unknown][] + changes: [path: readonly (string | number)[], value: any][] } export type ProcessMessage = {