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.
How a plugin fits into the scene
Section titled “How a plugin fits into the scene”<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.
Your first plugin
Section titled “Your first plugin”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.
Mount it as a child of <Visualizer />:
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:
The rest of this guide is the toolkit for going further.
Rendering into the scene
Section titled “Rendering into the scene”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/extrasanchors HTML to a world position.<MeasureTool />uses it to float a distance readout at the midpoint of a measurement. - Per-frame work —
useTask(() => { … })from@threlte/coreruns a callback in Threlte’s render loop; use it instead of$effectfor anything animated. - Performance — opt display-only geometry out of hit-testing with
raycast={() => null}orbvh={{ enabled: false }}.
After any imperative Three.js mutation, call invalidate() from useThrelte() so the on-demand renderer repaints.
Adding overlay UI
Section titled “Adding overlay UI”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:
| Portal | Lands in | Use for |
|---|---|---|
DashboardPortal | Top-center toolbar | Mode toggles and tool buttons |
WorkspacePortal | Top-right, by the viewer badge | Panel launchers and workspace-level toggles |
SettingsPortal | A new tab in the settings pane | Plugin configuration that belongs with the other settings |
FloatingPanel | A draggable, resizable panel | Rich UI that shouldn’t crowd the toolbar |
For a launcher-plus-panel pattern, pair a WorkspacePortal button with a FloatingPanel whose isOpen you bind — this is exactly how <ControlWidgets /> works:
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.
Reading and writing scene state
Section titled “Reading and writing scene state”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:
Read the set of entities carrying a trait, and react to it, with useQuery:
Read a single entity’s trait with useTrait — note the target is a getter function:
Defining your own traits
Section titled “Defining your own traits”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.
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.
Sharing state with a hook
Section titled “Sharing state with a hook”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.
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.
Reacting to user input
Section titled “Reacting to user input”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:
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.
Optional dependencies
Section titled “Optional dependencies”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.
Packaging your plugin
Section titled “Packaging your plugin”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:
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.
Contributing a plugin back means it ships in @viamrobotics/motion-tools/plugins for everyone.
Plugins in the repo import through the $lib alias, so they can use internal hooks
(useCameraControls, useMouseRaycaster, …) and components that aren’t publicly exported.
-
Create a directory under
src/lib/plugins/named after the plugin, e.g.src/lib/plugins/Waypoint/. Put the root component (Waypoint.svelte), any context hook (useWaypoints.svelte.ts), and trait modules there. Import shared code via$lib,$lib/ecs, and$lib/hooks/*. -
Export it from
src/lib/plugins/index.ts: -
Add a
__tests__/directory next to the component and cover the behavior with Vitest (*.svelte.spec.ts), following the neighboring plugins. -
Write a docs page at
docs/src/content/docs/plugins/waypoint.mdx(use an existing plugin page as the template) and add it to the Plugins sidebar group indocs/astro.config.mjs. -
Add a changeset —
pnpm changeset,minorfor a new plugin — then verify:
Checklist
Section titled “Checklist”- 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 aprovide*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.