Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions src/lib/plugins/LLMSceneBuilder/frameDeltaAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -295,7 +295,7 @@ export function validateProposedFrameDeltas(
)

if (delta.orientation) {
applyEulerDeltaToPose(previousPose, delta.orientation, newPose)
setOrientationFromEuler(previousPose, delta.orientation, newPose)
}

if (
Expand Down
Loading
Loading