Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions buf.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ deps:
commit: 62f35d8aed1149c291d606d958a7ce32
digest: b5:d66bf04adc77a0870bdc9328aaf887c7188a36fb02b83a480dc45ef9dc031b4d39fc6e9dc6435120ccf4fe5bfd5c6cb6592533c6c316595571f9a31420ab47fe
- name: buf.build/viamrobotics/api
commit: eb251db530ed439a936944cbacd8ba04
digest: b5:7dae7c86cfd8f0686b5f51fce11dca2523376b1c10175dd3a443215ba5567efe7c376c53e0f470917a5ddf24cf091ee56e8a19e5dee9d18f12090d15417076c5
commit: 559617baef304105829214c1dee6f94e
digest: b5:e0af4fa98f5b4bf72b973614deb9290b068335623be58f68c02d374a91efd0742e5016a9a1581e33b74a5966231d24f8058544510edaff93812c0e3442b20a7b
34 changes: 34 additions & 0 deletions src/lib/attribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BufferAttribute, BufferGeometry } from 'three'
import type { Metadata } from './metadata'

import { STRIDE } from './buffer'
import type { LODLevel } from './loaders/pcd/messages'

export const createBufferGeometry = (positions: Float32Array, { colors, opacities }: Metadata) => {
const geometry = new BufferGeometry()
Expand All @@ -19,6 +20,39 @@ export const createBufferGeometry = (positions: Float32Array, { colors, opacitie
return geometry
}

export interface LODGeometryLevel {
geometry: BufferGeometry
distance: number
}

export const createLODGeometries = (levels: LODLevel[]): LODGeometryLevel[] => {
return levels.map((level) => ({
geometry: createBufferGeometry(level.positions, { colors: level.colors ?? undefined }),
distance: level.distance,
}))
}

export const updateLODGeometries = (
existing: LODGeometryLevel[],
levels: LODLevel[]
): LODGeometryLevel[] => {
if (existing.length !== levels.length) {
for (const { geometry } of existing) {
geometry.dispose()
}
return createLODGeometries(levels)
}

for (let i = 0; i < levels.length; i++) {
updateBufferGeometry(existing[i]!.geometry, levels[i]!.positions, {
colors: levels[i]!.colors ?? undefined,
})
existing[i]!.distance = levels[i]!.distance
}

return existing
}

export const updateBufferGeometry = (
geometry: BufferGeometry,
positions: Float32Array,
Expand Down
43 changes: 35 additions & 8 deletions src/lib/components/Entities/Points.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import type { Snippet } from 'svelte'

import { T, useTask, useThrelte } from '@threlte/core'
import { Portal } from '@threlte/extras'
import { OrthographicCamera, Points, PointsMaterial } from 'three'
import { Detailed, Portal } from '@threlte/extras'
import { LOD, OrthographicCamera, Points, PointsMaterial } from 'three'

import { asColor, isSingleColor } from '$lib/buffer'
import { traits, useTrait } from '$lib/ecs'
Expand All @@ -26,21 +26,32 @@
const parent = useTrait(() => entity, traits.Parent)
const pose = useTrait(() => entity, traits.Pose)
const geometry = useTrait(() => entity, traits.BufferGeometry)
const lodData = useTrait(() => entity, traits.PointCloudLOD)
const opacity = useTrait(() => entity, traits.Opacity)
const entityColor = useTrait(() => entity, traits.Color)
const colors = useTrait(() => entity, traits.Colors)
const entityPointSize = useTrait(() => entity, traits.PointSize)
const opacity = useTrait(() => entity, traits.Opacity)
const invisible = useTrait(() => entity, traits.Invisible)

const pointSize = $derived(
entityPointSize.current ? entityPointSize.current * 0.001 : settings.current.pointSize
)
const orthographic = $derived(settings.current.cameraMode === 'orthographic')
const hasLOD = $derived(lodData.current !== undefined && lodData.current.levels.length > 0)

const points = new Points()
const material = points.material as PointsMaterial
material.toneMapped = false

let lodRef = $state<LOD>()

const lodPoints = $derived(
lodData.current?.levels.map((level) => ({
points: new Points(level.geometry, material),
distance: level.distance,
})) ?? []
)

$effect.pre(() => {
material.size = pointSize
})
Expand Down Expand Up @@ -98,7 +109,7 @@

$effect.pre(() => {
if (pose.current) {
poseToObject3d(pose.current, points)
poseToObject3d(pose.current, hasLOD && lodRef ? lodRef : points)
}
})

Expand All @@ -123,8 +134,24 @@
})
</script>

{#if geometry.current}
<Portal id={parent.current}>
<Portal id={parent.current}>
{#if hasLOD}
<Detailed
bind:ref={lodRef}
name={entity}
visible={invisible.current !== true}
{...events}
>
{#each lodPoints as { points: childPoints, distance } (childPoints)}
<T
is={childPoints}
{distance}
bvh={{ maxDepth: 40, maxLeafSize: 20 }}
/>
{/each}
{@render children?.()}
</Detailed>
{:else if geometry.current}
<T
is={points}
name={entity}
Expand All @@ -136,5 +163,5 @@
<T is={material} />
{@render children?.()}
</T>
</Portal>
{/if}
{/if}
</Portal>
7 changes: 7 additions & 0 deletions src/lib/ecs/traits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Geometry as ViamGeometry } from '@viamrobotics/sdk'
import { type Entity, trait } from 'koota'
import { BufferGeometry as ThreeBufferGeometry } from 'three'

import type { LODGeometryLevel } from '$lib/attribute'

import { createBox, createCapsule, createSphere } from '$lib/geometry'
import { parsePlyInput } from '$lib/ply'

Expand Down Expand Up @@ -110,6 +112,11 @@ export const Sphere = trait({ r: 200 })

export const BufferGeometry = trait(() => new ThreeBufferGeometry())

export const PointCloudLOD = trait(() => ({
levels: [] as LODGeometryLevel[],
diagonal: 0,
}))

export const GLTF = trait(() => ({
source: { url: '' } as { url: string } | { gltf: ThreeGltf } | { glb: Uint8Array<ArrayBuffer> },
animationName: '',
Expand Down
49 changes: 38 additions & 11 deletions src/lib/hooks/usePointclouds.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Entity } from 'koota'
import type { ConfigurableTrait, Entity } from 'koota'

import { CameraClient } from '@viamrobotics/sdk'
import {
Expand All @@ -8,10 +8,15 @@ import {
} from '@viamrobotics/svelte-sdk'
import { getContext, setContext, untrack } from 'svelte'

import { createBufferGeometry, updateBufferGeometry } from '$lib/attribute'
import {
createBufferGeometry,
createLODGeometries,
updateBufferGeometry,
updateLODGeometries,
} from '$lib/attribute'
import { RefetchRates } from '$lib/components/overlay/RefreshRate.svelte'
import { traits, useWorld } from '$lib/ecs'
import { parsePcdInWorker } from '$lib/loaders/pcd'
import { parsePcdWithLOD } from '$lib/loaders/pcd'

import { useEnvironment } from './useEnvironment.svelte'
import { useLogs } from './useLogs.svelte'
Expand Down Expand Up @@ -140,35 +145,57 @@ export const providePointclouds = (partID: () => string) => {
}
}

parsePcdInWorker(data)
.then(({ positions, colors }) => {
parsePcdWithLOD(data)
.then(({ levels, boundingBoxDiagonal }) => {
if (disposed) {
return
}

const existing = entities.get(queryKey)
const finest = levels.find((l) => l.level === 0) ?? levels[0]!
const metadata = {
colors: colors ?? undefined,
colors: finest.colors ?? undefined,
}

if (existing) {
const geometry = existing.get(traits.BufferGeometry)
const existingLOD = existing.get(traits.PointCloudLOD)

if (geometry) {
updateBufferGeometry(geometry, positions, metadata)
updateBufferGeometry(geometry, finest.positions, metadata)
return
}

if (existingLOD && levels.length > 1) {
// Update geometry buffers in place without setting the trait
// to avoid triggering re-renders and component remounts.
// BVH is not recomputed here — it drifts slightly between
// frames but avoids expensive main-thread recomputation.
updateLODGeometries(existingLOD.levels, levels)
}

return
}

const geometry = createBufferGeometry(positions, metadata)
const geometry = createBufferGeometry(finest.positions, metadata)

const entity = world.spawn(
const entityTraits: ConfigurableTrait[] = [
traits.Parent(name),
traits.Name(`${name} pointcloud`),
traits.BufferGeometry(geometry),
traits.Points
)
traits.Points,
]

if (levels.length > 1) {
entityTraits.push(
traits.PointCloudLOD({
levels: createLODGeometries(levels),
diagonal: boundingBoxDiagonal,
})
)
}

const entity = world.spawn(...entityTraits)
entities.set(queryKey, entity)
})
.catch((error) => {
Expand Down
98 changes: 82 additions & 16 deletions src/lib/loaders/pcd/index.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,108 @@
import type { Message, SuccessMessage } from './messages'
import type { LODLevel, Message, SuccessMessage } from './messages'

import { workerCode } from './worker.inline'

const blob = new Blob([workerCode], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
const worker = new Worker(url)

export interface LODResult {
levels: LODLevel[]
boundingBoxDiagonal: number
}

let requestId = 0
const pending = new Map<
number,
{
resolve: (msg: SuccessMessage) => void
reject: (err: string) => void
}
>()

type PendingEntry =
| {
mode: 'simple'
resolve: (msg: SuccessMessage) => void
reject: (err: string) => void
}
| {
mode: 'lod'
resolve: (result: LODResult) => void
reject: (err: string) => void
onProgress?: (level: LODLevel) => void
levels: LODLevel[]
diagonal: number
}

const pending = new Map<number, PendingEntry>()

worker.addEventListener('message', (event: MessageEvent<Message>) => {
const { id, ...rest } = event.data as Message
const msg = event.data

const entry = pending.get(msg.id)
if (!entry) return

if ('error' in msg) {
pending.delete(msg.id)
entry.reject(msg.error)
return
}

const promise = pending.get(id)
if ('lod' in msg) {
// Progressive LOD message
if (entry.mode === 'lod') {
entry.levels.push(msg.lod)
entry.diagonal = msg.boundingBoxDiagonal
entry.onProgress?.(msg.lod)

if (!promise) {
if (msg.done) {
pending.delete(msg.id)
entry.resolve({
levels: entry.levels.sort((a, b) => a.level - b.level),
boundingBoxDiagonal: entry.diagonal,
})
}
} else {
// Simple mode receiving LOD messages — accumulate and resolve with finest level
if (!('_levels' in entry)) {
;(entry as PendingEntry & { _levels: LODLevel[] })._levels = []
}
const extended = entry as PendingEntry & { _levels: LODLevel[] }
extended._levels.push(msg.lod)

if (msg.done) {
pending.delete(msg.id)
const finest = extended._levels.find((l) => l.level === 0) ?? extended._levels[0]!
entry.resolve({ id: msg.id, positions: finest.positions, colors: finest.colors })
}
}
return
}

pending.delete(id)
// Legacy single-message response (small cloud)
pending.delete(msg.id)

if ('error' in rest) {
promise.reject(rest.error)
if (entry.mode === 'lod') {
entry.resolve({
levels: [{ level: 0, distance: 0, positions: msg.positions, colors: msg.colors }],
boundingBoxDiagonal: 0,
})
} else {
promise.resolve(rest as SuccessMessage)
entry.resolve(msg as SuccessMessage)
}
})

export const parsePcdInWorker = (data: Uint8Array<ArrayBufferLike>): Promise<SuccessMessage> => {
return new Promise((resolve, reject) => {
const id = ++requestId
pending.set(id, { resolve, reject })
pending.set(id, { mode: 'simple', resolve, reject })

const copy = new Uint8Array(data)
worker.postMessage({ id, data: copy }, [copy.buffer])
})
}

export const parsePcdWithLOD = (
data: Uint8Array<ArrayBufferLike>,
onProgress?: (level: LODLevel) => void
): Promise<LODResult> => {
return new Promise((resolve, reject) => {
const id = ++requestId
pending.set(id, { mode: 'lod', resolve, reject, onProgress, levels: [], diagonal: 0 })

const copy = new Uint8Array(data)
worker.postMessage({ id, data: copy }, [copy.buffer])
Expand Down
Loading