-
Notifications
You must be signed in to change notification settings - Fork 2
SCOPE: Motion plan replay plugin #782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
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 3a81882
Move tests
sucrammal ea40eae
Allow file dropping of motion plan JSON
sucrammal d8782fa
Add in motion plan plugin UI elements + reorg
sucrammal caa9ccd
use in +layout
sucrammal 4334197
Fix file drop; scrubber appears when plan selected
sucrammal cb7336f
Motion plans running, first go
sucrammal b7e96a8
Arm geometries fixed.
sucrammal 98e83ee
Model frame parented objects fixed
sucrammal 2c26251
Merge branch 'main' into APP-9316-motion-plan-replay
sucrammal b941f27
Motion replay entity opacity
sucrammal 6ad0e89
Better error state
sucrammal db5ab73
Cleanup logs and code
sucrammal 6ee653c
Interpolation per Dan's advice
sucrammal 1304ee1
Refactor and code cleanup.
sucrammal 9445896
Update plan-to-snapshots.spec.ts
sucrammal 7930ad6
Fix file drop
sucrammal 90625d7
Use prime / lucide for style consistency
sucrammal 36ffdf1
Revert "Interpolation per Dan's advice"
sucrammal 21d810f
Revert "Motion replay entity opacity"
sucrammal 111fafe
Consolidate scrubber interaction with timer
sucrammal d3be9d2
Parse plans with zod, simplify caller.
sucrammal f3f70cb
Design clear hook for app-embedded plan upload
sucrammal 58cd153
Refactor: Joints as ECS entities
sucrammal e87fe24
Readd support for frames parented to model frames
sucrammal c08a58c
Euler angle support; Fix geometry translation
sucrammal abe76c0
support OV case; restore primary_output_frame reparenting
sucrammal 8fdb6b9
Merge branch 'main' into APP-9316-motion-plan-replay
sucrammal 5631b12
Add a Plan relation for cleaner teardown
sucrammal cc87cee
Revert useFileDrop
sucrammal fa999e5
Easy UI/UX hits from Jason
sucrammal 31f4afe
Merge branch 'main' into APP-9316-motion-plan-replay
sucrammal e13263d
Lint
sucrammal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
27 changes: 27 additions & 0 deletions
27
src/lib/plugins/MotionPlanReplayer/MotionPlanReplayer.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
176
src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerScrubber.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
155
src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerUI.svelte
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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