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) +
+ +
+
+ +
+ local orientation +
+ + + + + + + + +
+
+ {/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 `` parent. Tests render Svelte +// components in isolation, so stub the pieces Details / plugin Details panels +// touch with no-op components. +vi.mock('@threlte/extras', () => ({ + PortalTarget: vi.fn(), + Portal: vi.fn(), + HTML: vi.fn(), +})) + // Mock useFrames hook vi.mock('$lib/hooks/useFrames.svelte', () => ({ useFrames: vi.fn(() => ({ current: [], fetching: false })),