Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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;
}
27 changes: 27 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte'

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 seed the list on mount (e.g. from app DB fetch). */
plans?: PlanEntry[]
/**
* Optional snippet rendered inside the plan panel's action area.
* Receives `addPlan(name, content)` so callers can inject a DB picker
* without escaping the plugin's context boundary.
*/
extraSource?: Snippet<[(name: string, content: string) => void]>
}

const { plans, extraSource }: Props = $props()

provideMotionPlanReplayer(untrack(() => plans))
</script>

<MotionPlanReplayerUI {extraSource} />
<MotionPlanReplayerScrubber />
176 changes: 176 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerScrubber.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<script lang="ts">
import { Portal } from '@threlte/extras'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Pause,
Play,
X,
} from 'lucide-svelte'

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

const STEP_INTERVAL_MS = 100

const ctx = useMotionPlanReplayer()

let isPlaying = $state(false)

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

const pause = () => {
isPlaying = false
}

const play = () => {
if (ctx.totalSteps <= 0) return
isPlaying = true
}

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 (!isPlaying) return

const intervalId = setInterval(() => {
if (ctx.currentStep >= lastStepIdx) {
pause()
return
}
ctx.setStep(ctx.currentStep + 1)
}, STEP_INTERVAL_MS)

return () => clearInterval(intervalId)
})

$effect(() => {
if (ctx.totalSteps <= 0 && isPlaying) 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 border border-[#666] bg-[#666] px-3 py-2 text-xs text-white"
>
<button
type="button"
class="border-medium flex h-7 w-7 shrink-0 items-center justify-center rounded border text-sm leading-none disabled:opacity-40"
onclick={togglePlay}
disabled={ctx.totalSteps <= 0}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}
>{#if isPlaying}<Pause size={14} />{:else}<Play size={14} />{/if}</button
>

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

<button
type="button"
class="border-medium flex h-7 w-7 shrink-0 items-center justify-center rounded border text-sm leading-none disabled:opacity-40"
onclick={() => stepOnce('prev')}
disabled={ctx.currentStep <= 0}
aria-label="Previous step"
title="Previous step"><ChevronLeft size={14} /></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="border-medium flex h-7 w-7 shrink-0 items-center justify-center rounded border text-sm leading-none disabled:opacity-40"
onclick={() => stepOnce('next')}
disabled={atEnd}
aria-label="Next step"
title="Next step"><ChevronRight size={14} /></button
>

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

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

<button
type="button"
class="border-medium flex h-7 w-7 shrink-0 items-center justify-center rounded border text-sm leading-none"
onclick={ctx.clearActivePlan}
aria-label="Exit replay"
title="Exit replay"><X size={14} /></button
>
</div>
{/if}
</Portal>

<style>
.scrubber {
appearance: none;
-webkit-appearance: none;
height: 4px;
border-radius: 2px;
background: var(--color-gray-7, #52525b);
cursor: pointer;
accent-color: white;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
border: none;
cursor: pointer;
}
.scrubber::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
border: none;
cursor: pointer;
}
</style>
155 changes: 155 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerUI.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<script lang="ts">
import type { Snippet } from 'svelte'

import { Portal } from '@threlte/extras'
import { ToastVariant, useToast } from '@viamrobotics/prime-core'
import { Eye, EyeOff } from 'lucide-svelte'

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

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

interface Props {
extraSource?: Snippet<[(name: string, content: string) => void]>
}

const { extraSource }: Props = $props()

const truncate = (s: string, max = 40): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s)

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

let isOpen = $state(false)
let fileInput: HTMLInputElement | undefined = $state()

const handlePlanFile = async (name: string, content: string) => {
if (ctx.plans.some((p) => p.name === name)) {
toast({ message: `"${truncate(name, 24)}" already loaded.`, variant: ToastVariant.Warning })
return
}

const result = await planDropper({ name, content })

if (!result.success) {
toast({ message: result.error.message, variant: ToastVariant.Danger })
return
}

ctx.addPlan(result.name, result.content, result.snapshots)
isOpen = true
}

const readAndHandle = (file: File) => {
const reader = new FileReader()
reader.addEventListener('load', async (e) => {
const content = e.target?.result
if (typeof content === 'string') await handlePlanFile(file.name, content)
})
reader.addEventListener('error', () => {
toast({
message: `"${truncate(file.name, 24)}" failed to load.`,
variant: ToastVariant.Danger,
})
})
reader.readAsText(file)
}

const onFileChange = (e: Event) => {
const files = (e.currentTarget as HTMLInputElement).files
if (!files) return
for (const file of files) readAndHandle(file)
if (fileInput) fileInput.value = ''
}
</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="text-subtle-1 flex grow items-center justify-center text-center">
Use the button below to upload a plan JSON file
</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-light font-medium' : 'hover:bg-ghost-light',
]}
role="button"
tabindex="0"
onclick={() => (isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))}
onkeydown={(e) =>
e.key === 'Enter' && (isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))}
>
<span class="text-subtle-1 mr-1 shrink-0">
{#if isActive}
<Eye size={14} />
{:else}
<EyeOff size={14} />
{/if}
</span>
<span class="grow truncate">{plan.name}</span>

<button
type="button"
class="text-subtle-1 ml-1 rounded px-1 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 class="mt-auto pt-1">
{#if extraSource}
{@render extraSource(ctx.addPlan)}
{/if}
<input
bind:this={fileInput}
type="file"
accept=".json"
class="hidden"
onchange={onFileChange}
/>
<button
type="button"
class="border-light text-subtle-1 hover:bg-light w-full rounded border px-2 py-1"
onclick={() => fileInput?.click()}
>
Upload plan JSON
</button>
</div>
</div>
</FloatingPanel>
</Portal>
Loading
Loading