Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/curly-heads-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@viamrobotics/motion-tools': patch
---

Fix perf monitor in embedded environments with blocking CSPs.
51 changes: 51 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: verify
description: Build, launch, and drive the visualizer to verify a change end-to-end at its rendered surface.
---

# Verify a change in the running visualizer

## Build + launch

```bash
pnpm run up # wireit: vite-build + go-build, then bun static server (5173) + draw-server (3030)
```

- **Never invoke it as bare `pnpm up`** — pnpm treats `up` as the built-in
`update` alias and mutates package.json/pnpm-lock.yaml instead of running
the wireit script. Always `pnpm run up` (`make up` does this).
- Server is ready when `curl -s http://localhost:5173/` returns 200
(~30-60s cold build).
- The bun server serves exact files from `build/` — routes need `.html`:
`http://localhost:5173/snapshot.html` (self-contained demo scene, loads
`/visualization_snapshot.json`, no robot required). `/snapshot` 404s.

## Drive it

Playwright is available via the repo's `@playwright/test` devDep. Scripts
must live inside the repo root for module resolution:

```js
import { chromium } from '@playwright/test'
```

Useful selectors on the snapshot page:

- Dashboard buttons use `aria-label` = their description, e.g.
`[aria-label="Settings"]`.
- Settings panel tabs are text labels: Connection / Scene / Debug / etc.
- Debug tab has a "Render stats" `<Switch>` (toggles the three-perf
monitor, `#three-perf-ui`).
- Robot-query console errors (`[viam-svelte-sdk] error … getPose …`) are
expected offline noise on the snapshot page; the SvelteKit router also
logs one `Not found: /snapshot.html` — both harmless.

## Gotchas

- Settings persist via localStorage per browser context; fresh Playwright
contexts start from defaults.
- Rendering is on-demand: after toggling scene settings give it ~1-2s
before screenshotting.
- Sentry replay, GLTF/basis, and the PCD loader pool create blob workers
at startup — don't mistake them for the code under test when
intercepting `window.Worker`.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ setup:
@./etc/setup.sh

up:
@pnpm up
@pnpm run up
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,10 @@
"@changesets/cli": "2.29.6",
"@connectrpc/connect": "1.7.0",
"@connectrpc/connect-web": "1.7.0",
"@langchain/anthropic": "^1.4.0",
"@dimforge/rapier3d-compat": "0.18.2",
"@eslint/compat": "2.0.2",
"@eslint/js": "10.0.1",
"@langchain/anthropic": "^1.4.0",
"@playwright/test": "1.55.1",
"@sentry/sveltekit": "10.10.0",
"@skeletonlabs/skeleton": "3.2.0",
Expand Down Expand Up @@ -632,6 +632,8 @@
"koota": "0.6.5",
"lodash-es": "4.18.1",
"three-mesh-bvh": "^0.9.8",
"three-perf": "^1.0.11",
"troika-three-text": "^0.52.4",
"uuid-tool": "^2.0.3"
}
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions src/lib/components/App.svelte
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
<script
lang="ts"
module
>
import { configureTextBuilder } from 'troika-three-text'

// Embedding hosts (e.g. app.viam.com) can serve a Content-Security-Policy
// that blocks the `blob:` worker troika-three-text typesets in, which
// leaves every troika Text mesh blank — the perf monitor counters, XR
// labels, etc. The visualizer only renders short labels, so typeset on
// the main thread instead of depending on host CSP allowing blob workers.
// Troika ignores configuration changes after its first font request, so
// this must run before the first Text renders.
//
// Note: troika's CONFIG is a process-global singleton, so this switches
// every troika Text on the page to main-thread typesetting, not just the
// visualizer's own meshes. That is intended (it also rescues any troika
// text a CSP host renders itself); the only cost on a non-CSP host is
// losing worker offload, which is negligible for our short labels.
configureTextBuilder({ useWorker: false })
</script>

<script lang="ts">
import type { Struct } from '@viamrobotics/sdk'
import type { Entity } from 'koota'
Expand Down
85 changes: 85 additions & 0 deletions src/lib/components/PerfMonitor.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script lang="ts">
import { useStage, useTask, useThrelte } from '@threlte/core'
import { ThreePerf } from 'three-perf'

interface Props {
logsPerSecond?: number
showGraph?: boolean
memory?: boolean
enabled?: boolean
visible?: boolean
backgroundOpacity?: number
scale?: number
anchorX?: 'left' | 'right'
anchorY?: 'top' | 'bottom'
}

let {
logsPerSecond = 10,
showGraph = true,
memory = true,
enabled = true,
visible = true,
backgroundOpacity = 0.7,
scale = 1,
anchorX = 'left',
anchorY = 'top',
}: Props = $props()

const { dom, renderer, renderStage, mainStage } = useThrelte()

let perf: ThreePerf

$effect.pre(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's weird that claude is still $effect.pre happy, neither of these should be pre.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah can't seem to get it off them

perf = new ThreePerf({
domElement: dom,
renderer,
})

// three-perf hardcodes `position: fixed`, which anchors the monitor to
// the viewport even when the visualizer is embedded in a larger page.
// Anchor it to Threlte's relative-positioned canvas wrapper instead.
perf.ui.wrapper.style.position = 'absolute'

// Overlay panels (Details, FloatingPanel) share this corner at z-4/z-5;
// keep the debug monitor above them so toggling it on is never a no-op,
// but below the top-layer fullscreen/file-drop overlays.
perf.ui.wrapper.style.zIndex = '10'

return () => perf.dispose()
})

$effect.pre(() => {
perf.logsPerSecond = logsPerSecond
perf.showGraph = showGraph
perf.memory = memory
perf.enabled = enabled
perf.visible = visible
perf.backgroundOpacity = backgroundOpacity
perf.scale = scale
perf.anchorX = anchorX
perf.anchorY = anchorY
})

useTask(
() => {
perf.begin()
},
{
stage: useStage('monitor-begin', {
before: mainStage,
}),
}
)

useTask(
() => {
perf.end()
},
{
stage: useStage('monitor-end', {
after: renderStage,
}),
}
)
</script>
3 changes: 2 additions & 1 deletion src/lib/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import type { Snippet } from 'svelte'

import { T, useThrelte } from '@threlte/core'
import { Environment, Grid, interactivity, PerfMonitor, PortalTarget } from '@threlte/extras'
import { Environment, Grid, interactivity, PortalTarget } from '@threlte/extras'
import { useXR } from '@threlte/xr'
import { ShaderMaterial } from 'three'

import Camera from '$lib/components/Camera.svelte'
import Entities from '$lib/components/Entities/Entities.svelte'
import PerfMonitor from '$lib/components/PerfMonitor.svelte'
import Selected from '$lib/components/Selected.svelte'
import SelectedTransformControls from '$lib/components/SelectedTransformControls.svelte'
import StaticGeometries from '$lib/components/StaticGeometries.svelte'
Expand Down
5 changes: 5 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ declare const BACKEND_IP: string
declare const WS_PORT: string

declare module '*.hdr'

// troika-three-text ships no type declarations; declare the one API we use.
declare module 'troika-three-text' {
export function configureTextBuilder(config: { useWorker?: boolean }): void
}
Loading