diff --git a/.changeset/details-panel-subcomponents.md b/.changeset/details-panel-subcomponents.md
new file mode 100644
index 000000000..c40219b82
--- /dev/null
+++ b/.changeset/details-panel-subcomponents.md
@@ -0,0 +1,5 @@
+---
+'@viamrobotics/motion-tools': patch
+---
+
+Split the Details overlay into focused subcomponents under `details/` and expose a `details-extensions` portal target for plugin-supplied editors
diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte
index 4b4dcc1e3..d48f69a24 100644
--- a/src/lib/components/overlay/Details.svelte
+++ b/src/lib/components/overlay/Details.svelte
@@ -13,13 +13,14 @@
+
+
+ show axes helper
+ handleToggle(event.detail)}
+ />
+
diff --git a/src/lib/components/overlay/details/ColorDetails.svelte b/src/lib/components/overlay/details/ColorDetails.svelte
new file mode 100644
index 000000000..21b613101
--- /dev/null
+++ b/src/lib/components/overlay/details/ColorDetails.svelte
@@ -0,0 +1,35 @@
+
+
+{#if color.current}
+
+ color
+
+
+{/if}
diff --git a/src/lib/components/overlay/details/GeometryDetails.svelte b/src/lib/components/overlay/details/GeometryDetails.svelte
new file mode 100644
index 000000000..2fa9ad68d
--- /dev/null
+++ b/src/lib/components/overlay/details/GeometryDetails.svelte
@@ -0,0 +1,104 @@
+
+
+{#if box.current}
+
+
dimensions
+
(box) (mm)
+
+
+{:else if sphere.current}
+
+
dimensions
+
(sphere) (mm)
+
+
+
+
+{:else if capsule.current}
+
+
dimensions
+
(capsule) (mm)
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte
new file mode 100644
index 000000000..92825458d
--- /dev/null
+++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte
@@ -0,0 +1,196 @@
+
+
+{#if linePositions.current}
+
+
+ line positions
+ (m, {linePositionList.length})
+
+
+
+ {#each linePositionList as position, index (index)}
+
+
+
{
+ if (event.detail.origin !== 'internal') return
+ writeLinePosition(index, event.detail.value as PointValue3dObject)
+ }}
+ />
+
+
+
+ {/each}
+
+
+
+
+
+
+ line width
+
+
+
+ {#if lineColor.current}
+
+ line color
+
+
+ {/if}
+
+
+ dot size
+
+
+
+ {#if dotColorValue}
+
+ dot color
+
+
+ {/if}
+{/if}
diff --git a/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts b/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts
new file mode 100644
index 000000000..72a339f47
--- /dev/null
+++ b/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/svelte'
+import { createWorld } from 'koota'
+import '@testing-library/jest-dom/vitest'
+import { describe, expect, it } from 'vitest'
+
+import { traits } from '$lib/ecs'
+import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld'
+
+import LineDetails from '../LineDetails.svelte'
+
+describe('LineDetails', () => {
+ const world = createWorld()
+
+ it('renders line positions section when entity has LinePositions trait', () => {
+ const entity = world.spawn(traits.LinePositions(new Float32Array([0, 0, 0, 1, 1, 1, 2, 2, 2])))
+ render(LineDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByLabelText('mutable line positions')).toBeInTheDocument()
+ })
+
+ it('renders nothing when entity has no LinePositions trait', () => {
+ const entity = world.spawn()
+ render(LineDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.queryByLabelText('mutable line positions')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts
new file mode 100644
index 000000000..4b48716e8
--- /dev/null
+++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest'
+
+import { appendLinePosition, removeLinePosition, writeLinePosition } from '../linePositions'
+
+describe('writeLinePosition', () => {
+ it('overwrites the point at the given index', () => {
+ const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ const next = writeLinePosition(source, 1, 40, 50, 60)
+ expect([...next]).toEqual([1, 2, 3, 40, 50, 60, 7, 8, 9])
+ })
+
+ it('returns a fresh Float32Array (does not mutate input)', () => {
+ const source = new Float32Array([1, 2, 3])
+ const next = writeLinePosition(source, 0, 10, 20, 30)
+ expect(next).not.toBe(source)
+ expect([...source]).toEqual([1, 2, 3])
+ })
+
+ it('returns a copy unchanged when index is out of bounds', () => {
+ const source = new Float32Array([1, 2, 3])
+ const next = writeLinePosition(source, 5, 99, 99, 99)
+ expect(next).not.toBe(source)
+ expect([...next]).toEqual([1, 2, 3])
+ })
+})
+
+describe('appendLinePosition', () => {
+ it('appends a zero-valued point when the source is empty', () => {
+ const next = appendLinePosition(new Float32Array())
+ expect([...next]).toEqual([0, 0, 0])
+ })
+
+ it('appends a point offset by +0.1 in X from the previous tail', () => {
+ const source = new Float32Array([0, 0, 0, 1, 2, 3])
+ const next = appendLinePosition(source)
+ expect(next.length).toBe(9)
+ expect(next[6]).toBeCloseTo(1.1)
+ expect(next[7]).toBe(2)
+ expect(next[8]).toBe(3)
+ })
+
+ it('preserves prior points', () => {
+ const source = new Float32Array([1, 2, 3])
+ const next = appendLinePosition(source)
+ expect([...next.subarray(0, 3)]).toEqual([1, 2, 3])
+ })
+})
+
+describe('removeLinePosition', () => {
+ it('refuses to remove when there is only one point', () => {
+ const source = new Float32Array([0, 0, 0])
+ const next = removeLinePosition(source, 0)
+ expect(next).toBe(source)
+ })
+
+ it('refuses to remove when there are only two points', () => {
+ const source = new Float32Array([0, 0, 0, 1, 1, 1])
+ const next = removeLinePosition(source, 0)
+ expect(next).toBe(source)
+ })
+
+ it('removes the first point', () => {
+ const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ const next = removeLinePosition(source, 0)
+ expect([...next]).toEqual([4, 5, 6, 7, 8, 9])
+ })
+
+ it('removes a middle point', () => {
+ const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ const next = removeLinePosition(source, 1)
+ expect([...next]).toEqual([1, 2, 3, 7, 8, 9])
+ })
+
+ it('removes the last point', () => {
+ const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ const next = removeLinePosition(source, 2)
+ expect([...next]).toEqual([1, 2, 3, 4, 5, 6])
+ })
+
+ it('keeps original array intact', () => {
+ const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ removeLinePosition(source, 1)
+ expect([...source]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ })
+
+ it('returns source unchanged when index is out of bounds', () => {
+ const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ const next = removeLinePosition(source, 5)
+ expect(next).toBe(source)
+ })
+})
diff --git a/src/lib/components/overlay/details/LineDetails/linePositions.ts b/src/lib/components/overlay/details/LineDetails/linePositions.ts
new file mode 100644
index 000000000..16d95c044
--- /dev/null
+++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts
@@ -0,0 +1,37 @@
+export const writeLinePosition = (
+ source: Float32Array,
+ index: number,
+ x: number,
+ y: number,
+ z: number
+): Float32Array => {
+ if (index * 3 + 2 >= source.length) return new Float32Array(source)
+ const next = new Float32Array(source)
+ next[index * 3 + 0] = x
+ next[index * 3 + 1] = y
+ next[index * 3 + 2] = z
+ return next
+}
+
+export const appendLinePosition = (source: Float32Array): Float32Array => {
+ const next = new Float32Array(source.length + 3)
+ next.set(source)
+ const lastIndex = source.length - 3
+ if (lastIndex >= 0) {
+ next[source.length + 0] = source[lastIndex]! + 0.1
+ next[source.length + 1] = source[lastIndex + 1]!
+ next[source.length + 2] = source[lastIndex + 2]!
+ }
+
+ return next
+}
+
+export const removeLinePosition = (source: Float32Array, index: number): Float32Array => {
+ if (source.length <= 6) return source
+ if (index * 3 + 2 >= source.length) return source
+
+ const next = new Float32Array(source.length - 3)
+ next.set(source.subarray(0, index * 3), 0)
+ next.set(source.subarray((index + 1) * 3), index * 3)
+ return next
+}
diff --git a/src/lib/components/overlay/details/OpacityDetails.svelte b/src/lib/components/overlay/details/OpacityDetails.svelte
new file mode 100644
index 000000000..468939534
--- /dev/null
+++ b/src/lib/components/overlay/details/OpacityDetails.svelte
@@ -0,0 +1,44 @@
+
+
+
+
opacity
+
+ v.toFixed(2)}
+ on:change={handleOpacityChange}
+ />
+
+
diff --git a/src/lib/components/overlay/details/PoseDetails.svelte b/src/lib/components/overlay/details/PoseDetails.svelte
new file mode 100644
index 000000000..6b1fa4214
--- /dev/null
+++ b/src/lib/components/overlay/details/PoseDetails.svelte
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
parent
+ {#key entity}
+
+
+
+ {/key}
+
+
+
+
world position
+
(mm)
+
+
x {(worldPose?.x ?? 0).toFixed(2)}
+
y {(worldPose?.y ?? 0).toFixed(2)}
+
z {(worldPose?.z ?? 0).toFixed(2)}
+
+
+
+
+
world orientation
+
(deg)
+
+
x {(worldPose?.oX ?? 0).toFixed(2)}
+
y {(worldPose?.oY ?? 0).toFixed(2)}
+
z {(worldPose?.oZ ?? 0).toFixed(2)}
+
th {(worldPose?.theta ?? 0).toFixed(2)}
+
+
+
+ {#if localPose}
+
+
local position
+
(mm)
+
+
+
+
+ {/if}
+
diff --git a/src/lib/components/overlay/details/__tests__/AxesHelperDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/AxesHelperDetails.svelte.spec.ts
new file mode 100644
index 000000000..3a8bb0cbc
--- /dev/null
+++ b/src/lib/components/overlay/details/__tests__/AxesHelperDetails.svelte.spec.ts
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/svelte'
+import { createWorld } from 'koota'
+import '@testing-library/jest-dom/vitest'
+import { describe, expect, it } from 'vitest'
+
+import { traits } from '$lib/ecs'
+import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld'
+
+import AxesHelperDetails from '../AxesHelperDetails.svelte'
+
+describe('AxesHelperDetails', () => {
+ const world = createWorld()
+
+ it('renders the show axes helper toggle', () => {
+ const entity = world.spawn()
+ render(AxesHelperDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByText('show axes helper')).toBeInTheDocument()
+ })
+
+ it('renders for entities that already have ShowAxesHelper', () => {
+ const entity = world.spawn(traits.ShowAxesHelper)
+ render(AxesHelperDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByText('show axes helper')).toBeInTheDocument()
+ })
+})
diff --git a/src/lib/components/overlay/details/__tests__/ColorDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/ColorDetails.svelte.spec.ts
new file mode 100644
index 000000000..1eb43bc38
--- /dev/null
+++ b/src/lib/components/overlay/details/__tests__/ColorDetails.svelte.spec.ts
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/svelte'
+import { createWorld } from 'koota'
+import '@testing-library/jest-dom/vitest'
+import { describe, expect, it } from 'vitest'
+
+import { traits } from '$lib/ecs'
+import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld'
+
+import ColorDetails from '../ColorDetails.svelte'
+
+describe('ColorDetails', () => {
+ const world = createWorld()
+
+ it('renders color section when entity has Color trait', () => {
+ const entity = world.spawn(traits.Color({ r: 1, g: 0, b: 0 }))
+ render(ColorDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByText('color')).toBeInTheDocument()
+ })
+
+ it('renders nothing when entity has no Color trait', () => {
+ const entity = world.spawn()
+ render(ColorDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.queryByText('color')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/lib/components/overlay/details/__tests__/GeometryDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/GeometryDetails.svelte.spec.ts
new file mode 100644
index 000000000..92cfdfe5d
--- /dev/null
+++ b/src/lib/components/overlay/details/__tests__/GeometryDetails.svelte.spec.ts
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/svelte'
+import { createWorld } from 'koota'
+import '@testing-library/jest-dom/vitest'
+import { describe, expect, it } from 'vitest'
+
+import { traits } from '$lib/ecs'
+import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld'
+
+import GeometryDetails from '../GeometryDetails.svelte'
+
+describe('GeometryDetails', () => {
+ const world = createWorld()
+
+ it('renders box dimensions when entity has Box trait', () => {
+ const entity = world.spawn(traits.Box({ x: 10, y: 20, z: 30 }))
+ render(GeometryDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByLabelText('mutable box dimensions')).toBeInTheDocument()
+ })
+
+ it('renders sphere dimensions when entity has Sphere trait', () => {
+ const entity = world.spawn(traits.Sphere({ r: 50 }))
+ render(GeometryDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByLabelText('mutable sphere dimensions')).toBeInTheDocument()
+ })
+
+ it('renders capsule dimensions when entity has Capsule trait', () => {
+ const entity = world.spawn(traits.Capsule({ r: 25, l: 100 }))
+ render(GeometryDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByLabelText('mutable capsule dimensions')).toBeInTheDocument()
+ })
+
+ it('renders nothing when entity has no geometry trait', () => {
+ const entity = world.spawn()
+ render(GeometryDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.queryByText('dimensions')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts
new file mode 100644
index 000000000..c78f7c9a9
--- /dev/null
+++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts
@@ -0,0 +1,52 @@
+import { render, screen } from '@testing-library/svelte'
+import { createWorld } from 'koota'
+import { Matrix4 } from 'three'
+import '@testing-library/jest-dom/vitest'
+import { describe, expect, it } from 'vitest'
+
+import { traits } from '$lib/ecs'
+import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld'
+import { createPose, poseToMatrix } from '$lib/transform'
+
+import PoseDetails from '../PoseDetails.svelte'
+
+describe('PoseDetails', () => {
+ const world = createWorld()
+ const parentOptions = [{ value: 'world', text: 'world' }]
+ const noop = () => {}
+
+ it('always renders parent, world position, and world orientation sections', () => {
+ const entity = world.spawn()
+ render(PoseDetails, {
+ props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByText('parent')).toBeInTheDocument()
+ expect(screen.getByText('world position')).toBeInTheDocument()
+ expect(screen.getByText('world orientation')).toBeInTheDocument()
+ })
+
+ it('renders local position and orientation when entity has a Matrix', () => {
+ const matrix = poseToMatrix(
+ createPose({ x: 1, y: 2, z: 3, oX: 0.6, oY: 0.8, oZ: 0, theta: 0.4 }),
+ new Matrix4()
+ )
+ const entity = world.spawn(traits.Matrix(matrix))
+ render(PoseDetails, {
+ props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByLabelText('mutable local position')).toBeInTheDocument()
+ expect(screen.getByLabelText('mutable local orientation')).toBeInTheDocument()
+ })
+
+ it('does not render local position/orientation when entity has no matrix or center', () => {
+ const entity = world.spawn()
+ render(PoseDetails, {
+ props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('mutable local orientation')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/lib/components/overlay/details/__tests__/OpacityDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/OpacityDetails.svelte.spec.ts
new file mode 100644
index 000000000..080746591
--- /dev/null
+++ b/src/lib/components/overlay/details/__tests__/OpacityDetails.svelte.spec.ts
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/svelte'
+import { createWorld } from 'koota'
+import '@testing-library/jest-dom/vitest'
+import { describe, expect, it } from 'vitest'
+
+import { traits } from '$lib/ecs'
+import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld'
+
+import OpacityDetails from '../OpacityDetails.svelte'
+
+describe('OpacityDetails', () => {
+ const world = createWorld()
+
+ it('renders the opacity slider', () => {
+ const entity = world.spawn()
+ render(OpacityDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByText('opacity')).toBeInTheDocument()
+ expect(screen.getByLabelText('mutable opacity')).toBeInTheDocument()
+ })
+
+ it('renders for entities with an Opacity trait', () => {
+ const entity = world.spawn(traits.Opacity(0.4))
+ render(OpacityDetails, {
+ props: { entity },
+ context: new Map([[WORLD_CONTEXT_KEY, world]]),
+ })
+ expect(screen.getByLabelText('mutable opacity')).toBeInTheDocument()
+ })
+})
diff --git a/src/lib/ecs/traits.ts b/src/lib/ecs/traits.ts
index d48983c53..66a6790bc 100644
--- a/src/lib/ecs/traits.ts
+++ b/src/lib/ecs/traits.ts
@@ -81,7 +81,7 @@ export const Invisible = trait(() => true)
* `details-extensions` portal target (e.g. gizmo plugin entities) opt in by
* adding this trait.
*/
-export const CustomDetails = trait(() => true)
+export const CustomDetails = trait()
/**
* True when the entity itself, or any of its parents up the `ChildOf`
diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts
index 96cc098a4..76756d462 100644
--- a/vitest-setup-client.ts
+++ b/vitest-setup-client.ts
@@ -16,6 +16,16 @@ vi.mock('@threlte/core', () => ({
isInstanceOf: vi.fn(() => false),
}))
+// `@threlte/extras` components (PortalTarget, HTML, etc.) call into Threlte's
+// internal context which requires a `