Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/odd-stamps-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@viamrobotics/motion-tools": minor
---

Move frame plugin UX improvements
19 changes: 19 additions & 0 deletions src/lib/components/Entities/hooks/useEntityEvents.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MouseEvent>) =>
entityForEvent(event)?.has(traits.NonSelectable) ?? false

const hoverEntity = (currentEntity: Entity, event: IntersectionEvent<MouseEvent>) => {
const hoverInfo = updateHoverInfo(currentEntity, event)

Expand All @@ -56,6 +65,8 @@ const createEntityEvents = (
}

const onpointerenter = (event: IntersectionEvent<MouseEvent>) => {
if (isNonSelectable(event)) return

event.stopPropagation()
cursor.onPointerEnter()

Expand All @@ -67,6 +78,8 @@ const createEntityEvents = (
}

const onpointermove = (event: IntersectionEvent<MouseEvent>) => {
if (isNonSelectable(event)) return

event.stopPropagation()

const currentEntity = entityForEvent(event)
Expand Down Expand Up @@ -99,6 +112,8 @@ const createEntityEvents = (
}

const onpointerleave = (event: IntersectionEvent<MouseEvent>) => {
if (isNonSelectable(event)) return

event.stopPropagation()
cursor.onPointerLeave()

Expand All @@ -113,10 +128,14 @@ const createEntityEvents = (
}

const onpointerdown = (event: IntersectionEvent<MouseEvent>) => {
if (isNonSelectable(event)) return

down.copy(event.pointer)
}

const onclick = (event: IntersectionEvent<MouseEvent>) => {
if (isNonSelectable(event)) return

event.stopPropagation()

if (down.distanceToSquared(event.pointer) >= 0.1) {
Expand Down
8 changes: 8 additions & 0 deletions src/lib/ecs/traits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
46 changes: 46 additions & 0 deletions src/lib/editing/__tests__/frameHistory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: [],
Expand Down
26 changes: 25 additions & 1 deletion src/lib/hooks/usePartConfig.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading