Skip to content

Authoring plugins

Every feature listed under Plugins in this sidebar — <SelectionTool />, <MeasureTool />, <Skybox />, and the rest — is “just” a Svelte 5 component mounted as a child of <Visualizer />. There is no plugin manifest, no registration call, no lifecycle interface to implement. If you can write a Svelte component, you can write a plugin.

This guide shows how, using the built-in plugins as worked examples. Each section points at the one that demonstrates its pattern best.

<Visualizer /> renders your children inside its Threlte <Canvas> — specifically inside the <Scene>, wrapped in every context the visualizer provides. That single fact drives everything else:

  • Your component runs inside the Threlte scene graph, so it can render 3D objects with <T> directly and they appear in the world.
  • It runs inside the Koota ECS world and the visualizer’s shared-state contexts, so it reads and mutates scene state through hooks — no prop threading.
  • It renders inside a WebGL canvas, so DOM chrome (buttons, panels) can’t sit inline. Instead you teleport it to a named overlay region with a Portal.
┌────────────────────────────────────────────────────┐
│  [ dashboard ]                      [ workspace ]  │  DashboardPortal / WorkspacePortal
│                                                    │
│                                                    │
│                   3-D scene                        │  <T> … />  renders here
│               (your children)                      │
│                                                    │
│                                      [ controls ]  │  camera controls
└────────────────────────────────────────────────────┘

Start with the simplest possible plugin: a component that drops an object into the scene. This one renders a small sphere at a configurable position.

<!-- Waypoint.svelte -->
<script lang="ts">
	import { T } from '@threlte/core'

	interface Props {
		position?: [x: number, y: number, z: number]
	}

	let { position = [0, 0, 0] }: Props = $props()
</script>

<T.Mesh {position}>
	<T.SphereGeometry args={[0.05]} />
	<T.MeshStandardMaterial color="hotpink" />
</T.Mesh>

Mount it as a child of <Visualizer />:

<script lang="ts">
	import { Visualizer } from '@viamrobotics/motion-tools'

	import Waypoint from './Waypoint.svelte'
</script>

<div class="h-screen w-screen">
	<Visualizer>
		<Waypoint position={[0, 0, 0.5]} />
	</Visualizer>
</div>

That’s a complete, working plugin. Now give it a control. Because the button lives in the DOM overlay rather than the 3D scene, wrap it in DashboardPortal to teleport it into the top toolbar, and drive visibility from local state:

<!-- Waypoint.svelte -->
<script lang="ts">
	import { T } from '@threlte/core'
	import { IconButton } from '@viamrobotics/prime-core'

	import { DashboardPortal } from '@viamrobotics/motion-tools'

	interface Props {
		position?: [x: number, y: number, z: number]
	}

	let { position = [0, 0, 0] }: Props = $props()
	let visible = $state(true)
</script>

<DashboardPortal>
	<IconButton
		icon={visible ? 'eye-outline' : 'eye-off-outline'}
		label="Toggle waypoint"
		variant="secondary"
		onclick={() => (visible = !visible)}
	/>
</DashboardPortal>

{#if visible}
	<T.Mesh {position}>
		<T.SphereGeometry args={[0.05]} />
		<T.MeshStandardMaterial color="hotpink" />
	</T.Mesh>
{/if}

The rest of this guide is the toolkit for going further.

Anything you can render with Threlte works inside a plugin, because your children already live in the <Canvas>:

  • 3D objects<T> and <T.Mesh> from @threlte/core, or higher-level helpers from @threlte/extras. <Skybox /> is a pure-scene plugin: it loads a texture and renders a single <T>.
  • In-scene DOM labels<HTML> from @threlte/extras anchors HTML to a world position. <MeasureTool /> uses it to float a distance readout at the midpoint of a measurement.
  • Per-frame workuseTask(() => { … }) from @threlte/core runs a callback in Threlte’s render loop; use it instead of $effect for anything animated.
  • Performance — opt display-only geometry out of hit-testing with raycast={() => null} or bvh={{ enabled: false }}.

After any imperative Three.js mutation, call invalidate() from useThrelte() so the on-demand renderer repaints.

To place DOM chrome in the visualizer’s overlay, render it into one of the exported Portal components. Each teleports its contents to a fixed region:

PortalLands inUse for
DashboardPortalTop-center toolbarMode toggles and tool buttons
WorkspacePortalTop-right, by the viewer badgePanel launchers and workspace-level toggles
SettingsPortalA new tab in the settings panePlugin configuration that belongs with the other settings
FloatingPanelA draggable, resizable panelRich UI that shouldn’t crowd the toolbar
<script lang="ts">
	import { DashboardPortal } from '@viamrobotics/motion-tools'
</script>

<DashboardPortal>
	<!-- your button / control here -->
</DashboardPortal>

For a launcher-plus-panel pattern, pair a WorkspacePortal button with a FloatingPanel whose isOpen you bind — this is exactly how <ControlWidgets /> works:

<script lang="ts">
	import { IconButton } from '@viamrobotics/prime-core'

	import { FloatingPanel, WorkspacePortal } from '@viamrobotics/motion-tools'

	let open = $state(false)
</script>

<WorkspacePortal>
	<IconButton
		icon="cog"
		label="Diagnostics"
		variant="secondary"
		onclick={() => (open = !open)}
	/>
</WorkspacePortal>

<FloatingPanel
	title="Diagnostics"
	bind:isOpen={open}
	defaultSize={{ width: 320, height: 480 }}
	resizable
>
	<!-- panel body -->
</FloatingPanel>

To add UI to the per-entity details card instead, pass a details snippet to <Visualizer /> — it receives the selected { entity } and renders inside each card.

Shared scene state lives in a Koota ECS world, not Svelte stores. <Visualizer /> provides the world; your plugin consumes it. The whole ECS surface a plugin needs is exported from the package root:

import {
	useWorld, // the raw Koota World
	useQuery, // reactive query → { current }
	useTrait, // reactive single-entity trait → { current }
	traits, // built-in trait namespace
	relations, // built-in relation namespace
} from '@viamrobotics/motion-tools'

Read the set of entities carrying a trait, and react to it, with useQuery:

<script lang="ts">
	import { traits, useQuery } from '@viamrobotics/motion-tools'

	const selected = useQuery(traits.Selected)
</script>

<p>{selected.current.length} selected</p>

Read a single entity’s trait with useTrait — note the target is a getter function:

import { traits, useTrait } from '@viamrobotics/motion-tools'

const name = useTrait(() => entity, traits.Name)
// name.current updates whenever the Name trait changes

A plugin can spawn its own entities and tag them with traits it defines. Traits come straight from koota: a factory returning () => true is a marker; anything else is a data trait with a default.

// waypointTraits.ts
import { trait } from 'koota'

export const Waypoint = trait() // marker
export const Label = trait('') // data (string, default '')
<script lang="ts">
	import { useWorld } from '@viamrobotics/motion-tools'

	import { Label, Waypoint } from './waypointTraits'

	const world = useWorld()

	function drop(x: number, y: number, z: number) {
		world.spawn(Waypoint, Label('Home'))
	}
</script>

Use standard Koota on the world and entities: world.query(Waypoint), entity.add(...), entity.get(Label), entity.set(Label, 'Dock'), entity.destroy(). For array-of-struct traits you mutate in place, call entity.changed(Trait) afterward so queries re-run. <SelectionTool /> is the reference: traits.ts defines its own traits, and it exports them so consumers can read the selection down to the enclosed points.

Relations (relation({ ... })) model links between entities — parent/child, “captured by”, and so on. See the built-in relations namespace and the Selection plugin’s relations.ts.

To let the host app (or your own sub-components) read a plugin’s state, expose it through Svelte context using the project’s provide* / use* convention: a Symbol() key, and an object of getters so reactivity survives the context boundary.

// useWaypoints.svelte.ts
import { getContext, setContext } from 'svelte'

const KEY = Symbol('waypoints')

export const provideWaypoints = () => {
	let count = $state(0)

	return setContext(KEY, {
		get count() {
			return count
		},
		add() {
			count += 1
		},
	})
}

export const useWaypoints = () => getContext<ReturnType<typeof provideWaypoints>>(KEY)

Your plugin’s root component calls provideWaypoints(); its children — and the host app — call useWaypoints(). This is exactly the shape of useFullscreen and useSelectionPlugin, and it’s how a plugin publishes an API without prop drilling.

Tool plugins that need the pointer claim it through the shared useSettings hook, which exposes an interactionMode ('navigate' | 'measure' | 'select' | 'gizmo' | 'move'). Flip it when your tool activates so other tools stand down:

<script lang="ts">
	import { useSettings } from '@viamrobotics/motion-tools'

	const settings = useSettings()
	const active = $derived(settings.current.interactionMode === 'measure')

	function toggle() {
		settings.current.interactionMode = active ? 'navigate' : 'measure'
	}
</script>

For picking points and objects in the scene, use Threlte’s pointer events — the visualizer already enables interactivity(), so <T.Mesh onclick={…} onpointermove={…}> and <HTML> handlers work out of the box.

The plugin model is the right home for heavy or situational dependencies: keep the import inside the plugin, and consumers only pay for it when they mount it. <SelectionTool /> needs earcut; <DrawService /> needs the Connect RPC client. Neither is pulled in unless you use that plugin.

If your plugin needs an extra package, import it normally, declare it a peer dependency of your plugin, and document the one-line install in your plugin’s own docs — the same way the built-in plugin pages open with an Install section.

Nothing special — a plugin is a component. Put the .svelte file (and any use*.svelte.ts hook or trait module) wherever your app keeps components, then import Visualizer from @viamrobotics/motion-tools and your plugin from its own path, and mount it as a child:

<script lang="ts">
	import { Visualizer } from '@viamrobotics/motion-tools'

	import Waypoint from '$lib/Waypoint.svelte'
</script>

<div class="h-screen w-screen">
	<Visualizer>
		<Waypoint />
	</Visualizer>
</div>

Import scene/ECS/portal helpers from the package root (@viamrobotics/motion-tools), Threlte primitives from @threlte/core / @threlte/extras, and trait / relation from koota. To share a plugin across projects, extract it to your own package with @viamrobotics/motion-tools as a peer dependency — it stays a plain Svelte component.

  • The plugin is a child of <Visualizer /> — it relies on the contexts the visualizer provides, not props passed down from the host.
  • DOM UI is teleported through a Portal (DashboardPortal / WorkspacePortal / SettingsPortal / FloatingPanel), never rendered inline in the canvas.
  • Shared state goes through the ECS world (useWorld / useQuery / useTrait); UI-only state stays local ($state / $derived).
  • Any published API is a use* hook backed by a provide* context with getters.
  • Imperative Three.js mutations are followed by invalidate().
  • Optional dependencies live inside the plugin and are documented with an install line.
  • External plugins import only from @viamrobotics/motion-tools, /plugins, /lib, and peer packages — internal $lib/* paths are for in-repo plugins only.