From faf59c0cf0478bc4f18f45a92649de85f0ea8fb6 Mon Sep 17 00:00:00 2001 From: Devin C Date: Mon, 1 Jun 2026 16:19:59 -0400 Subject: [PATCH 01/40] Add shared ECS traits and helpers for entity pose plugins Foundation for the upcoming Gizmos plugin. No callers yet; these symbols are consumed by follow-up PRs in the stack. * writeMatrix(entity, patch): patch a Pose onto an entity's Matrix trait via createPose/matrixToPose/poseToMatrix, then dirty the trait. Used by the refactored Details panel and the Gizmos plugin. * CustomDetails trait: marker so the Details panel suppresses its default frame/pose UI for entities that render their own. * useMouseRaycaster firstHitOnly option (forwarded to three-mesh-bvh). * 'gizmo' value added to Settings.interactionMode. * Re-exports of DEFAULT_LINE_WIDTH and ARROW_LENGTH for downstream consumers. --- src/lib/draw.ts | 2 +- src/lib/ecs/__tests__/writeMatrix.spec.ts | 67 +++++++++++++++++++++++ src/lib/ecs/traits.ts | 21 ++++++- src/lib/hooks/useMouseRaycaster.svelte.ts | 12 +++- src/lib/hooks/useSettings.svelte.ts | 2 +- src/lib/three/arrow.ts | 5 +- 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/lib/ecs/__tests__/writeMatrix.spec.ts diff --git a/src/lib/draw.ts b/src/lib/draw.ts index dd433f1df..27620a26b 100644 --- a/src/lib/draw.ts +++ b/src/lib/draw.ts @@ -35,7 +35,7 @@ import { isPointCloud } from './geometry' const vec3 = new Vector3() const rgb = { r: 0, g: 0, b: 0 } -const DEFAULT_LINE_WIDTH = 5 +export const DEFAULT_LINE_WIDTH = 5 const DEFAULT_POINT_SIZE = 10 const DEFAULT_NURBS_DEGREE = 3 const DEFAULT_NURBS_WEIGHT = 1 diff --git a/src/lib/ecs/__tests__/writeMatrix.spec.ts b/src/lib/ecs/__tests__/writeMatrix.spec.ts new file mode 100644 index 000000000..198858a03 --- /dev/null +++ b/src/lib/ecs/__tests__/writeMatrix.spec.ts @@ -0,0 +1,67 @@ +import { createWorld } from 'koota' +import { Matrix4 } from 'three' +import { describe, expect, it, vi } from 'vitest' + +import { traits } from '$lib/ecs' +import { createPose, matrixToPose, poseToMatrix } from '$lib/transform' + +import { writeMatrix } from '../traits' + +const matrix = () => + poseToMatrix( + createPose({ + x: 10, + y: 20, + z: 30, + oX: 0.6, + oY: 0.8, + oZ: 0, + theta: 45, + }), + new Matrix4() + ) + +describe('writeMatrix', () => { + it('no-ops when the entity has no Matrix trait', () => { + const world = createWorld() + const entity = world.spawn() + expect(() => writeMatrix(entity, { x: 1 })).not.toThrow() + }) + + it('overwrites only the supplied position fields', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + writeMatrix(entity, { x: 99 }) + const pose = matrixToPose(entity.get(traits.Matrix)!, createPose()) + expect(pose.x).toBeCloseTo(99) + expect(pose.y).toBeCloseTo(20) + expect(pose.z).toBeCloseTo(30) + }) + + it('overwrites only the supplied orientation fields', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + writeMatrix(entity, { theta: 90 }) + const pose = matrixToPose(entity.get(traits.Matrix)!, createPose()) + expect(pose.x).toBeCloseTo(10) + expect(pose.theta).toBeCloseTo(90) + }) + + it('notifies subscribers via entity.changed', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + const onChange = vi.fn() + world.onChange(traits.Matrix, onChange) + writeMatrix(entity, { x: 5 }) + expect(onChange).toHaveBeenCalledWith(entity) + }) + + it('mutates the existing Matrix4 in place (does not allocate a new one)', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + const before = entity.get(traits.Matrix) + writeMatrix(entity, { x: 5 }) + const after = entity.get(traits.Matrix) + expect(after).toBe(before) + }) +}) diff --git a/src/lib/ecs/traits.ts b/src/lib/ecs/traits.ts index 71d447311..8921de085 100644 --- a/src/lib/ecs/traits.ts +++ b/src/lib/ecs/traits.ts @@ -1,6 +1,6 @@ import type { GLTF as ThreeGltf } from 'three/examples/jsm/loaders/GLTFLoader.js' -import { Geometry as ViamGeometry } from '@viamrobotics/sdk' +import { Pose, Geometry as ViamGeometry } from '@viamrobotics/sdk' import { type Entity, trait } from 'koota' import { Matrix4, BufferGeometry as ThreeBufferGeometry } from 'three' @@ -9,6 +9,7 @@ import { ColorFormat } from '$lib/buf/draw/v1/metadata_pb' import { createBox, createCapsule, createSphere } from '$lib/geometry' import { parsePcdInWorker } from '$lib/loaders/pcd' import { parsePlyInput } from '$lib/ply' +import { createPose, matrixToPose, poseToMatrix } from '$lib/transform' export const Name = trait(() => '') export const UUID = trait(() => '') @@ -71,9 +72,27 @@ export const InstancedMatrix = trait(() => ({ index: -1, })) +export const writeMatrix = (entity: Entity, patch: Partial) => { + const matrix = entity.get(Matrix) + if (!matrix) return + + const pose = matrixToPose(matrix, createPose()) + Object.assign(pose, patch) + poseToMatrix(pose, matrix) + entity.changed(Matrix) +} + export const Hovered = trait(() => true) export const Invisible = trait(() => true) +/** + * Suppresses the default frame-style world/local pose and parent-frame blocks + * in the details panel. Entities that render their own pose UI via the + * `details-extensions` portal target (e.g. gizmo plugin entities) opt in by + * adding this trait. + */ +export const CustomDetails = trait(() => true) + /** * True when the entity itself, or any of its parents up the `ChildOf` * chain, has `Invisible`. Maintained by `provideInheritedInvisible`; diff --git a/src/lib/hooks/useMouseRaycaster.svelte.ts b/src/lib/hooks/useMouseRaycaster.svelte.ts index 65e78fefe..3a3179b8c 100644 --- a/src/lib/hooks/useMouseRaycaster.svelte.ts +++ b/src/lib/hooks/useMouseRaycaster.svelte.ts @@ -14,11 +14,17 @@ interface RaycastEvent { type Callback = (event: RaycastEvent) => void -export const useMouseRaycaster = (getOptions?: () => { enabled: boolean }) => { +interface MouseRaycasterOptions { + enabled?: boolean + firstHitOnly?: boolean +} + +export const useMouseRaycaster = (getOptions?: () => MouseRaycasterOptions) => { let intersections: Intersection[] = [] - const options = $derived({ + const options = $derived>({ enabled: true, + firstHitOnly: false, ...getOptions?.(), }) @@ -110,6 +116,8 @@ export const useMouseRaycaster = (getOptions?: () => { enabled: boolean }) => { return } + raycaster.firstHitOnly = options.firstHitOnly + dom.addEventListener('pointermove', onPointerMove, { passive: true }) dom.addEventListener('pointerdown', onPointerDown, { passive: true }) dom.addEventListener('pointerup', onPointerUp, { passive: true }) diff --git a/src/lib/hooks/useSettings.svelte.ts b/src/lib/hooks/useSettings.svelte.ts index fea0e6079..bd68c77d5 100644 --- a/src/lib/hooks/useSettings.svelte.ts +++ b/src/lib/hooks/useSettings.svelte.ts @@ -7,7 +7,7 @@ const key = Symbol('dashboard-context') export interface Settings { cameraMode: 'orthographic' | 'perspective' - interactionMode: 'navigate' | 'measure' | 'select' + interactionMode: 'navigate' | 'measure' | 'select' | 'gizmo' refreshRates: { poses: number pointclouds: number diff --git a/src/lib/three/arrow.ts b/src/lib/three/arrow.ts index 4798d5e69..2a6e35439 100644 --- a/src/lib/three/arrow.ts +++ b/src/lib/three/arrow.ts @@ -1,13 +1,16 @@ import { BoxGeometry, type BufferGeometry, ConeGeometry } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +/** Total length of the geometry produced by `createArrowGeometry`, in meters. */ +export const ARROW_LENGTH = 0.1 + /** * Returns one merged geometry for an arrow (box tail + cone head) * * Arrow points along +Y with its base at y = 0 */ export const createArrowGeometry = (): BufferGeometry => { - const length = 0.1 + const length = ARROW_LENGTH const headLength = length * 0.3 const headWidth = headLength * 0.3 const tailLength = length - headLength From 9e637c68d55807cbcdfb4f23e1b0581c908b4c67 Mon Sep 17 00:00:00 2001 From: Devin C Date: Mon, 1 Jun 2026 17:11:54 -0400 Subject: [PATCH 02/40] Add changeset --- .changeset/gizmo-foundation-utils.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gizmo-foundation-utils.md diff --git a/.changeset/gizmo-foundation-utils.md b/.changeset/gizmo-foundation-utils.md new file mode 100644 index 000000000..175894a35 --- /dev/null +++ b/.changeset/gizmo-foundation-utils.md @@ -0,0 +1,5 @@ +--- +'@viamrobotics/motion-tools': patch +--- + +Add `writeMatrix` and `CustomDetails` ECS traits, a `firstHitOnly` raycaster option, and a `'gizmo'` interaction mode From 02bc0d09fa57225f891f8b63e10f410c37462340 Mon Sep 17 00:00:00 2001 From: Devin C Date: Mon, 1 Jun 2026 16:30:35 -0400 Subject: [PATCH 03/40] Refactor Details panel into focused subcomponents The Details overlay component had grown to handle four entirely different entity shapes (color, geometry, line, matrix) inline. Split each into its own component under details/ so callers and reviewers can reason about one entity type at a time. * ColorDetails.svelte, GeometryDetails.svelte, MatrixDetails.svelte and LineDetails/LineDetails.svelte are now individual focused components. * linePositions.ts extracts the line-position derivation with its own spec. * MatrixDetails uses writeMatrix() added in the prior PR. * vitest-setup-client.ts stubs @threlte/extras Portal / PortalTarget / HTML so the refactored panel can render in unit tests. Behavior should be unchanged; existing Details.svelte.spec.ts continues to pass with the new mocks. --- src/lib/components/overlay/Details.svelte | 469 ++++++++++-------- .../overlay/details/ColorDetails.svelte | 32 ++ .../overlay/details/GeometryDetails.svelte | 98 ++++ .../details/LineDetails/LineDetails.svelte | 164 ++++++ .../__tests__/linePositions.spec.ts | 72 +++ .../details/LineDetails/linePositions.ts | 35 ++ .../overlay/details/MatrixDetails.svelte | 198 ++++++++ vitest-setup-client.ts | 10 + 8 files changed, 865 insertions(+), 213 deletions(-) create mode 100644 src/lib/components/overlay/details/ColorDetails.svelte create mode 100644 src/lib/components/overlay/details/GeometryDetails.svelte create mode 100644 src/lib/components/overlay/details/LineDetails/LineDetails.svelte create mode 100644 src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts create mode 100644 src/lib/components/overlay/details/LineDetails/linePositions.ts create mode 100644 src/lib/components/overlay/details/MatrixDetails.svelte diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index fcde50474..568c3b7f8 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -13,12 +13,13 @@ + +{#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..54b60921e --- /dev/null +++ b/src/lib/components/overlay/details/GeometryDetails.svelte @@ -0,0 +1,98 @@ + + +{#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..d4a9f23cc --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -0,0 +1,164 @@ + + +{#if linePositions.current} +
+ line positions + (m) +
+
+ {#each linePositionList as position, index (index)} +
+
+ { + if (event.detail.origin !== 'internal') return + writeLinePosition(index, event.detail.value as PointValue3dObject) + }} + /> +
+ +
+ {/each} +
+ +
+
+ +
+ line width + +
+ +
+ dot size + +
+ + {#if dotColorValue} +
+ dot color + +
+ {/if} +{/if} 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..2c225246d --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts @@ -0,0 +1,72 @@ +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]) + }) +}) + +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 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]) + }) +}) 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..d7515ddca --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts @@ -0,0 +1,35 @@ +export const writeLinePosition = ( + source: Float32Array, + index: number, + x: number, + y: number, + z: number +): Float32Array => { + 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 + + 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/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte new file mode 100644 index 000000000..b95558b8c --- /dev/null +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -0,0 +1,198 @@ + + + + +
+
+ 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/vitest-setup-client.ts b/vitest-setup-client.ts index 03cc3e15b..463e609c2 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -14,6 +14,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 selection hooks vi.mock('$lib/hooks/useSelection.svelte', () => ({ useFocused: vi.fn(() => ({ current: undefined, set: vi.fn() })), From bf4974759c61ef4a18985483af016fae07daf8a2 Mon Sep 17 00:00:00 2001 From: Devin C Date: Mon, 1 Jun 2026 17:13:58 -0400 Subject: [PATCH 04/40] Add changeset --- .changeset/details-panel-subcomponents.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/details-panel-subcomponents.md 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 From 77eed59efb81766695e8910443151a41f8f8d196 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:27:41 +0000 Subject: [PATCH 05/40] Address review feedback on writeMatrix and useMouseRaycaster - Move writeMatrix below updateGeometryTrait for consistent file layout - Add JSDoc to writeMatrix documenting round-trip semantics and no-op behavior - Filter undefined values from patch before Object.assign to prevent NaN in matrix - Add comment clarifying firstHitOnly affects both hover and click handlers - Strengthen orientation test with oX/oY/oZ assertions Co-authored-by: Devin T. Currie --- src/lib/ecs/__tests__/writeMatrix.spec.ts | 3 +++ src/lib/ecs/traits.ts | 28 +++++++++++++++-------- src/lib/hooks/useMouseRaycaster.svelte.ts | 3 +++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/lib/ecs/__tests__/writeMatrix.spec.ts b/src/lib/ecs/__tests__/writeMatrix.spec.ts index 198858a03..aa5a2f4a0 100644 --- a/src/lib/ecs/__tests__/writeMatrix.spec.ts +++ b/src/lib/ecs/__tests__/writeMatrix.spec.ts @@ -45,6 +45,9 @@ describe('writeMatrix', () => { const pose = matrixToPose(entity.get(traits.Matrix)!, createPose()) expect(pose.x).toBeCloseTo(10) expect(pose.theta).toBeCloseTo(90) + expect(pose.oX).toBeCloseTo(0.6) + expect(pose.oY).toBeCloseTo(0.8) + expect(pose.oZ).toBeCloseTo(0) }) it('notifies subscribers via entity.changed', () => { diff --git a/src/lib/ecs/traits.ts b/src/lib/ecs/traits.ts index 8921de085..7e2e43dba 100644 --- a/src/lib/ecs/traits.ts +++ b/src/lib/ecs/traits.ts @@ -72,16 +72,6 @@ export const InstancedMatrix = trait(() => ({ index: -1, })) -export const writeMatrix = (entity: Entity, patch: Partial) => { - const matrix = entity.get(Matrix) - if (!matrix) return - - const pose = matrixToPose(matrix, createPose()) - Object.assign(pose, patch) - poseToMatrix(pose, matrix) - entity.changed(Matrix) -} - export const Hovered = trait(() => true) export const Invisible = trait(() => true) @@ -310,6 +300,24 @@ export const updateGeometryTrait = (entity: Entity, geometry?: ViamGeometry) => } } +/** + * Patches an entity's `Matrix` trait in-place via the `Pose` round-trip + * (`matrixToPose` → merge → `poseToMatrix`), then signals `entity.changed(Matrix)`. + * No-ops silently if the entity has no `Matrix` trait. + */ +export const writeMatrix = (entity: Entity, patch: Partial) => { + const matrix = entity.get(Matrix) + if (!matrix) return + + const pose = matrixToPose(matrix, createPose()) + const filtered = Object.fromEntries( + Object.entries(patch).filter(([, v]) => v !== undefined) + ) as Partial + Object.assign(pose, filtered) + poseToMatrix(pose, matrix) + entity.changed(Matrix) +} + const updatePointCloud = (entity: Entity, pointCloud: Uint8Array): void => { parsePcdInWorker(new Uint8Array(pointCloud)) .then((parsed) => { diff --git a/src/lib/hooks/useMouseRaycaster.svelte.ts b/src/lib/hooks/useMouseRaycaster.svelte.ts index 3a3179b8c..412f58be1 100644 --- a/src/lib/hooks/useMouseRaycaster.svelte.ts +++ b/src/lib/hooks/useMouseRaycaster.svelte.ts @@ -116,6 +116,9 @@ export const useMouseRaycaster = (getOptions?: () => MouseRaycasterOptions) => { return } + // firstHitOnly is set on the shared raycaster instance, so it applies to + // both onPointerMove (hover) and onPointerUp (click). This is intentional + // for gizmo-style consumers that only care about the closest hit. raycaster.firstHitOnly = options.firstHitOnly dom.addEventListener('pointermove', onPointerMove, { passive: true }) From 0d33dea92840266d20c8b0cd5cd9ecc69c4c5ee6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:16:41 +0000 Subject: [PATCH 06/40] Address review feedback on Details refactor - linePositions.ts: add bounds check to writeLinePosition; return copy unchanged on out-of-bounds index - Details.svelte: await navigator.clipboard.writeText() and wrap in try/catch to handle permission denial - MatrixDetails.svelte: add comments explaining useQuery vs configFrames and unconditional rendering - linePositions.spec.ts: add edge cases for out-of-bounds write and single-point removal guard - Add component tests for ColorDetails, GeometryDetails, MatrixDetails, and LineDetails Co-authored-by: Devin T. Currie --- src/lib/components/overlay/Details.svelte | 6 ++- .../__tests__/LineDetails.svelte.spec.ts | 33 ++++++++++++ .../__tests__/linePositions.spec.ts | 13 +++++ .../details/LineDetails/linePositions.ts | 1 + .../overlay/details/MatrixDetails.svelte | 6 +++ .../__tests__/ColorDetails.svelte.spec.ts | 31 ++++++++++++ .../__tests__/GeometryDetails.svelte.spec.ts | 49 ++++++++++++++++++ .../__tests__/MatrixDetails.svelte.spec.ts | 50 +++++++++++++++++++ 8 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/ColorDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/GeometryDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 568c3b7f8..15f9f757a 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -457,7 +457,11 @@ class="text-subtle-2" aria-describedby={tooltipID} onclick={async () => { - navigator.clipboard.writeText(getCopyClipboardText()) + try { + await navigator.clipboard.writeText(getCopyClipboardText()) + } catch { + // clipboard unavailable (non-secure context or permission denied) + } copied = true setTimeout(() => (copied = false), 1000) }} 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..2b9a5844c --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts @@ -0,0 +1,33 @@ +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 index 2c225246d..bd845e23c 100644 --- a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts +++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts @@ -15,6 +15,13 @@ describe('writeLinePosition', () => { 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', () => { @@ -40,6 +47,12 @@ describe('appendLinePosition', () => { }) 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) diff --git a/src/lib/components/overlay/details/LineDetails/linePositions.ts b/src/lib/components/overlay/details/LineDetails/linePositions.ts index d7515ddca..442316ba4 100644 --- a/src/lib/components/overlay/details/LineDetails/linePositions.ts +++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts @@ -5,6 +5,7 @@ export const writeLinePosition = ( 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 diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index b95558b8c..5584e16e0 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -46,6 +46,9 @@ const worldMatrix = useTrait(() => entity, traits.WorldMatrix) const center = useTrait(() => entity, traits.Center) const parent = useParentName(() => entity) + // Query all named entities in the world (not config-validated frames) so + // gizmo entities can parent to any named entity, including other gizmos. + // This intentionally diverges from Details.svelte's configFrames.getParentFrameOptions(). const namedEntities = useQuery(traits.Name) const parentOptions = $derived.by(() => { @@ -122,6 +125,9 @@ } +
parent 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..5630d4baf --- /dev/null +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -0,0 +1,50 @@ +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 MatrixDetails from '../MatrixDetails.svelte' + +describe('MatrixDetails', () => { + const world = createWorld() + + it('always renders parent, world position, and world orientation sections', () => { + const entity = world.spawn() + render(MatrixDetails, { + props: { entity }, + 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(MatrixDetails, { + props: { entity }, + 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(MatrixDetails, { + props: { entity }, + context: new Map([[WORLD_CONTEXT_KEY, world]]), + }) + expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument() + expect(screen.queryByLabelText('mutable local orientation')).not.toBeInTheDocument() + }) +}) From dfef8377491a25c912a9f630061de4b52acf8077 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:26:46 +0000 Subject: [PATCH 07/40] Address review feedback: writeMatrix early exit, undefined tests, firstHitOnly effect split - Add early-exit guard in writeMatrix when filtered patch is empty to avoid spurious entity.changed(Matrix) notifications - Add tests for undefined field filtering and empty/all-undefined patch paths - Split firstHitOnly assignment into its own $effect so toggling it doesn't tear down and re-attach event listeners Co-authored-by: Devin T. Currie --- src/lib/ecs/__tests__/writeMatrix.spec.ts | 28 +++++++++++++++++++++++ src/lib/ecs/traits.ts | 1 + src/lib/hooks/useMouseRaycaster.svelte.ts | 14 ++++++++---- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/lib/ecs/__tests__/writeMatrix.spec.ts b/src/lib/ecs/__tests__/writeMatrix.spec.ts index aa5a2f4a0..158fc005a 100644 --- a/src/lib/ecs/__tests__/writeMatrix.spec.ts +++ b/src/lib/ecs/__tests__/writeMatrix.spec.ts @@ -67,4 +67,32 @@ describe('writeMatrix', () => { const after = entity.get(traits.Matrix) expect(after).toBe(before) }) + + it('ignores explicitly undefined fields', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + writeMatrix(entity, { x: undefined }) + const pose = matrixToPose(entity.get(traits.Matrix)!, createPose()) + expect(pose.x).toBeCloseTo(10) + expect(pose.y).toBeCloseTo(20) + expect(pose.z).toBeCloseTo(30) + }) + + it('does not notify subscribers when patch is empty', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + const onChange = vi.fn() + world.onChange(traits.Matrix, onChange) + writeMatrix(entity, {}) + expect(onChange).not.toHaveBeenCalled() + }) + + it('does not notify subscribers when all patch fields are undefined', () => { + const world = createWorld() + const entity = world.spawn(traits.Matrix(matrix())) + const onChange = vi.fn() + world.onChange(traits.Matrix, onChange) + writeMatrix(entity, { x: undefined, y: undefined }) + expect(onChange).not.toHaveBeenCalled() + }) }) diff --git a/src/lib/ecs/traits.ts b/src/lib/ecs/traits.ts index 7e2e43dba..bd75990f7 100644 --- a/src/lib/ecs/traits.ts +++ b/src/lib/ecs/traits.ts @@ -313,6 +313,7 @@ export const writeMatrix = (entity: Entity, patch: Partial) => { const filtered = Object.fromEntries( Object.entries(patch).filter(([, v]) => v !== undefined) ) as Partial + if (Object.keys(filtered).length === 0) return Object.assign(pose, filtered) poseToMatrix(pose, matrix) entity.changed(Matrix) diff --git a/src/lib/hooks/useMouseRaycaster.svelte.ts b/src/lib/hooks/useMouseRaycaster.svelte.ts index 412f58be1..8a01991dd 100644 --- a/src/lib/hooks/useMouseRaycaster.svelte.ts +++ b/src/lib/hooks/useMouseRaycaster.svelte.ts @@ -111,16 +111,20 @@ export const useMouseRaycaster = (getOptions?: () => MouseRaycasterOptions) => { intersections = currentIntersections } + // firstHitOnly is set on the shared raycaster instance, so it applies to + // both onPointerMove (hover) and onPointerUp (click). This is intentional + // for gizmo-style consumers that only care about the closest hit. + // Kept in its own effect so toggling firstHitOnly doesn't tear down and + // re-attach the event listeners. + $effect(() => { + raycaster.firstHitOnly = options.firstHitOnly + }) + $effect(() => { if (!options.enabled) { return } - // firstHitOnly is set on the shared raycaster instance, so it applies to - // both onPointerMove (hover) and onPointerUp (click). This is intentional - // for gizmo-style consumers that only care about the closest hit. - raycaster.firstHitOnly = options.firstHitOnly - dom.addEventListener('pointermove', onPointerMove, { passive: true }) dom.addEventListener('pointerdown', onPointerDown, { passive: true }) dom.addEventListener('pointerup', onPointerUp, { passive: true }) From c00b48ad5630b2c6feb2c7d6d37bc08917932ef5 Mon Sep 17 00:00:00 2001 From: "Devin T. Currie" Date: Tue, 2 Jun 2026 10:47:06 -0400 Subject: [PATCH 08/40] Update src/lib/ecs/traits.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/lib/ecs/traits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ecs/traits.ts b/src/lib/ecs/traits.ts index bd75990f7..03ce3342a 100644 --- a/src/lib/ecs/traits.ts +++ b/src/lib/ecs/traits.ts @@ -1,6 +1,6 @@ import type { GLTF as ThreeGltf } from 'three/examples/jsm/loaders/GLTFLoader.js' -import { Pose, Geometry as ViamGeometry } from '@viamrobotics/sdk' +import { type Pose, Geometry as ViamGeometry } from '@viamrobotics/sdk' import { type Entity, trait } from 'koota' import { Matrix4, BufferGeometry as ThreeBufferGeometry } from 'three' From b18c1671ed55e60237f652e8941f6632c4bc3984 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:02:20 +0000 Subject: [PATCH 09/40] Add invalidate() to subcomponents and fix Details handleParentChange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Details.svelte handleParentChange: add invalidate() after setParent() so reparenting non-frame entities triggers a Three.js re-render - ColorDetails, GeometryDetails, LineDetails: add useThrelte() and call invalidate() after every ECS trait mutation that affects rendering - Details.svelte: revert panel width w-80 → w-70 (accidental experiment change) - LineDetails.svelte.spec.ts: fix pre-existing prettier formatting Co-authored-by: Devin T. Currie --- src/lib/components/overlay/Details.svelte | 3 ++- src/lib/components/overlay/details/ColorDetails.svelte | 3 +++ src/lib/components/overlay/details/GeometryDetails.svelte | 6 ++++++ .../overlay/details/LineDetails/LineDetails.svelte | 8 ++++++++ .../LineDetails/__tests__/LineDetails.svelte.spec.ts | 4 +--- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 15f9f757a..794620474 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -281,6 +281,7 @@ const value = event.detail.value as string if (value === parent.current) return hierarchy.setParent(entity, value) + invalidate() // Non-frame entities (gizmos, custom geometries) aren't backed by the // robot config, so skip the config sync. if (isFrameNode) { @@ -348,7 +349,7 @@
import type { Entity } from 'koota' + import { useThrelte } from '@threlte/core' import { Color, type ColorChangeEvent, type ColorValueRgbObject } from 'svelte-tweakpane-ui' import { traits, useTrait } from '$lib/ecs' @@ -11,12 +12,14 @@ let { entity }: Props = $props() + const { invalidate } = useThrelte() const color = useTrait(() => entity, traits.Color) const handleColorChange = (event: ColorChangeEvent) => { if (event.detail.origin !== 'internal') return const next = event.detail.value as ColorValueRgbObject entity.set(traits.Color, { r: next.r, g: next.g, b: next.b }) + invalidate() } diff --git a/src/lib/components/overlay/details/GeometryDetails.svelte b/src/lib/components/overlay/details/GeometryDetails.svelte index 54b60921e..b0db3ce14 100644 --- a/src/lib/components/overlay/details/GeometryDetails.svelte +++ b/src/lib/components/overlay/details/GeometryDetails.svelte @@ -1,6 +1,7 @@ diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte index d4a9f23cc..1ff44a4e0 100644 --- a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -1,6 +1,7 @@ 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 index 2b9a5844c..72a339f47 100644 --- a/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts @@ -12,9 +12,7 @@ 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])) - ) + 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]]), From fd1cd538629853045413658f566555ed5da5ee31 Mon Sep 17 00:00:00 2001 From: "Devin T. Currie" Date: Tue, 2 Jun 2026 12:07:03 -0400 Subject: [PATCH 10/40] Apply suggestion from @claude[bot] Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- vitest-setup-client.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 463e609c2..bfbc5294b 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -12,7 +12,14 @@ vi.mock('@threlte/core', () => ({ }, })), isInstanceOf: vi.fn(() => false), -})) + useThrelte: vi.fn(() => ({ + invalidate: vi.fn(), + scene: { + getObjectByProperty: vi.fn(() => ({ + clone: vi.fn(() => ({ traverse: vi.fn() })), + })), + }, + })), // `@threlte/extras` components (PortalTarget, HTML, etc.) call into Threlte's // internal context which requires a `` parent. Tests render Svelte From b890b953b8c0afe196065212defbae19de9feb9b Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 12:10:43 -0400 Subject: [PATCH 11/40] fix claude's bad suggestion --- vitest-setup-client.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index bfbc5294b..388c416a6 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -5,6 +5,7 @@ import { vi } from 'vitest' vi.mock('@threlte/core', () => ({ useTask: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), useThrelte: vi.fn(() => ({ + invalidate: vi.fn(), scene: { getObjectByProperty: vi.fn(() => ({ clone: vi.fn(() => ({ traverse: vi.fn() })), @@ -12,14 +13,7 @@ vi.mock('@threlte/core', () => ({ }, })), isInstanceOf: vi.fn(() => false), - useThrelte: vi.fn(() => ({ - invalidate: vi.fn(), - scene: { - getObjectByProperty: vi.fn(() => ({ - clone: vi.fn(() => ({ traverse: vi.fn() })), - })), - }, - })), +})) // `@threlte/extras` components (PortalTarget, HTML, etc.) call into Threlte's // internal context which requires a `` parent. Tests render Svelte From 82d5076ac1264a687d86021546ff111859f6b634 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 12:28:33 -0400 Subject: [PATCH 12/40] cleanup --- .../overlay/details/ColorDetails.svelte | 2 +- .../overlay/details/GeometryDetails.svelte | 2 +- .../details/LineDetails/LineDetails.svelte | 2 +- .../overlay/details/MatrixDetails.svelte | 47 ++++++------------- .../__tests__/MatrixDetails.svelte.spec.ts | 7 +-- 5 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/lib/components/overlay/details/ColorDetails.svelte b/src/lib/components/overlay/details/ColorDetails.svelte index 28a5e8c3f..21b613101 100644 --- a/src/lib/components/overlay/details/ColorDetails.svelte +++ b/src/lib/components/overlay/details/ColorDetails.svelte @@ -10,7 +10,7 @@ entity: Entity } - let { entity }: Props = $props() + const { entity }: Props = $props() const { invalidate } = useThrelte() const color = useTrait(() => entity, traits.Color) diff --git a/src/lib/components/overlay/details/GeometryDetails.svelte b/src/lib/components/overlay/details/GeometryDetails.svelte index b0db3ce14..2fa9ad68d 100644 --- a/src/lib/components/overlay/details/GeometryDetails.svelte +++ b/src/lib/components/overlay/details/GeometryDetails.svelte @@ -16,7 +16,7 @@ entity: Entity } - let { entity }: Props = $props() + const { entity }: Props = $props() const { invalidate } = useThrelte() const box = useTrait(() => entity, traits.Box) diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte index 1ff44a4e0..a7d9dabc6 100644 --- a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -24,7 +24,7 @@ entity: Entity } - let { entity }: Props = $props() + const { entity }: Props = $props() const { invalidate } = useThrelte() const linePositions = useTrait(() => entity, traits.LinePositions) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index 5584e16e0..3580d6d93 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -6,9 +6,9 @@ import { OrientationVector } from '$lib/three/OrientationVector' - const quaternion = new Quaternion() - const ov = new OrientationVector() - const euler = new Euler() + const quaternionUtil = new Quaternion() + const ovUtil = new OrientationVector() + const eulerUtil = new Euler() -
parent diff --git a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts index 5630d4baf..9426f5fce 100644 --- a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -12,11 +12,12 @@ import MatrixDetails from '../MatrixDetails.svelte' describe('MatrixDetails', () => { const world = createWorld() + const parentOptions = [{ value: 'world', text: 'world' }] it('always renders parent, world position, and world orientation sections', () => { const entity = world.spawn() render(MatrixDetails, { - props: { entity }, + props: { entity, parentOptions }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByText('parent')).toBeInTheDocument() @@ -31,7 +32,7 @@ describe('MatrixDetails', () => { ) const entity = world.spawn(traits.Matrix(matrix)) render(MatrixDetails, { - props: { entity }, + props: { entity, parentOptions }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByLabelText('mutable local position')).toBeInTheDocument() @@ -41,7 +42,7 @@ describe('MatrixDetails', () => { it('does not render local position/orientation when entity has no matrix or center', () => { const entity = world.spawn() render(MatrixDetails, { - props: { entity }, + props: { entity, parentOptions }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument() From ddd050ad8d0cebbde57c4edba691f7f0d2cabb88 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 14:49:13 -0400 Subject: [PATCH 13/40] add handlers for changes --- .../components/overlay/details/MatrixDetails.svelte | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index 3580d6d93..d87005d60 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -36,9 +36,16 @@ interface Props { entity: Entity parentOptions: Array<{ value: string; text: string }> + onPoseChange?: (patch: Partial) => void + onParentChange?: (parent: string) => void } - const { entity, parentOptions }: Props = $props() + const { + entity, + parentOptions, + onPoseChange = (patch) => traits.writeMatrix(entity, patch), + onParentChange = (next) => hierarchy.setParent(entity, next), + }: Props = $props() const { invalidate } = useThrelte() @@ -72,7 +79,7 @@ }) const applyLocal = (patch: Partial) => { - traits.writeMatrix(entity, patch) + onPoseChange(patch) invalidate() } @@ -92,7 +99,7 @@ if (event.detail.origin !== 'internal') return const value = event.detail.value as string if (value === parent.current) return - hierarchy.setParent(entity, value) + onParentChange(value) invalidate() } From 9ad63d7cc19629f230e8055e448013a753257538 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 15:05:25 -0400 Subject: [PATCH 14/40] use callbacks with no defaults --- .../components/overlay/details/MatrixDetails.svelte | 13 ++++--------- .../details/__tests__/MatrixDetails.svelte.spec.ts | 7 ++++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index d87005d60..6b1fa4214 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -30,22 +30,17 @@ TabPage, } from 'svelte-tweakpane-ui' - import { hierarchy, traits, useParentName, useTrait } from '$lib/ecs' + import { traits, useParentName, useTrait } from '$lib/ecs' import { createPose, matrixToPose } from '$lib/transform' interface Props { entity: Entity parentOptions: Array<{ value: string; text: string }> - onPoseChange?: (patch: Partial) => void - onParentChange?: (parent: string) => void + onPoseChange: (patch: Partial) => void + onParentChange: (parent: string) => void } - const { - entity, - parentOptions, - onPoseChange = (patch) => traits.writeMatrix(entity, patch), - onParentChange = (next) => hierarchy.setParent(entity, next), - }: Props = $props() + const { entity, parentOptions, onPoseChange, onParentChange }: Props = $props() const { invalidate } = useThrelte() diff --git a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts index 9426f5fce..3cd69142d 100644 --- a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -13,11 +13,12 @@ import MatrixDetails from '../MatrixDetails.svelte' describe('MatrixDetails', () => { 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(MatrixDetails, { - props: { entity, parentOptions }, + props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByText('parent')).toBeInTheDocument() @@ -32,7 +33,7 @@ describe('MatrixDetails', () => { ) const entity = world.spawn(traits.Matrix(matrix)) render(MatrixDetails, { - props: { entity, parentOptions }, + props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByLabelText('mutable local position')).toBeInTheDocument() @@ -42,7 +43,7 @@ describe('MatrixDetails', () => { it('does not render local position/orientation when entity has no matrix or center', () => { const entity = world.spawn() render(MatrixDetails, { - props: { entity, parentOptions }, + props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument() From 2450370cee93fbf240c707fe25cae4bdfeed53d8 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 15:39:47 -0400 Subject: [PATCH 15/40] revert opacity behavior --- src/lib/components/overlay/Details.svelte | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 794620474..ed341ddb0 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -259,16 +259,12 @@ detailConfigUpdater.updateGeometry(entity, { type: 'capsule', l: event.detail.value }) } - const opacityValue = $derived(opacity.current ?? 1) + const opacityValue = $derived(opacity.current ?? 0.7) const handleOpacityChange = (event: SliderChangeEvent) => { if (event.detail.origin !== 'internal' || !entity) return const next = event.detail.value - // No trait === fully opaque, so drop the trait when the user returns to 1 - // instead of leaving an Opacity(1) entry on the entity. - if (next >= 1) { - entity.remove(traits.Opacity) - } else if (entity.has(traits.Opacity)) { + if (entity.has(traits.Opacity)) { entity.set(traits.Opacity, next) } else { entity.add(traits.Opacity(next)) From 081f85269252b94655fdd3aa4b96dd7e976119b7 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 15:55:56 -0400 Subject: [PATCH 16/40] split out opacity and axes helper details --- src/lib/components/overlay/Details.svelte | 49 ++----------------- .../overlay/details/AxesHelperDetails.svelte | 32 ++++++++++++ .../overlay/details/OpacityDetails.svelte | 44 +++++++++++++++++ .../AxesHelperDetails.svelte.spec.ts | 31 ++++++++++++ .../__tests__/OpacityDetails.svelte.spec.ts | 32 ++++++++++++ 5 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 src/lib/components/overlay/details/AxesHelperDetails.svelte create mode 100644 src/lib/components/overlay/details/OpacityDetails.svelte create mode 100644 src/lib/components/overlay/details/__tests__/AxesHelperDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/OpacityDetails.svelte.spec.ts diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index ed341ddb0..e2c77dc1c 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -18,7 +18,7 @@ import { draggable } from '@neodrag/svelte' import { isInstanceOf, useThrelte } from '@threlte/core' import { PortalTarget } from '@threlte/extras' - import { Button, Icon, Switch, Tooltip } from '@viamrobotics/prime-core' + import { Button, Icon, Tooltip } from '@viamrobotics/prime-core' import { type Entity } from 'koota' import { Check, Copy } from 'lucide-svelte' import { @@ -38,6 +38,8 @@ } from 'svelte-tweakpane-ui' import AddRelationship from '$lib/components/overlay/AddRelationship.svelte' + import AxesHelperDetails from '$lib/components/overlay/details/AxesHelperDetails.svelte' + import OpacityDetails from '$lib/components/overlay/details/OpacityDetails.svelte' import { hierarchy, relations, traits, useParentName, useTrait, useWorld } from '$lib/ecs' import { FrameConfigUpdater } from '$lib/FrameConfigUpdater.svelte' import { useConfigFrames } from '$lib/hooks/useConfigFrames.svelte' @@ -92,20 +94,11 @@ const removable = useTrait(() => entity, traits.Removable) const points = useTrait(() => entity, traits.Points) const arrows = useTrait(() => entity, traits.Arrows) - const opacity = useTrait(() => entity, traits.Opacity) const framesAPI = useTrait(() => entity, traits.FramesAPI) const geometriesAPI = useTrait(() => entity, traits.GeometriesAPI) - const showAxesHelper = useTrait(() => entity, traits.ShowAxesHelper) const customDetails = useTrait(() => entity, traits.CustomDetails) const hasCustomDetails = $derived(customDetails.current === true) - const handleAxesHelperToggle = (next: boolean) => { - if (!entity) return - if (next) entity.add(traits.ShowAxesHelper) - else entity.remove(traits.ShowAxesHelper) - invalidate() - } - const localPose = $derived.by(() => { const source = editedMatrix.current ?? matrix.current if (source) return matrixToPose(source, createPose()) @@ -259,19 +252,6 @@ detailConfigUpdater.updateGeometry(entity, { type: 'capsule', l: event.detail.value }) } - const opacityValue = $derived(opacity.current ?? 0.7) - - const handleOpacityChange = (event: SliderChangeEvent) => { - if (event.detail.origin !== 'internal' || !entity) return - const next = event.detail.value - if (entity.has(traits.Opacity)) { - entity.set(traits.Opacity, next) - } else { - entity.add(traits.Opacity(next)) - } - invalidate() - } - const handleParentChange = (event: ListChangeEvent) => { if (event.detail.origin !== 'internal' || !entity) return const value = event.detail.value as string @@ -776,27 +756,8 @@ -
- opacity -
- v.toFixed(2)} - on:change={handleOpacityChange} - /> -
-
- -
- show axes helper - handleAxesHelperToggle(event.detail)} - /> -
+ +
{#if linkedEntities.current.length > 0} diff --git a/src/lib/components/overlay/details/AxesHelperDetails.svelte b/src/lib/components/overlay/details/AxesHelperDetails.svelte new file mode 100644 index 000000000..6a87226c8 --- /dev/null +++ b/src/lib/components/overlay/details/AxesHelperDetails.svelte @@ -0,0 +1,32 @@ + + +
+ show axes helper + handleToggle(event.detail)} + /> +
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/__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__/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() + }) +}) From f2b8a195bf0550eaa1e4fd38399a3b6ad39fbbf0 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 17:52:12 -0400 Subject: [PATCH 17/40] line color updates --- src/lib/components/overlay/Details.svelte | 6 ++-- .../details/LineDetails/LineDetails.svelte | 36 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index e2c77dc1c..f6c896aad 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -756,8 +756,10 @@ - - + {#if !hasCustomDetails} + + + {/if}
{#if linkedEntities.current.length > 0} diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte index a7d9dabc6..92825458d 100644 --- a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -29,6 +29,7 @@ const { invalidate } = useThrelte() const linePositions = useTrait(() => entity, traits.LinePositions) const lineWidth = useTrait(() => entity, traits.LineWidth) + const lineColor = useTrait(() => entity, traits.Color) const dotSize = useTrait(() => entity, traits.DotSize) const dotColors = useTrait(() => entity, traits.DotColors) @@ -81,6 +82,13 @@ invalidate() } + const handleLineColorChange = (event: ColorChangeEvent) => { + if (event.detail.origin !== 'internal') return + const next = event.detail.value as ColorValueRgbObject + entity.set(traits.Color, { r: next.r, g: next.g, b: next.b }) + invalidate() + } + const handleDotSizeChange = (event: SliderChangeEvent) => { if (event.detail.origin !== 'internal') return entity.set(traits.DotSize, event.detail.value) @@ -99,11 +107,16 @@ {#if linePositions.current} -
- line positions - (m) -
-
+
+ + line positions + (m, {linePositionList.length}) + +
+
{#each linePositionList as position, index (index)}
@@ -135,7 +148,7 @@ Add position
-
+
line width @@ -148,6 +161,17 @@ />
+ {#if lineColor.current} +
+ line color + +
+ {/if} +
dot size Date: Tue, 2 Jun 2026 17:53:58 -0400 Subject: [PATCH 18/40] default to first hit --- src/lib/hooks/useMouseRaycaster.svelte.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/lib/hooks/useMouseRaycaster.svelte.ts b/src/lib/hooks/useMouseRaycaster.svelte.ts index 8a01991dd..b0f2c2063 100644 --- a/src/lib/hooks/useMouseRaycaster.svelte.ts +++ b/src/lib/hooks/useMouseRaycaster.svelte.ts @@ -16,7 +16,6 @@ type Callback = (event: RaycastEvent) => void interface MouseRaycasterOptions { enabled?: boolean - firstHitOnly?: boolean } export const useMouseRaycaster = (getOptions?: () => MouseRaycasterOptions) => { @@ -24,7 +23,6 @@ export const useMouseRaycaster = (getOptions?: () => MouseRaycasterOptions) => { const options = $derived>({ enabled: true, - firstHitOnly: false, ...getOptions?.(), }) @@ -111,20 +109,13 @@ export const useMouseRaycaster = (getOptions?: () => MouseRaycasterOptions) => { intersections = currentIntersections } - // firstHitOnly is set on the shared raycaster instance, so it applies to - // both onPointerMove (hover) and onPointerUp (click). This is intentional - // for gizmo-style consumers that only care about the closest hit. - // Kept in its own effect so toggling firstHitOnly doesn't tear down and - // re-attach the event listeners. - $effect(() => { - raycaster.firstHitOnly = options.firstHitOnly - }) - $effect(() => { if (!options.enabled) { return } + raycaster.firstHitOnly = true + dom.addEventListener('pointermove', onPointerMove, { passive: true }) dom.addEventListener('pointerdown', onPointerDown, { passive: true }) dom.addEventListener('pointerup', onPointerUp, { passive: true }) From b7b7d7ca09dc98f6f86b0d472770659e9708dab6 Mon Sep 17 00:00:00 2001 From: Devin C Date: Mon, 1 Jun 2026 16:30:35 -0400 Subject: [PATCH 19/40] Refactor Details panel into focused subcomponents The Details overlay component had grown to handle four entirely different entity shapes (color, geometry, line, matrix) inline. Split each into its own component under details/ so callers and reviewers can reason about one entity type at a time. * ColorDetails.svelte, GeometryDetails.svelte, MatrixDetails.svelte and LineDetails/LineDetails.svelte are now individual focused components. * linePositions.ts extracts the line-position derivation with its own spec. * MatrixDetails uses writeMatrix() added in the prior PR. * vitest-setup-client.ts stubs @threlte/extras Portal / PortalTarget / HTML so the refactored panel can render in unit tests. Behavior should be unchanged; existing Details.svelte.spec.ts continues to pass with the new mocks. --- src/lib/components/overlay/Details.svelte | 467 ++++++++++-------- .../overlay/details/ColorDetails.svelte | 32 ++ .../overlay/details/GeometryDetails.svelte | 98 ++++ .../details/LineDetails/LineDetails.svelte | 164 ++++++ .../__tests__/linePositions.spec.ts | 72 +++ .../details/LineDetails/linePositions.ts | 35 ++ .../overlay/details/MatrixDetails.svelte | 198 ++++++++ vitest-setup-client.ts | 19 + 8 files changed, 873 insertions(+), 212 deletions(-) create mode 100644 src/lib/components/overlay/details/ColorDetails.svelte create mode 100644 src/lib/components/overlay/details/GeometryDetails.svelte create mode 100644 src/lib/components/overlay/details/LineDetails/LineDetails.svelte create mode 100644 src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts create mode 100644 src/lib/components/overlay/details/LineDetails/linePositions.ts create mode 100644 src/lib/components/overlay/details/MatrixDetails.svelte diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 4b4dcc1e3..553d2c199 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -13,13 +13,14 @@ + +{#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..54b60921e --- /dev/null +++ b/src/lib/components/overlay/details/GeometryDetails.svelte @@ -0,0 +1,98 @@ + + +{#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..d4a9f23cc --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -0,0 +1,164 @@ + + +{#if linePositions.current} +
+ line positions + (m) +
+
+ {#each linePositionList as position, index (index)} +
+
+ { + if (event.detail.origin !== 'internal') return + writeLinePosition(index, event.detail.value as PointValue3dObject) + }} + /> +
+ +
+ {/each} +
+ +
+
+ +
+ line width + +
+ +
+ dot size + +
+ + {#if dotColorValue} +
+ dot color + +
+ {/if} +{/if} 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..2c225246d --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts @@ -0,0 +1,72 @@ +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]) + }) +}) + +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 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]) + }) +}) 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..d7515ddca --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts @@ -0,0 +1,35 @@ +export const writeLinePosition = ( + source: Float32Array, + index: number, + x: number, + y: number, + z: number +): Float32Array => { + 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 + + 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/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte new file mode 100644 index 000000000..b95558b8c --- /dev/null +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -0,0 +1,198 @@ + + + + +
+
+ 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/vitest-setup-client.ts b/vitest-setup-client.ts index 96cc098a4..98a21df90 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -16,6 +16,25 @@ 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 selection hooks +vi.mock('$lib/hooks/useSelection.svelte', () => ({ + useFocused: vi.fn(() => ({ current: undefined, set: vi.fn() })), + useFocusedEntity: vi.fn(() => ({ current: undefined })), + useFocusedObject3d: vi.fn(() => ({ current: undefined })), + useSelectedEntity: vi.fn(() => ({ current: undefined })), + useSelectedObject3d: vi.fn(() => ({ current: undefined })), +})) + // Mock useFrames hook vi.mock('$lib/hooks/useFrames.svelte', () => ({ useFrames: vi.fn(() => ({ current: [], fetching: false })), From 168cd3e1fe452ed882546bda18893156b2c16ba6 Mon Sep 17 00:00:00 2001 From: Devin C Date: Mon, 1 Jun 2026 17:13:58 -0400 Subject: [PATCH 20/40] Add changeset --- .changeset/details-panel-subcomponents.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/details-panel-subcomponents.md 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 From 473af2edea4aca4bf848671519b823732977682b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:16:41 +0000 Subject: [PATCH 21/40] Address review feedback on Details refactor - linePositions.ts: add bounds check to writeLinePosition; return copy unchanged on out-of-bounds index - Details.svelte: await navigator.clipboard.writeText() and wrap in try/catch to handle permission denial - MatrixDetails.svelte: add comments explaining useQuery vs configFrames and unconditional rendering - linePositions.spec.ts: add edge cases for out-of-bounds write and single-point removal guard - Add component tests for ColorDetails, GeometryDetails, MatrixDetails, and LineDetails Co-authored-by: Devin T. Currie --- src/lib/components/overlay/Details.svelte | 6 ++- .../__tests__/LineDetails.svelte.spec.ts | 33 ++++++++++++ .../__tests__/linePositions.spec.ts | 13 +++++ .../details/LineDetails/linePositions.ts | 1 + .../overlay/details/MatrixDetails.svelte | 6 +++ .../__tests__/ColorDetails.svelte.spec.ts | 31 ++++++++++++ .../__tests__/GeometryDetails.svelte.spec.ts | 49 ++++++++++++++++++ .../__tests__/MatrixDetails.svelte.spec.ts | 50 +++++++++++++++++++ 8 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/ColorDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/GeometryDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 553d2c199..cdbf06bd7 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -450,7 +450,11 @@ class="text-subtle-2" aria-describedby={tooltipID} onclick={async () => { - navigator.clipboard.writeText(getCopyClipboardText()) + try { + await navigator.clipboard.writeText(getCopyClipboardText()) + } catch { + // clipboard unavailable (non-secure context or permission denied) + } copied = true setTimeout(() => (copied = false), 1000) }} 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..2b9a5844c --- /dev/null +++ b/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts @@ -0,0 +1,33 @@ +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 index 2c225246d..bd845e23c 100644 --- a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts +++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts @@ -15,6 +15,13 @@ describe('writeLinePosition', () => { 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', () => { @@ -40,6 +47,12 @@ describe('appendLinePosition', () => { }) 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) diff --git a/src/lib/components/overlay/details/LineDetails/linePositions.ts b/src/lib/components/overlay/details/LineDetails/linePositions.ts index d7515ddca..442316ba4 100644 --- a/src/lib/components/overlay/details/LineDetails/linePositions.ts +++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts @@ -5,6 +5,7 @@ export const writeLinePosition = ( 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 diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index b95558b8c..5584e16e0 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -46,6 +46,9 @@ const worldMatrix = useTrait(() => entity, traits.WorldMatrix) const center = useTrait(() => entity, traits.Center) const parent = useParentName(() => entity) + // Query all named entities in the world (not config-validated frames) so + // gizmo entities can parent to any named entity, including other gizmos. + // This intentionally diverges from Details.svelte's configFrames.getParentFrameOptions(). const namedEntities = useQuery(traits.Name) const parentOptions = $derived.by(() => { @@ -122,6 +125,9 @@ } +
parent 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..5630d4baf --- /dev/null +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -0,0 +1,50 @@ +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 MatrixDetails from '../MatrixDetails.svelte' + +describe('MatrixDetails', () => { + const world = createWorld() + + it('always renders parent, world position, and world orientation sections', () => { + const entity = world.spawn() + render(MatrixDetails, { + props: { entity }, + 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(MatrixDetails, { + props: { entity }, + 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(MatrixDetails, { + props: { entity }, + context: new Map([[WORLD_CONTEXT_KEY, world]]), + }) + expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument() + expect(screen.queryByLabelText('mutable local orientation')).not.toBeInTheDocument() + }) +}) From 873a67b6b1b37d53a40aabaceceefe85d7990d78 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:02:20 +0000 Subject: [PATCH 22/40] Add invalidate() to subcomponents and fix Details handleParentChange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Details.svelte handleParentChange: add invalidate() after setParent() so reparenting non-frame entities triggers a Three.js re-render - ColorDetails, GeometryDetails, LineDetails: add useThrelte() and call invalidate() after every ECS trait mutation that affects rendering - Details.svelte: revert panel width w-80 → w-70 (accidental experiment change) - LineDetails.svelte.spec.ts: fix pre-existing prettier formatting Co-authored-by: Devin T. Currie --- src/lib/components/overlay/Details.svelte | 1 + src/lib/components/overlay/details/ColorDetails.svelte | 3 +++ src/lib/components/overlay/details/GeometryDetails.svelte | 6 ++++++ .../overlay/details/LineDetails/LineDetails.svelte | 8 ++++++++ .../LineDetails/__tests__/LineDetails.svelte.spec.ts | 4 +--- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index cdbf06bd7..5de665bbb 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -271,6 +271,7 @@ const value = event.detail.value as string if (value === parent.current) return hierarchy.setParent(entity, value) + invalidate() // Non-frame entities (gizmos, custom geometries) aren't backed by the // robot config, so skip the config sync. if (isFrameNode) { diff --git a/src/lib/components/overlay/details/ColorDetails.svelte b/src/lib/components/overlay/details/ColorDetails.svelte index 5554ab698..28a5e8c3f 100644 --- a/src/lib/components/overlay/details/ColorDetails.svelte +++ b/src/lib/components/overlay/details/ColorDetails.svelte @@ -1,6 +1,7 @@ diff --git a/src/lib/components/overlay/details/GeometryDetails.svelte b/src/lib/components/overlay/details/GeometryDetails.svelte index 54b60921e..b0db3ce14 100644 --- a/src/lib/components/overlay/details/GeometryDetails.svelte +++ b/src/lib/components/overlay/details/GeometryDetails.svelte @@ -1,6 +1,7 @@ diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte index d4a9f23cc..1ff44a4e0 100644 --- a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -1,6 +1,7 @@ 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 index 2b9a5844c..72a339f47 100644 --- a/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/LineDetails/__tests__/LineDetails.svelte.spec.ts @@ -12,9 +12,7 @@ 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])) - ) + 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]]), From 72a6fced22c9a0c51bd36ff39a1754713406a357 Mon Sep 17 00:00:00 2001 From: "Devin T. Currie" Date: Tue, 2 Jun 2026 12:07:03 -0400 Subject: [PATCH 23/40] Apply suggestion from @claude[bot] Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- vitest-setup-client.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 98a21df90..42e1cd449 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -14,7 +14,14 @@ vi.mock('@threlte/core', () => ({ invalidate: vi.fn(), })), isInstanceOf: vi.fn(() => false), -})) + useThrelte: vi.fn(() => ({ + invalidate: vi.fn(), + scene: { + getObjectByProperty: vi.fn(() => ({ + clone: vi.fn(() => ({ traverse: vi.fn() })), + })), + }, + })), // `@threlte/extras` components (PortalTarget, HTML, etc.) call into Threlte's // internal context which requires a `` parent. Tests render Svelte From a92007cb55dcd3ed6a7413f77234285b80972b8b Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 12:10:43 -0400 Subject: [PATCH 24/40] fix claude's bad suggestion --- vitest-setup-client.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 42e1cd449..500861c49 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -5,6 +5,7 @@ import { vi } from 'vitest' vi.mock('@threlte/core', () => ({ useTask: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), useThrelte: vi.fn(() => ({ + invalidate: vi.fn(), scene: { getObjectByName: vi.fn(() => undefined), getObjectByProperty: vi.fn(() => ({ @@ -14,14 +15,7 @@ vi.mock('@threlte/core', () => ({ invalidate: vi.fn(), })), isInstanceOf: vi.fn(() => false), - useThrelte: vi.fn(() => ({ - invalidate: vi.fn(), - scene: { - getObjectByProperty: vi.fn(() => ({ - clone: vi.fn(() => ({ traverse: vi.fn() })), - })), - }, - })), +})) // `@threlte/extras` components (PortalTarget, HTML, etc.) call into Threlte's // internal context which requires a `` parent. Tests render Svelte From 2d7c9c1be119274104615dea08f713e651cf1c4d Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 12:28:33 -0400 Subject: [PATCH 25/40] cleanup --- .../overlay/details/ColorDetails.svelte | 2 +- .../overlay/details/GeometryDetails.svelte | 2 +- .../details/LineDetails/LineDetails.svelte | 2 +- .../overlay/details/MatrixDetails.svelte | 47 ++++++------------- .../__tests__/MatrixDetails.svelte.spec.ts | 7 +-- 5 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/lib/components/overlay/details/ColorDetails.svelte b/src/lib/components/overlay/details/ColorDetails.svelte index 28a5e8c3f..21b613101 100644 --- a/src/lib/components/overlay/details/ColorDetails.svelte +++ b/src/lib/components/overlay/details/ColorDetails.svelte @@ -10,7 +10,7 @@ entity: Entity } - let { entity }: Props = $props() + const { entity }: Props = $props() const { invalidate } = useThrelte() const color = useTrait(() => entity, traits.Color) diff --git a/src/lib/components/overlay/details/GeometryDetails.svelte b/src/lib/components/overlay/details/GeometryDetails.svelte index b0db3ce14..2fa9ad68d 100644 --- a/src/lib/components/overlay/details/GeometryDetails.svelte +++ b/src/lib/components/overlay/details/GeometryDetails.svelte @@ -16,7 +16,7 @@ entity: Entity } - let { entity }: Props = $props() + const { entity }: Props = $props() const { invalidate } = useThrelte() const box = useTrait(() => entity, traits.Box) diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte index 1ff44a4e0..a7d9dabc6 100644 --- a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -24,7 +24,7 @@ entity: Entity } - let { entity }: Props = $props() + const { entity }: Props = $props() const { invalidate } = useThrelte() const linePositions = useTrait(() => entity, traits.LinePositions) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index 5584e16e0..3580d6d93 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -6,9 +6,9 @@ import { OrientationVector } from '$lib/three/OrientationVector' - const quaternion = new Quaternion() - const ov = new OrientationVector() - const euler = new Euler() + const quaternionUtil = new Quaternion() + const ovUtil = new OrientationVector() + const eulerUtil = new Euler() -
parent diff --git a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts index 5630d4baf..9426f5fce 100644 --- a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -12,11 +12,12 @@ import MatrixDetails from '../MatrixDetails.svelte' describe('MatrixDetails', () => { const world = createWorld() + const parentOptions = [{ value: 'world', text: 'world' }] it('always renders parent, world position, and world orientation sections', () => { const entity = world.spawn() render(MatrixDetails, { - props: { entity }, + props: { entity, parentOptions }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByText('parent')).toBeInTheDocument() @@ -31,7 +32,7 @@ describe('MatrixDetails', () => { ) const entity = world.spawn(traits.Matrix(matrix)) render(MatrixDetails, { - props: { entity }, + props: { entity, parentOptions }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByLabelText('mutable local position')).toBeInTheDocument() @@ -41,7 +42,7 @@ describe('MatrixDetails', () => { it('does not render local position/orientation when entity has no matrix or center', () => { const entity = world.spawn() render(MatrixDetails, { - props: { entity }, + props: { entity, parentOptions }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument() From d5a080edc0d3293b84af2175dc6e46cb3392f876 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 14:49:13 -0400 Subject: [PATCH 26/40] add handlers for changes --- .../components/overlay/details/MatrixDetails.svelte | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index 3580d6d93..d87005d60 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -36,9 +36,16 @@ interface Props { entity: Entity parentOptions: Array<{ value: string; text: string }> + onPoseChange?: (patch: Partial) => void + onParentChange?: (parent: string) => void } - const { entity, parentOptions }: Props = $props() + const { + entity, + parentOptions, + onPoseChange = (patch) => traits.writeMatrix(entity, patch), + onParentChange = (next) => hierarchy.setParent(entity, next), + }: Props = $props() const { invalidate } = useThrelte() @@ -72,7 +79,7 @@ }) const applyLocal = (patch: Partial) => { - traits.writeMatrix(entity, patch) + onPoseChange(patch) invalidate() } @@ -92,7 +99,7 @@ if (event.detail.origin !== 'internal') return const value = event.detail.value as string if (value === parent.current) return - hierarchy.setParent(entity, value) + onParentChange(value) invalidate() } From 17e814f5675f90d38f8e905c5f00c090504f8b16 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 15:05:25 -0400 Subject: [PATCH 27/40] use callbacks with no defaults --- .../components/overlay/details/MatrixDetails.svelte | 13 ++++--------- .../details/__tests__/MatrixDetails.svelte.spec.ts | 7 ++++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/MatrixDetails.svelte index d87005d60..6b1fa4214 100644 --- a/src/lib/components/overlay/details/MatrixDetails.svelte +++ b/src/lib/components/overlay/details/MatrixDetails.svelte @@ -30,22 +30,17 @@ TabPage, } from 'svelte-tweakpane-ui' - import { hierarchy, traits, useParentName, useTrait } from '$lib/ecs' + import { traits, useParentName, useTrait } from '$lib/ecs' import { createPose, matrixToPose } from '$lib/transform' interface Props { entity: Entity parentOptions: Array<{ value: string; text: string }> - onPoseChange?: (patch: Partial) => void - onParentChange?: (parent: string) => void + onPoseChange: (patch: Partial) => void + onParentChange: (parent: string) => void } - const { - entity, - parentOptions, - onPoseChange = (patch) => traits.writeMatrix(entity, patch), - onParentChange = (next) => hierarchy.setParent(entity, next), - }: Props = $props() + const { entity, parentOptions, onPoseChange, onParentChange }: Props = $props() const { invalidate } = useThrelte() diff --git a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts index 9426f5fce..3cd69142d 100644 --- a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -13,11 +13,12 @@ import MatrixDetails from '../MatrixDetails.svelte' describe('MatrixDetails', () => { 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(MatrixDetails, { - props: { entity, parentOptions }, + props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByText('parent')).toBeInTheDocument() @@ -32,7 +33,7 @@ describe('MatrixDetails', () => { ) const entity = world.spawn(traits.Matrix(matrix)) render(MatrixDetails, { - props: { entity, parentOptions }, + props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.getByLabelText('mutable local position')).toBeInTheDocument() @@ -42,7 +43,7 @@ describe('MatrixDetails', () => { it('does not render local position/orientation when entity has no matrix or center', () => { const entity = world.spawn() render(MatrixDetails, { - props: { entity, parentOptions }, + props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) expect(screen.queryByLabelText('mutable local position')).not.toBeInTheDocument() From 76b80efb5f827226ef97b2bce9a42d259393b059 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 15:39:47 -0400 Subject: [PATCH 28/40] revert opacity behavior --- src/lib/components/overlay/Details.svelte | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 5de665bbb..5a891d6b1 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -249,16 +249,12 @@ detailConfigUpdater.updateGeometry(entity, { type: 'capsule', l: event.detail.value }) } - const opacityValue = $derived(opacity.current ?? 1) + const opacityValue = $derived(opacity.current ?? 0.7) const handleOpacityChange = (event: SliderChangeEvent) => { if (event.detail.origin !== 'internal' || !entity) return const next = event.detail.value - // No trait === fully opaque, so drop the trait when the user returns to 1 - // instead of leaving an Opacity(1) entry on the entity. - if (next >= 1) { - entity.remove(traits.Opacity) - } else if (entity.has(traits.Opacity)) { + if (entity.has(traits.Opacity)) { entity.set(traits.Opacity, next) } else { entity.add(traits.Opacity(next)) From 053d6e1f676846681dbdbd74b722a0cacdc95691 Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 15:55:56 -0400 Subject: [PATCH 29/40] split out opacity and axes helper details --- src/lib/components/overlay/Details.svelte | 49 ++----------------- .../overlay/details/AxesHelperDetails.svelte | 32 ++++++++++++ .../overlay/details/OpacityDetails.svelte | 44 +++++++++++++++++ .../AxesHelperDetails.svelte.spec.ts | 31 ++++++++++++ .../__tests__/OpacityDetails.svelte.spec.ts | 32 ++++++++++++ 5 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 src/lib/components/overlay/details/AxesHelperDetails.svelte create mode 100644 src/lib/components/overlay/details/OpacityDetails.svelte create mode 100644 src/lib/components/overlay/details/__tests__/AxesHelperDetails.svelte.spec.ts create mode 100644 src/lib/components/overlay/details/__tests__/OpacityDetails.svelte.spec.ts diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 5a891d6b1..6db228033 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -19,7 +19,7 @@ import { draggable } from '@neodrag/svelte' import { isInstanceOf, useThrelte } from '@threlte/core' import { PortalTarget } from '@threlte/extras' - import { Button, Icon, Switch, Tooltip } from '@viamrobotics/prime-core' + import { Button, Icon, Tooltip } from '@viamrobotics/prime-core' import { type Entity } from 'koota' import { Check, Copy } from 'lucide-svelte' import { @@ -39,6 +39,8 @@ } from 'svelte-tweakpane-ui' import AddRelationship from '$lib/components/overlay/AddRelationship.svelte' + import AxesHelperDetails from '$lib/components/overlay/details/AxesHelperDetails.svelte' + import OpacityDetails from '$lib/components/overlay/details/OpacityDetails.svelte' import { hierarchy, relations, traits, useParentName, useTrait, useWorld } from '$lib/ecs' import { FrameConfigUpdater } from '$lib/FrameConfigUpdater.svelte' import { useConfigFrames } from '$lib/hooks/useConfigFrames.svelte' @@ -83,20 +85,11 @@ const removable = useTrait(() => entity, traits.Removable) const points = useTrait(() => entity, traits.Points) const arrows = useTrait(() => entity, traits.Arrows) - const opacity = useTrait(() => entity, traits.Opacity) const framesAPI = useTrait(() => entity, traits.FramesAPI) const geometriesAPI = useTrait(() => entity, traits.GeometriesAPI) - const showAxesHelper = useTrait(() => entity, traits.ShowAxesHelper) const customDetails = useTrait(() => entity, traits.CustomDetails) const hasCustomDetails = $derived(customDetails.current === true) - const handleAxesHelperToggle = (next: boolean) => { - if (!entity) return - if (next) entity.add(traits.ShowAxesHelper) - else entity.remove(traits.ShowAxesHelper) - invalidate() - } - const localPose = $derived.by(() => { const source = editedMatrix.current ?? matrix.current if (source) return matrixToPose(source, createPose()) @@ -249,19 +242,6 @@ detailConfigUpdater.updateGeometry(entity, { type: 'capsule', l: event.detail.value }) } - const opacityValue = $derived(opacity.current ?? 0.7) - - const handleOpacityChange = (event: SliderChangeEvent) => { - if (event.detail.origin !== 'internal' || !entity) return - const next = event.detail.value - if (entity.has(traits.Opacity)) { - entity.set(traits.Opacity, next) - } else { - entity.add(traits.Opacity(next)) - } - invalidate() - } - const handleParentChange = (event: ListChangeEvent) => { if (event.detail.origin !== 'internal' || !entity) return const value = event.detail.value as string @@ -769,27 +749,8 @@ -
- opacity -
- v.toFixed(2)} - on:change={handleOpacityChange} - /> -
-
- -
- show axes helper - handleAxesHelperToggle(event.detail)} - /> -
+ +
{#if linkedEntities.current.length > 0} diff --git a/src/lib/components/overlay/details/AxesHelperDetails.svelte b/src/lib/components/overlay/details/AxesHelperDetails.svelte new file mode 100644 index 000000000..6a87226c8 --- /dev/null +++ b/src/lib/components/overlay/details/AxesHelperDetails.svelte @@ -0,0 +1,32 @@ + + +
+ show axes helper + handleToggle(event.detail)} + /> +
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/__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__/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() + }) +}) From 51d9b2b88205730aadb837273b02b63b9c20353f Mon Sep 17 00:00:00 2001 From: Devin C Date: Tue, 2 Jun 2026 17:52:12 -0400 Subject: [PATCH 30/40] line color updates --- src/lib/components/overlay/Details.svelte | 6 ++-- .../details/LineDetails/LineDetails.svelte | 36 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 6db228033..208debbe0 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -749,8 +749,10 @@ - - + {#if !hasCustomDetails} + + + {/if}
{#if linkedEntities.current.length > 0} diff --git a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte index a7d9dabc6..92825458d 100644 --- a/src/lib/components/overlay/details/LineDetails/LineDetails.svelte +++ b/src/lib/components/overlay/details/LineDetails/LineDetails.svelte @@ -29,6 +29,7 @@ const { invalidate } = useThrelte() const linePositions = useTrait(() => entity, traits.LinePositions) const lineWidth = useTrait(() => entity, traits.LineWidth) + const lineColor = useTrait(() => entity, traits.Color) const dotSize = useTrait(() => entity, traits.DotSize) const dotColors = useTrait(() => entity, traits.DotColors) @@ -81,6 +82,13 @@ invalidate() } + const handleLineColorChange = (event: ColorChangeEvent) => { + if (event.detail.origin !== 'internal') return + const next = event.detail.value as ColorValueRgbObject + entity.set(traits.Color, { r: next.r, g: next.g, b: next.b }) + invalidate() + } + const handleDotSizeChange = (event: SliderChangeEvent) => { if (event.detail.origin !== 'internal') return entity.set(traits.DotSize, event.detail.value) @@ -99,11 +107,16 @@ {#if linePositions.current} -
- line positions - (m) -
-
+
+ + line positions + (m, {linePositionList.length}) + +
+
{#each linePositionList as position, index (index)}
@@ -135,7 +148,7 @@ Add position
-
+
line width @@ -148,6 +161,17 @@ />
+ {#if lineColor.current} +
+ line color + +
+ {/if} +
dot size Date: Wed, 3 Jun 2026 10:05:18 -0400 Subject: [PATCH 31/40] Apply suggestions from code review Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/lib/components/overlay/details/LineDetails/linePositions.ts | 2 ++ vitest-setup-client.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/overlay/details/LineDetails/linePositions.ts b/src/lib/components/overlay/details/LineDetails/linePositions.ts index 442316ba4..3808d69d9 100644 --- a/src/lib/components/overlay/details/LineDetails/linePositions.ts +++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts @@ -28,6 +28,8 @@ export const appendLinePosition = (source: Float32Array): Float32Array => { export const removeLinePosition = (source: Float32Array, index: number): Float32Array => { if (source.length <= 6) return source + if (index * 3 + 2 >= source.length) return source + if (source.length <= 6) return source const next = new Float32Array(source.length - 3) next.set(source.subarray(0, index * 3), 0) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 500861c49..adb42295e 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -5,7 +5,7 @@ import { vi } from 'vitest' vi.mock('@threlte/core', () => ({ useTask: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), useThrelte: vi.fn(() => ({ - invalidate: vi.fn(), + scene: { scene: { getObjectByName: vi.fn(() => undefined), getObjectByProperty: vi.fn(() => ({ From 299bed5a379cba16062b437d875d4e38257a0aef Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:12:37 +0000 Subject: [PATCH 32/40] fix: correct useThrelte mock structure and remove duplicate guard - vitest-setup-client.ts: `invalidate` was nested inside `scene` in the useThrelte mock instead of at the top level; `const { invalidate } = useThrelte()` returned undefined in all tests. - linePositions.ts: `removeLinePosition` had a duplicate `if (source.length <= 6) return source` guard (dead code after the bounds-check fix in a prior commit). Co-authored-by: Devin T. Currie --- .../components/overlay/details/LineDetails/linePositions.ts | 1 - vitest-setup-client.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/components/overlay/details/LineDetails/linePositions.ts b/src/lib/components/overlay/details/LineDetails/linePositions.ts index 3808d69d9..16d95c044 100644 --- a/src/lib/components/overlay/details/LineDetails/linePositions.ts +++ b/src/lib/components/overlay/details/LineDetails/linePositions.ts @@ -29,7 +29,6 @@ export const appendLinePosition = (source: Float32Array): Float32Array => { export const removeLinePosition = (source: Float32Array, index: number): Float32Array => { if (source.length <= 6) return source if (index * 3 + 2 >= source.length) return source - if (source.length <= 6) return source const next = new Float32Array(source.length - 3) next.set(source.subarray(0, index * 3), 0) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index adb42295e..dcfece08c 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -5,14 +5,13 @@ import { vi } from 'vitest' vi.mock('@threlte/core', () => ({ useTask: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), useThrelte: vi.fn(() => ({ - scene: { + invalidate: vi.fn(), scene: { getObjectByName: vi.fn(() => undefined), getObjectByProperty: vi.fn(() => ({ clone: vi.fn(() => ({ traverse: vi.fn() })), })), }, - invalidate: vi.fn(), })), isInstanceOf: vi.fn(() => false), })) From 2cdca10949bb46ee1d4eee85a470917b593963b3 Mon Sep 17 00:00:00 2001 From: "Devin T. Currie" Date: Wed, 3 Jun 2026 10:17:33 -0400 Subject: [PATCH 33/40] Apply suggestion from @claude[bot] Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../LineDetails/__tests__/linePositions.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts index bd845e23c..794a38d8f 100644 --- a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts +++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts @@ -82,4 +82,15 @@ describe('removeLinePosition', () => { removeLinePosition(source, 1) expect([...source]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) }) + 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) + }) }) From fb7f589a01fd4021ebfbe7c33a9b831dfd2101f5 Mon Sep 17 00:00:00 2001 From: "Devin T. Currie" Date: Wed, 3 Jun 2026 10:39:55 -0400 Subject: [PATCH 34/40] Apply suggestion from @claude[bot] Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- vitest-setup-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index dcfece08c..873ca038b 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -5,7 +5,7 @@ import { vi } from 'vitest' vi.mock('@threlte/core', () => ({ useTask: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), useThrelte: vi.fn(() => ({ - invalidate: vi.fn(), + scene: { scene: { getObjectByName: vi.fn(() => undefined), getObjectByProperty: vi.fn(() => ({ From 7d48588c9014632af7ef16ae1e56909838f2aed1 Mon Sep 17 00:00:00 2001 From: Devin C Date: Wed, 3 Jun 2026 10:40:47 -0400 Subject: [PATCH 35/40] claude is bad at suggesting code edits --- vitest-setup-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 873ca038b..98a21df90 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -5,13 +5,13 @@ import { vi } from 'vitest' vi.mock('@threlte/core', () => ({ useTask: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), useThrelte: vi.fn(() => ({ - scene: { scene: { getObjectByName: vi.fn(() => undefined), getObjectByProperty: vi.fn(() => ({ clone: vi.fn(() => ({ traverse: vi.fn() })), })), }, + invalidate: vi.fn(), })), isInstanceOf: vi.fn(() => false), })) From 5ccbd2bb475a540a81eb221630fd1262c297931f Mon Sep 17 00:00:00 2001 From: Devin C Date: Wed, 3 Jun 2026 10:43:09 -0400 Subject: [PATCH 36/40] remove outdated mock --- vitest-setup-client.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index 98a21df90..76756d462 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -26,15 +26,6 @@ vi.mock('@threlte/extras', () => ({ HTML: vi.fn(), })) -// Mock selection hooks -vi.mock('$lib/hooks/useSelection.svelte', () => ({ - useFocused: vi.fn(() => ({ current: undefined, set: vi.fn() })), - useFocusedEntity: vi.fn(() => ({ current: undefined })), - useFocusedObject3d: vi.fn(() => ({ current: undefined })), - useSelectedEntity: vi.fn(() => ({ current: undefined })), - useSelectedObject3d: vi.fn(() => ({ current: undefined })), -})) - // Mock useFrames hook vi.mock('$lib/hooks/useFrames.svelte', () => ({ useFrames: vi.fn(() => ({ current: [], fetching: false })), From 84b0156ce639ce46ed5cdb81afc4ab07bd7e78d2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:47:19 +0000 Subject: [PATCH 37/40] test: remove duplicate 'keeps original array intact' test case Co-authored-by: Devin T. Currie --- .../details/LineDetails/__tests__/linePositions.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts index 794a38d8f..4b48716e8 100644 --- a/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts +++ b/src/lib/components/overlay/details/LineDetails/__tests__/linePositions.spec.ts @@ -77,11 +77,6 @@ describe('removeLinePosition', () => { 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('keeps original array intact', () => { const source = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9]) removeLinePosition(source, 1) From 8c7540afa7cdd25c2cc2db7aad56dcf197f78d7e Mon Sep 17 00:00:00 2001 From: Devin C Date: Wed, 3 Jun 2026 13:30:48 -0400 Subject: [PATCH 38/40] remove old local matrix code --- src/lib/components/overlay/Details.svelte | 40 +++++------------------ 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 208debbe0..125e81972 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -154,19 +154,6 @@ const detailConfigUpdater = new FrameConfigUpdater(partConfig.updateFrame, partConfig.deleteFrame) - // Mutate the entity's local Matrix in place: read current pose, overwrite - // position or orientation, write back. Used for non-frame entities (gizmos, - // custom static geometries) that don't round-trip through the robot config. - // Matrix4 instances are shared by `useTrait`, so we must call - // `entity.changed(Matrix)` to notify the world-matrix system and any - // other listeners. - const applyLocal = (patch: Partial) => { - if (!entity) return - - traits.writeMatrix(entity, patch) - invalidate() - } - const stopKeyboardPropagation = (event: KeyboardEvent) => { event.stopPropagation() } @@ -174,22 +161,18 @@ const handlePositionChange = (event: PointChangeEvent) => { if (event.detail.origin !== 'internal' || !entity) return const next = event.detail.value as PointValue3dObject - if (isFrameNode) { - detailConfigUpdater.updateLocalPosition(entity, next) - } else { - applyLocal({ x: next.x, y: next.y, z: next.z }) - } + detailConfigUpdater.updateLocalPosition(entity, next) } const handleOrientationOVChange = (event: PointChangeEvent) => { if (event.detail.origin !== 'internal' || !entity) return const next = event.detail.value as PointValue4dObject - const ovValue = { oX: next.x, oY: next.y, oZ: next.z, theta: next.w } - if (isFrameNode) { - detailConfigUpdater.updateLocalOrientation(entity, ovValue) - } else { - applyLocal(ovValue) - } + detailConfigUpdater.updateLocalOrientation(entity, { + oX: next.x, + oY: next.y, + oZ: next.z, + theta: next.w, + }) } const handleOrientationEulerChange = (event: RotationEulerChangeEvent) => { @@ -203,17 +186,12 @@ ) quaternion.setFromEuler(euler) ov.setFromQuaternion(quaternion) - const ovValue = { + detailConfigUpdater.updateLocalOrientation(entity, { oX: ov.x, oY: ov.y, oZ: ov.z, theta: MathUtils.radToDeg(ov.th), - } - if (isFrameNode) { - detailConfigUpdater.updateLocalOrientation(entity, ovValue) - } else { - applyLocal(ovValue) - } + }) } const handleBoxChange = (event: PointChangeEvent) => { From 470ae90b2d07b2f5b43b0abe8a7ddc556e188641 Mon Sep 17 00:00:00 2001 From: Devin C Date: Wed, 3 Jun 2026 14:12:55 -0400 Subject: [PATCH 39/40] cleanup --- src/lib/components/overlay/Details.svelte | 25 +++++++---------------- src/lib/ecs/traits.ts | 2 +- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/lib/components/overlay/Details.svelte b/src/lib/components/overlay/Details.svelte index 125e81972..d48f69a24 100644 --- a/src/lib/components/overlay/Details.svelte +++ b/src/lib/components/overlay/Details.svelte @@ -41,7 +41,7 @@ import AddRelationship from '$lib/components/overlay/AddRelationship.svelte' import AxesHelperDetails from '$lib/components/overlay/details/AxesHelperDetails.svelte' import OpacityDetails from '$lib/components/overlay/details/OpacityDetails.svelte' - import { hierarchy, relations, traits, useParentName, useTrait, useWorld } from '$lib/ecs' + import { hierarchy, relations, traits, useParentName, useTag, useTrait, useWorld } from '$lib/ecs' import { FrameConfigUpdater } from '$lib/FrameConfigUpdater.svelte' import { useConfigFrames } from '$lib/hooks/useConfigFrames.svelte' import { useCameraControls } from '$lib/hooks/useControls.svelte' @@ -61,7 +61,7 @@ const { entity, details, ...rest }: Props = $props() const world = useWorld() - const { scene, invalidate } = useThrelte() + const { scene } = useThrelte() const controls = useCameraControls() const resourceByName = useResourceByName() const configFrames = useConfigFrames() @@ -87,8 +87,7 @@ const arrows = useTrait(() => entity, traits.Arrows) const framesAPI = useTrait(() => entity, traits.FramesAPI) const geometriesAPI = useTrait(() => entity, traits.GeometriesAPI) - const customDetails = useTrait(() => entity, traits.CustomDetails) - const hasCustomDetails = $derived(customDetails.current === true) + const customDetails = useTag(() => entity, traits.CustomDetails) const localPose = $derived.by(() => { const source = editedMatrix.current ?? matrix.current @@ -128,14 +127,9 @@ let geometryTabIndex = $derived(geometryTypes.indexOf(geometryType)) $effect(() => { - if (!entity || !isFrameNode) return - - const next = geometryTypes[geometryTabIndex] - if (next === undefined || next === geometryType) return - // setGeometryType guards against no-ops, so this is safe to fire on every // tab-index change (whether user-initiated or trait-derived). - detailConfigUpdater.setGeometryType(entity, next) + detailConfigUpdater.setGeometryType(entity, geometryTypes[geometryTabIndex]) }) let copied = $state(false) @@ -225,12 +219,7 @@ const value = event.detail.value as string if (value === parent.current) return hierarchy.setParent(entity, value) - invalidate() - // Non-frame entities (gizmos, custom geometries) aren't backed by the - // robot config, so skip the config sync. - if (isFrameNode) { - detailConfigUpdater.setFrameParent(entity, value) - } + detailConfigUpdater.setFrameParent(entity, value) } const getCopyClipboardText = () => { @@ -439,7 +428,7 @@

Details

- {#if !hasCustomDetails} + {#if !customDetails.current}
world position (mm) @@ -727,7 +716,7 @@ - {#if !hasCustomDetails} + {#if !customDetails.current} {/if} 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` From ac26da99f3ec5159aead585b3e548a17bc30e883 Mon Sep 17 00:00:00 2001 From: Devin C Date: Wed, 3 Jun 2026 16:57:20 -0400 Subject: [PATCH 40/40] MatrixDetails -> PoseDetails --- .../{MatrixDetails.svelte => PoseDetails.svelte} | 0 .../details/__tests__/MatrixDetails.svelte.spec.ts | 10 +++++----- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/lib/components/overlay/details/{MatrixDetails.svelte => PoseDetails.svelte} (100%) diff --git a/src/lib/components/overlay/details/MatrixDetails.svelte b/src/lib/components/overlay/details/PoseDetails.svelte similarity index 100% rename from src/lib/components/overlay/details/MatrixDetails.svelte rename to src/lib/components/overlay/details/PoseDetails.svelte diff --git a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts index 3cd69142d..c78f7c9a9 100644 --- a/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts +++ b/src/lib/components/overlay/details/__tests__/MatrixDetails.svelte.spec.ts @@ -8,16 +8,16 @@ import { traits } from '$lib/ecs' import { WORLD_CONTEXT_KEY } from '$lib/ecs/useWorld' import { createPose, poseToMatrix } from '$lib/transform' -import MatrixDetails from '../MatrixDetails.svelte' +import PoseDetails from '../PoseDetails.svelte' -describe('MatrixDetails', () => { +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(MatrixDetails, { + render(PoseDetails, { props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) @@ -32,7 +32,7 @@ describe('MatrixDetails', () => { new Matrix4() ) const entity = world.spawn(traits.Matrix(matrix)) - render(MatrixDetails, { + render(PoseDetails, { props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), }) @@ -42,7 +42,7 @@ describe('MatrixDetails', () => { it('does not render local position/orientation when entity has no matrix or center', () => { const entity = world.spawn() - render(MatrixDetails, { + render(PoseDetails, { props: { entity, parentOptions, onPoseChange: noop, onParentChange: noop }, context: new Map([[WORLD_CONTEXT_KEY, world]]), })