Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { useEnvironment } from '$lib/hooks/useEnvironment.svelte'

import { provideIKInspection } from './inspect-ik/useIKInspection.svelte'
import MotionPlanReplayerUI from './MotionPlanReplayerUI.svelte'
import { type ResolvePlanSnapshots } from './plan-dropper'
import { type PlanEntry, provideMotionPlanReplayer } from './useMotionPlanReplayer.svelte'
Expand All @@ -20,6 +21,7 @@
const { plans, children, resolvePlanSnapshots }: Props = $props()

provideMotionPlanReplayer(untrack(() => plans))
provideIKInspection()

const environment = useEnvironment()
</script>
Expand Down
31 changes: 28 additions & 3 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerUI.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte'

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

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

import IKInspectionPanel from './inspect-ik/IKInspectionPanel.svelte'
import { useIKInspection } from './inspect-ik/useIKInspection.svelte'
import MotionPlanReplayerScrubber from './MotionPlanReplayerScrubber.svelte'
import { planDropper, type ResolvePlanSnapshots } from './plan-dropper'
import { useMotionPlanReplayer } from './useMotionPlanReplayer.svelte'
Expand All @@ -22,6 +24,7 @@
const truncate = (s: string, max = 40): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s)

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

let isOpen = $state(false)
Expand Down Expand Up @@ -101,14 +104,16 @@
{@const isActive = ctx.activePlanIndex === i}
<div
class={[
'flex cursor-pointer items-center gap-1 rounded px-2 py-1',
'group 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))}
e.target === e.currentTarget &&
e.key === 'Enter' &&
(isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))}
>
<span class="text-subtle-1 mr-1 shrink-0">
{#if isActive}
Expand All @@ -119,6 +124,24 @@
</span>
<span class="grow truncate">{plan.name}</span>

<!-- MOCK: inspectIK ignores this plan and returns a bundled demo pair. -->
<button
type="button"
class="text-subtle-1 hover:bg-ghost-light hover:text-default focus-visible:ring-info-dark ml-1 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1 focus-visible:outline-none aria-disabled:opacity-50"
aria-label={`Inspect IK for ${plan.name}`}
aria-disabled={ik.status === 'loading'}
onclick={(e) => {
e.stopPropagation()
if (ik.status !== 'loading') void ik.inspect(plan.name, plan.content)
}}
>
<Icon
name="bug-outline"
size="sm"
aria-hidden="true"
/>
</button>

<button
type="button"
class="text-subtle-1 ml-1 rounded px-1 hover:text-red-500"
Expand Down Expand Up @@ -161,3 +184,5 @@
</div>
</div>
</FloatingPanel>

<IKInspectionPanel />
102 changes: 102 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/inspect-ik/IKCandidateDetail.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<script lang="ts">
import type { IKCandidate } from './ik-candidates'
import type { PoseKind, PoseSet } from './pose-sets'

import IKStatusDot from './IKStatusDot.svelte'
import { hasSolution, IK_STATUS_LABEL, isScored } from './parse-ik-solutions'

interface Props {
candidate: IKCandidate
poseSets: PoseSet[]
poseVisibility: Record<PoseKind, boolean>
setPoseVisible: (kind: PoseKind, visible: boolean) => void
}

const { candidate, poseSets, poseVisibility, setPoseVisible }: Props = $props()

let expanded = $state(false)

const cost = $derived(candidate.solution.cost)
const detail = $derived(candidate.solution.error ?? candidate.solution.firstError ?? '')
// Roughly the two lines the clamp shows — below it the reveal control is just noise.
const clampable = $derived(detail.length > 120)
const hasStartPose = $derived(poseSets.some((poseSet) => poseSet.kind === 'start'))
const solved = $derived(hasSolution(candidate.solution))
</script>

<div class="border-light flex shrink-0 flex-col gap-2 border-t p-2">
<div class="flex items-center gap-2">
<IKStatusDot
status={candidate.status}
labelled
/>
<span class="min-w-0 truncate">{candidate.seed} · #{candidate.indexInSeed + 1}</span>
<span class="text-subtle-1 ml-auto shrink-0">{IK_STATUS_LABEL[candidate.status]}</span>
<span class="font-roboto-mono shrink-0 tabular-nums">
{#if isScored(cost)}
{cost.toFixed(4)}
{:else}
<span class="text-subtle-2">unscored</span>
{/if}
</span>
</div>

<div class="flex flex-wrap items-center gap-1">
<span class="text-subtle-1 mr-0.5">Show</span>
{#each poseSets as poseSet (poseSet.kind)}
{@const shown = poseVisibility[poseSet.kind]}
<button
type="button"
aria-pressed={shown}
class={[
'flex items-center gap-1 rounded border px-1.5 py-0.5',
'hover:bg-ghost-light focus-visible:ring-info-dark focus-visible:ring-1 focus-visible:outline-none',
shown ? 'border-medium bg-light' : 'border-light text-subtle-2',
]}
onclick={() => setPoseVisible(poseSet.kind, !shown)}
>
<span
class={[
'size-2 shrink-0 rounded-full',
poseSet.style.swatchClass,
// Hollow rather than hidden, so the row keeps its shape and the colour stays legible.
!shown && 'opacity-40',
]}
aria-hidden="true"
></span>
{poseSet.label}
</button>
{/each}
</div>

{#if !hasStartPose}
<p class="text-subtle-2">
Start pose unavailable — the request carries no start configuration.
</p>
{/if}

{#if detail}
<div class="border-light bg-extralight rounded border p-1.5">
<p class={['font-roboto-mono text-[11px] break-words', !expanded && 'line-clamp-2']}>
{detail}
</p>
{#if clampable}
<button
type="button"
class="text-link hover:text-link focus-visible:ring-info-dark mt-1 rounded focus-visible:ring-1 focus-visible:outline-none"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
</div>
{:else if !solved}
<p class="text-subtle-1">
The solver returned no configuration for this seed, so there is no pose to draw — it failed
before producing a candidate rather than producing one that collides.
</p>
{:else}
<p class="text-subtle-2">No failure detail was recorded for this candidate.</p>
{/if}
</div>
133 changes: 133 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/inspect-ik/IKCandidateList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<script lang="ts">
import { Icon, Tooltip } from '@viamrobotics/prime-core'

import IKCandidateDetail from './IKCandidateDetail.svelte'
import IKCandidateRow from './IKCandidateRow.svelte'
import IKSeedGroup from './IKSeedGroup.svelte'
import IKStatusDot from './IKStatusDot.svelte'
import { IK_STATUS_DESCRIPTION, IK_STATUS_LABEL, type IKStatus } from './parse-ik-solutions'
import { useIKInspection } from './useIKInspection.svelte'

const ctx = useIKInspection()

const ORDER: IKStatus[] = ['valid', 'path-invalid', 'invalid']
</script>

<div class="flex h-full flex-col">
<div class="border-light flex shrink-0 flex-col gap-2 border-b p-2">
<div class="flex items-center gap-2">
<span class="font-medium tabular-nums">{ctx.totalCount} candidates</span>
<span class="text-subtle-1 ml-auto tabular-nums">{ctx.seedBuckets.length} seeds</span>
</div>

<div class="flex flex-wrap items-center gap-1">
{#each ORDER as status (status)}
{@const active = ctx.statusFilter.has(status)}
<button
type="button"
aria-pressed={active}
class={[
'flex items-center gap-1 rounded border px-1.5 py-0.5 tabular-nums',
'hover:bg-ghost-light focus-visible:ring-info-dark focus-visible:ring-1 focus-visible:outline-none',
active ? 'border-medium bg-light' : 'border-light text-subtle-2',
]}
onclick={() => ctx.toggleStatusFilter(status)}
>
<IKStatusDot {status} />
{ctx.counts[status]}
<span class="sr-only">{IK_STATUS_LABEL[status]}</span>
</button>
{/each}

<Tooltip
let:tooltipID
location="bottom"
>
<button
type="button"
aria-describedby={tooltipID}
aria-label="What the colours mean"
class="text-subtle-1 hover:text-default hover:bg-ghost-light focus-visible:ring-info-dark rounded p-0.5 focus-visible:ring-1 focus-visible:outline-none"
>
<Icon
name="help-circle-outline"
size="sm"
aria-hidden="true"
/>
</button>
<span slot="description">
{#each ORDER as status (status)}
<span class="mb-0.5 block last:mb-0">
<span class="font-medium">{IK_STATUS_LABEL[status]}</span> — {IK_STATUS_DESCRIPTION[
status
]}
</span>
{/each}
</span>
</Tooltip>

<div class="ml-auto flex items-center gap-1">
<span class="text-subtle-1">Sort</span>
{#each [['seed', 'Seed'], ['cost', 'Cost']] as const as [mode, label] (mode)}
<button
type="button"
aria-pressed={ctx.sortMode === mode}
class={[
'rounded border px-1.5 py-0.5',
'hover:bg-ghost-light focus-visible:ring-info-dark focus-visible:ring-1 focus-visible:outline-none',
ctx.sortMode === mode ? 'border-medium bg-light' : 'border-light text-subtle-2',
]}
onclick={() => ctx.setSortMode(mode)}
>
{label}
</button>
{/each}
</div>
</div>
</div>

<div class="min-h-0 flex-1 overflow-y-auto p-1">
{#if ctx.visibleCandidates.length === 0}
<div class="text-subtle-1 flex h-full flex-col items-center justify-center gap-2 text-center">
<p>No candidates match this filter.</p>
<button
type="button"
class="border-light hover:bg-light focus-visible:ring-info-dark rounded border px-2 py-1 focus-visible:ring-1 focus-visible:outline-none"
onclick={() => ctx.resetStatusFilter()}
>
Show all
</button>
</div>
{:else if ctx.sortMode === 'seed'}
{#each ctx.seedBuckets as bucket (bucket.seedIndex)}
<IKSeedGroup
{bucket}
expanded={ctx.expandedSeeds.has(bucket.seedIndex)}
selectedId={ctx.selectedCandidate?.id ?? null}
ontoggle={() => ctx.toggleSeed(bucket.seedIndex)}
onselect={(id) => ctx.select(id)}
/>
{/each}
{:else}
<div class="flex flex-col gap-0.5">
{#each ctx.visibleCandidates as candidate (candidate.id)}
<IKCandidateRow
{candidate}
selected={candidate.id === ctx.selectedCandidate?.id}
showSeed
onselect={() => ctx.select(candidate.id)}
/>
{/each}
</div>
{/if}
</div>

{#if ctx.selectedCandidate}
<IKCandidateDetail
candidate={ctx.selectedCandidate}
poseSets={ctx.poseSets}
poseVisibility={ctx.poseVisibility}
setPoseVisible={ctx.setPoseVisible}
/>
{/if}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { Tooltip } from '@viamrobotics/prime-core'

import type { IKCandidate } from './ik-candidates'

import IKStatusDot from './IKStatusDot.svelte'
import { isScored } from './parse-ik-solutions'

interface Props {
candidate: IKCandidate
selected: boolean
/** Cost-sorted mode has no seed headers, so the row has to carry its own. */
showSeed?: boolean
onselect: () => void
}

const { candidate, selected, showSeed = false, onselect }: Props = $props()

const cost = $derived(candidate.solution.cost)
</script>

<Tooltip
let:tooltipID
location="right"
>
<button
type="button"
aria-describedby={tooltipID}
aria-pressed={selected}
class={[
'flex w-full items-center gap-2 rounded px-2 py-1 text-left',
'hover:bg-ghost-light focus-visible:ring-info-dark focus-visible:ring-1 focus-visible:outline-none',
selected && 'bg-light font-medium',
]}
onclick={onselect}
>
<IKStatusDot
status={candidate.status}
labelled
/>
<span class="font-roboto-mono grow tabular-nums">
{#if isScored(cost)}
{cost.toFixed(4)}
{:else}
<span class="text-subtle-2">—</span>
{/if}
</span>
{#if showSeed}
<span class="text-subtle-2 min-w-0 truncate">{candidate.seed}</span>
{/if}
</button>
<p slot="description">
{candidate.seed} · #{candidate.indexInSeed + 1}{isScored(cost) ? '' : ' · unscored'}
</p>
</Tooltip>
Loading
Loading