diff --git a/.changeset/odd-stamps-tickle.md b/.changeset/odd-stamps-tickle.md new file mode 100644 index 000000000..b71390c43 --- /dev/null +++ b/.changeset/odd-stamps-tickle.md @@ -0,0 +1,5 @@ +--- +"@viamrobotics/motion-tools": minor +--- + +Move frame plugin UX improvements diff --git a/src/lib/components/Entities/hooks/useEntityEvents.svelte.ts b/src/lib/components/Entities/hooks/useEntityEvents.svelte.ts index a2681b382..9ddf58e55 100644 --- a/src/lib/components/Entities/hooks/useEntityEvents.svelte.ts +++ b/src/lib/components/Entities/hooks/useEntityEvents.svelte.ts @@ -33,6 +33,15 @@ const createEntityEvents = ( const world = useWorld() + /** + * A hit on a `NonSelectable` entity is treated as if the object weren't + * there: no cursor change, no hover or selection, and — because the handler + * returns before `stopPropagation` — the next intersection along the ray + * still receives the event, so a ghost never shadows the geometry behind it. + */ + const isNonSelectable = (event: IntersectionEvent) => + entityForEvent(event)?.has(traits.NonSelectable) ?? false + const hoverEntity = (currentEntity: Entity, event: IntersectionEvent) => { const hoverInfo = updateHoverInfo(currentEntity, event) @@ -56,6 +65,8 @@ const createEntityEvents = ( } const onpointerenter = (event: IntersectionEvent) => { + if (isNonSelectable(event)) return + event.stopPropagation() cursor.onPointerEnter() @@ -67,6 +78,8 @@ const createEntityEvents = ( } const onpointermove = (event: IntersectionEvent) => { + if (isNonSelectable(event)) return + event.stopPropagation() const currentEntity = entityForEvent(event) @@ -99,6 +112,8 @@ const createEntityEvents = ( } const onpointerleave = (event: IntersectionEvent) => { + if (isNonSelectable(event)) return + event.stopPropagation() cursor.onPointerLeave() @@ -113,10 +128,14 @@ const createEntityEvents = ( } const onpointerdown = (event: IntersectionEvent) => { + if (isNonSelectable(event)) return + down.copy(event.pointer) } const onclick = (event: IntersectionEvent) => { + if (isNonSelectable(event)) return + event.stopPropagation() if (down.distanceToSquared(event.pointer) >= 0.1) { diff --git a/src/lib/ecs/traits.ts b/src/lib/ecs/traits.ts index 8988d0175..439496211 100644 --- a/src/lib/ecs/traits.ts +++ b/src/lib/ecs/traits.ts @@ -242,6 +242,14 @@ export const ChunkProgress = trait({ loaded: 0, total: 0 }) export type InteractionLayerValue = 'selectTool' export const SelectToolInteractionLayer = trait(() => true) +/** + * Marker for entities that exist to be looked at, not interacted with — move + * ghosts, previews, and other transient display-only geometry. Pointer events + * that land on one are ignored and left to propagate, so whatever sits behind + * it hovers and selects as if the entity weren't there. + */ +export const NonSelectable = trait(() => true) + /** * This entity is selected by the user */ diff --git a/src/lib/editing/__tests__/frameHistory.spec.ts b/src/lib/editing/__tests__/frameHistory.spec.ts index 00383b9f1..da5700503 100644 --- a/src/lib/editing/__tests__/frameHistory.spec.ts +++ b/src/lib/editing/__tests__/frameHistory.spec.ts @@ -6,6 +6,7 @@ vi.mock('$lib/loaders/pcd', () => ({ })) import { hierarchy, traits } from '$lib/ecs' +import { installWorldMatrixListeners } from '$lib/ecs/worldMatrix' import { Pose } from '$lib/math' import { @@ -94,6 +95,51 @@ describe('frame history replay', () => { expect(entity.has(traits.Sphere)).toBe(false) }) + it('holds the edited pose after the baseline is re-derived from the saved config', async () => { + world = createWorld() + const unsub = installWorldMatrixListeners(world) + + // useFrames spawns the frame at its saved pose, then the user drags it. + const entity = world.spawn( + traits.Name('arm'), + traits.FramesAPI, + traits.Matrix(new Pose(100).toMatrix4()), + traits.LiveMatrix(new Pose(100).toMatrix4()), + traits.EditedMatrix(new Pose(500).toMatrix4()) + ) + await Promise.resolve() + expect(entity.get(traits.WorldMatrix)?.elements[12]).toBeCloseTo(0.5) + + // Saving commits the config into the world. + applyFrameHistorySnapshotToWorld( + world, + { + components: [ + { + name: 'arm', + frame: { + parent: 'world', + translation: { x: 500, y: 0, z: 0 }, + orientation: { type: 'ov_degrees', value: { x: 0, y: 0, z: 1, th: 0 } }, + }, + }, + ], + }, + {}, + { keepEditedMatrices: false } + ) + + // Back in monitor mode useFrames re-derives the baseline from the saved + // config. Without the commit above, the blend would cancel the edit + // against a LiveMatrix still holding the pre-save pose. + new Pose(500).toMatrix4(entity.get(traits.Matrix)!) + entity.changed(traits.Matrix) + await Promise.resolve() + + expect(entity.get(traits.WorldMatrix)?.elements[12]).toBeCloseTo(0.5) + unsub() + }) + it('uses the latest fragment frame mod when collecting replay frames', () => { const config: FrameHistoryPartConfig = { components: [], diff --git a/src/lib/hooks/usePartConfig.svelte.ts b/src/lib/hooks/usePartConfig.svelte.ts index bae7ddd40..cd7808c41 100644 --- a/src/lib/hooks/usePartConfig.svelte.ts +++ b/src/lib/hooks/usePartConfig.svelte.ts @@ -3,7 +3,7 @@ import type { JsonValue } from '@viamrobotics/sdk' import { Struct } from '@viamrobotics/sdk' import { createAppMutation, createAppQuery } from '@viamrobotics/svelte-sdk' import { StateHistory } from 'runed' -import { getContext, setContext } from 'svelte' +import { getContext, setContext, untrack } from 'svelte' import { useWorld } from '$lib/ecs' import { @@ -94,6 +94,30 @@ export const providePartConfig = ( } }) + /** + * Once the edits are committed — saved, discarded, or saved by the embedder — + * the config is the new baseline, so fold it into the world: write the config + * poses into `Matrix` / `LiveMatrix` and drop `EditedMatrix`. Without this the + * staged edit outlives the save, and as soon as `useFrames` re-derives the + * baseline from the saved config the blend (live × baseline⁻¹ × edited) + * cancels the edit against a `LiveMatrix` that still holds the pre-save pose — + * the frame snaps back to where it started. Edge-triggered on dirty → clean so + * it never fights `usePoses` while merely monitoring. + */ + let wasDirty = false + $effect(() => { + const settled = wasDirty && !config.isDirty + wasDirty = config.isDirty + + if (!settled) return + + untrack(() => { + applyFrameHistorySnapshotToWorld(world, current, fragmentInfo.current, { + keepEditedMatrices: false, + }) + }) + }) + let historyPartID: string | undefined $effect(() => { const id = partID() diff --git a/src/lib/plugins/LLMSceneBuilder/frameDeltaAdapter.ts b/src/lib/plugins/LLMSceneBuilder/frameDeltaAdapter.ts index 6c42aed1b..ce9c26ea3 100644 --- a/src/lib/plugins/LLMSceneBuilder/frameDeltaAdapter.ts +++ b/src/lib/plugins/LLMSceneBuilder/frameDeltaAdapter.ts @@ -6,7 +6,7 @@ import type { PartConfig } from '$lib/hooks/usePartConfig.svelte' import { frameGeometryFromTransform } from '$lib/geometry' import { Pose } from '$lib/math' -import { applyEulerDeltaToPose } from '$lib/transform' +import { setOrientationFromEuler } from '$lib/transform' /** * Resolves current frames for fragment-defined components from live framesystem @@ -295,7 +295,7 @@ export function validateProposedFrameDeltas( ) if (delta.orientation) { - applyEulerDeltaToPose(previousPose, delta.orientation, newPose) + setOrientationFromEuler(previousPose, delta.orientation, newPose) } if ( diff --git a/src/lib/plugins/MoveFrame/MoveControls.svelte b/src/lib/plugins/MoveFrame/MoveControls.svelte index c07a2b290..e2430ac80 100644 --- a/src/lib/plugins/MoveFrame/MoveControls.svelte +++ b/src/lib/plugins/MoveFrame/MoveControls.svelte @@ -1,25 +1,40 @@ -{#snippet options()} -
-