Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5920b5c
Scaffold plan JSON → Snapshot[] data pipeline
sucrammal Jun 16, 2026
3a81882
Move tests
sucrammal Jun 16, 2026
ea40eae
Allow file dropping of motion plan JSON
sucrammal Jun 16, 2026
d8782fa
Add in motion plan plugin UI elements + reorg
sucrammal Jun 16, 2026
caa9ccd
use in +layout
sucrammal Jun 16, 2026
4334197
Fix file drop; scrubber appears when plan selected
sucrammal Jun 17, 2026
cb7336f
Motion plans running, first go
sucrammal Jun 17, 2026
b7e96a8
Arm geometries fixed.
sucrammal Jun 17, 2026
98e83ee
Model frame parented objects fixed
sucrammal Jun 17, 2026
2c26251
Merge branch 'main' into APP-9316-motion-plan-replay
sucrammal Jun 18, 2026
b941f27
Motion replay entity opacity
sucrammal Jun 18, 2026
6ad0e89
Better error state
sucrammal Jun 18, 2026
db5ab73
Cleanup logs and code
sucrammal Jun 18, 2026
6ee653c
Interpolation per Dan's advice
sucrammal Jun 18, 2026
1304ee1
Refactor and code cleanup.
sucrammal Jun 18, 2026
9445896
Update plan-to-snapshots.spec.ts
sucrammal Jun 18, 2026
7930ad6
Fix file drop
sucrammal Jun 22, 2026
90625d7
Use prime / lucide for style consistency
sucrammal Jun 22, 2026
36ffdf1
Revert "Interpolation per Dan's advice"
sucrammal Jun 22, 2026
21d810f
Revert "Motion replay entity opacity"
sucrammal Jun 22, 2026
111fafe
Consolidate scrubber interaction with timer
sucrammal Jun 22, 2026
d3be9d2
Parse plans with zod, simplify caller.
sucrammal Jun 22, 2026
f3f70cb
Design clear hook for app-embedded plan upload
sucrammal Jun 22, 2026
58cd153
Refactor: Joints as ECS entities
sucrammal Jun 30, 2026
e87fe24
Readd support for frames parented to model frames
sucrammal Jun 30, 2026
c08a58c
Euler angle support; Fix geometry translation
sucrammal Jul 1, 2026
abe76c0
support OV case; restore primary_output_frame reparenting
sucrammal Jul 1, 2026
8fdb6b9
Merge branch 'main' into APP-9316-motion-plan-replay
sucrammal Jul 1, 2026
5631b12
Add a Plan relation for cleaner teardown
sucrammal Jul 1, 2026
cc87cee
Revert useFileDrop
sucrammal Jul 1, 2026
fa999e5
Easy UI/UX hits from Jason
sucrammal Jul 6, 2026
31f4afe
Merge branch 'main' into APP-9316-motion-plan-replay
sucrammal Jul 6, 2026
e13263d
Lint
sucrammal Jul 6, 2026
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 src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,8 @@ select {
.motion-tools-table tbody th {
@apply border-light font-roboto-mono text-default h-[40px] gap-2 border px-1.5 text-center text-xs font-normal;
}

/* Push toasts above the motion plan scrubber bar (fixed bottom-4, ~44px tall). */
body.has-scrubber [aria-label='Toasts'] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: for final impl we probably should look into how to do this with component placements instead of the app css

padding-bottom: 5rem;
}
8 changes: 7 additions & 1 deletion src/lib/components/FileDrop/FileDrop.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { useRelationships } from '$lib/hooks/useRelationships.svelte'
import { spawnSnapshotEntities } from '$lib/snapshot'

import type { FileDropperSuccess } from './file-dropper'
import type { FileDropperSuccess, PlanFileDropSuccess } from './file-dropper'

import { useFileDrop } from './useFileDrop.svelte'

Expand Down Expand Up @@ -71,6 +71,12 @@
)
break
}
case 'plan': {
window.dispatchEvent(
Comment thread
sucrammal marked this conversation as resolved.
Outdated
new CustomEvent<PlanFileDropSuccess>('viam:plan-loaded', { detail: result })
)
return
}
}

toast({ message: `${result.name} loaded.`, variant: ToastVariant.Success })
Expand Down
9 changes: 9 additions & 0 deletions src/lib/components/FileDrop/file-dropper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface PlyFileDropSuccess extends FileDropSuccess {
ply: BufferGeometry
}

export interface PlanFileDropSuccess extends FileDropSuccess {
type: 'plan'
content: string
snapshots: Snapshot[]
name: string
stepCount: number
}

export class FileDropperError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options)
Expand All @@ -34,6 +42,7 @@ export type FileDropperSuccess =
| SnapshotFileDropSuccess
| PointcloudFileDropSuccess
| PlyFileDropSuccess
| PlanFileDropSuccess

export interface FileDropperFailure {
success: false
Expand Down
5 changes: 5 additions & 0 deletions src/lib/components/FileDrop/useFileDrop.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { planDropper } from '$lib/plugins/MotionPlanReplayer/plan-dropper'

import type { FileDropperSuccess } from './file-dropper'

import { Extensions, parseFileName, Prefixes, readFile } from './file-names'
Expand Down Expand Up @@ -25,6 +27,9 @@ const createFileDropper = (extension: string, prefix: string | undefined) => {
case Extensions.PLY: {
return plyDropper
}
case Extensions.JSON: {
Comment thread
sucrammal marked this conversation as resolved.
Outdated
return planDropper
}
}

return undefined
Expand Down
23 changes: 23 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts">
import { untrack } from 'svelte'

import MotionPlanReplayerScrubber from './MotionPlanReplayerScrubber.svelte'
import MotionPlanReplayerUI from './MotionPlanReplayerUI.svelte'
import { type PlanEntry, provideMotionPlanReplayer } from './useMotionPlanReplayer.svelte'

interface Props {
/** Pass plans to use app-embedded mode (props-driven, no drag-and-drop UI). Omit for standalone mode. */
plans?: PlanEntry[]
}

const { plans }: Props = $props()

// `plans` is intentionally read once at setup time — initial entries only.
// The context manages its own reactive plan list after that.
provideMotionPlanReplayer(untrack(() => plans))
</script>

{#if plans === undefined}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be cool if drag and drop just updated the plans array and then we could use the ui for both cases

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Designed a more explicit hook here (f3f70cb) which calls the same addPlan hook from app vs. standalone.

<MotionPlanReplayerUI />
{/if}
<MotionPlanReplayerScrubber />
166 changes: 166 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerScrubber.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<script lang="ts">
import { Portal } from '@threlte/extras'

import { useMotionPlanReplayer } from './useMotionPlanReplayer.svelte'

const STEP_INTERVAL_MS = 100

const ctx = useMotionPlanReplayer()

let isPlaying = $state(false)
let intervalId: ReturnType<typeof setInterval> | undefined

const lastStepIdx = $derived(Math.max(0, ctx.totalSteps - 1))
const atEnd = $derived(ctx.currentStep >= lastStepIdx)

const pause = () => {
isPlaying = false
if (intervalId !== undefined) {
clearInterval(intervalId)
intervalId = undefined
}
}

const play = () => {
if (isPlaying || ctx.totalSteps <= 0) return
isPlaying = true
intervalId = setInterval(() => {
Comment thread
sucrammal marked this conversation as resolved.
Outdated
if (ctx.currentStep >= lastStepIdx) {
pause()
return
}
ctx.setStep(ctx.currentStep + 1)
}, STEP_INTERVAL_MS)
}

const togglePlay = () => {
if (isPlaying) {
pause()
return
}
if (atEnd && ctx.totalSteps > 0) ctx.setStep(0)
play()
}

const seek = (index: number) => {
pause()
ctx.setStep(index)
}

const stepOnce = (direction: 'prev' | 'next') => {
pause()
ctx.setStep(direction === 'next' ? ctx.currentStep + 1 : ctx.currentStep - 1)
}

$effect(() => {
if (ctx.totalSteps <= 0 && isPlaying) pause()
})
$effect(() => () => pause())
$effect(() => {
document.body.classList.toggle('has-scrubber', ctx.totalSteps > 0)
return () => document.body.classList.remove('has-scrubber')
})
</script>

<Portal id="dom">
{#if ctx.totalSteps > 0}
<div
class="pointer-events-auto fixed bottom-4 left-1/2 z-[10000] flex w-[min(640px,calc(100vw-2rem))] -translate-x-1/2 items-center gap-3 rounded bg-zinc-900/85 px-3 py-2 text-xs text-white"
>
<!-- Exit replay -->
<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none"
onclick={ctx.clearActivePlan}
aria-label="Exit replay"
title="Exit replay">×</button
>

<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40"
onclick={togglePlay}
disabled={ctx.totalSteps <= 0}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}>{isPlaying ? '⏸' : '▶'}</button
>

<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40"
onclick={() => seek(0)}
disabled={ctx.currentStep <= 0}
aria-label="Jump to start"
title="Jump to start">«</button
>

<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40"
onclick={() => stepOnce('prev')}
disabled={ctx.currentStep <= 0}
aria-label="Previous step"
title="Previous step">‹</button
>

<input
class="scrubber grow"
type="range"
min="0"
max={lastStepIdx}
value={ctx.currentStep}
oninput={(e) => seek(Number((e.currentTarget as HTMLInputElement).value))}
/>

<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40"
onclick={() => stepOnce('next')}
disabled={atEnd}
aria-label="Next step"
title="Next step">›</button
>

<button
type="button"
class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40"
onclick={() => seek(lastStepIdx)}
disabled={atEnd}
aria-label="Jump to end"
title="Jump to end">»</button
>

<span class="whitespace-nowrap tabular-nums">{ctx.currentStep + 1} / {ctx.totalSteps}</span>
</div>
{/if}
</Portal>

<style>
.scrubber {
appearance: none;
-webkit-appearance: none;
height: 4px;
border-radius: 2px;
background: #52525b;
cursor: pointer;
accent-color: #ffffff;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
border: none;
cursor: pointer;
}
.scrubber::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
border: none;
cursor: pointer;
}
</style>
94 changes: 94 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerUI.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts">
import { Portal } from '@threlte/extras'
import { ToastVariant, useToast } from '@viamrobotics/prime-core'

import type { PlanFileDropSuccess } from '$lib/components/FileDrop/file-dropper'

import DashboardButton from '$lib/components/overlay/dashboard/Button.svelte'
import FloatingPanel from '$lib/components/overlay/FloatingPanel.svelte'

import { truncate } from './plan-dropper'
import { useMotionPlanReplayer } from './useMotionPlanReplayer.svelte'

const ctx = useMotionPlanReplayer()
const toast = useToast()

let isOpen = $state(false)

$effect(() => {
const handle = (e: Event) => {
const { name, content, snapshots } = (e as CustomEvent<PlanFileDropSuccess>).detail
if (ctx.plans.some((p) => p.name === name)) {
toast({ message: `"${truncate(name, 24)}" already loaded.`, variant: ToastVariant.Warning })
return
}
ctx.addPlan(name, content, snapshots)
isOpen = true
}
window.addEventListener('viam:plan-loaded', handle)
Comment thread
sucrammal marked this conversation as resolved.
Outdated
return () => window.removeEventListener('viam:plan-loaded', handle)
})
</script>

<Portal id="dashboard">
<fieldset>
<DashboardButton
active={isOpen}
icon="play-circle-outline"
description="Motion Plan Replayer"
onclick={() => (isOpen = !isOpen)}
/>
</fieldset>
</Portal>

<Portal id="dom">
<FloatingPanel
bind:isOpen
title="Motion Plan Replayer"
defaultSize={{ width: 320, height: 260 }}
>
<div class="flex h-full flex-col gap-1 p-2 text-xs">
{#if ctx.plans.length === 0}
<div class="flex grow items-center justify-center text-center text-gray-400">
Drop a plan JSON file onto the canvas
</div>
{/if}

{#each ctx.plans as plan, i (plan.name)}
{@const isActive = ctx.activePlanIndex === i}
<div
class={[
'flex cursor-pointer items-center gap-1 rounded px-2 py-1',
isActive ? 'bg-blue-100 font-medium' : 'hover:bg-gray-100',
]}
role="button"
tabindex="0"
onclick={() => (isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))}
onkeydown={(e) =>
e.key === 'Enter' && (isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))}
>
<span class="mr-1 text-gray-400">{isActive ? '●' : '○'}</span>
<span class="grow truncate">{plan.name}</span>

<button
type="button"
class="ml-1 rounded px-1 text-gray-400 hover:text-red-500"
onclick={(e) => {
e.stopPropagation()
ctx.removePlan(i)
}}
aria-label="Remove plan"
title="Remove plan">×</button
>
</div>

{#if plan.status === 'error'}
<div class="pl-5 text-[10px] text-red-600">{plan.error}</div>
{/if}
{#if plan.status === 'no-trajectory'}
<div class="pl-5 text-[10px] text-yellow-600">No trajectory — nothing to replay</div>
{/if}
{/each}
</div>
</FloatingPanel>
</Portal>
Loading
Loading