Spaces:
Running
Running
| <script lang="ts"> | |
| import * as Sheet from "@/components/ui/sheet"; | |
| import { Button } from "@/components/ui/button"; | |
| import * as Alert from "@/components/ui/alert"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { toast } from "svelte-sonner"; | |
| import type { Robot } from "$lib/robot/Robot.svelte"; | |
| import { Separator } from "@/components/ui/separator"; | |
| interface Props { | |
| open: boolean; | |
| robot: Robot | null; | |
| } | |
| let { open = $bindable(), robot }: Props = $props(); | |
| async function calibrateRobot() { | |
| if (!robot) return; | |
| try { | |
| await robot.calibrateRobot(); | |
| } catch (err) { | |
| toast.error("Calibration Failed", { | |
| description: `Failed to calibrate: ${err}` | |
| }); | |
| console.error(err); | |
| } | |
| } | |
| async function moveToRest() { | |
| if (!robot) return; | |
| try { | |
| await robot.moveToRestPosition(); | |
| } catch (err) { | |
| toast.error("Movement Failed", { | |
| description: `Failed to move to rest: ${err}` | |
| }); | |
| console.error(err); | |
| } | |
| } | |
| function clearCalibration() { | |
| if (!robot) return; | |
| robot.clearCalibration(); | |
| } | |
| </script> | |
| <Sheet.Root bind:open> | |
| <Sheet.Content | |
| trapFocus={false} | |
| side="right" | |
| class="w-80 gap-0 border-l border-slate-600 bg-gradient-to-b from-slate-700 to-slate-800 p-0 text-white sm:w-96" | |
| > | |
| <!-- Header --> | |
| <Sheet.Header class="border-b border-slate-600 bg-slate-700/80 p-6 backdrop-blur-sm"> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <span class="icon-[mdi--tune] size-6 text-purple-400"></span> | |
| <div> | |
| <Sheet.Title class="text-xl font-semibold text-slate-100">Manual Control</Sheet.Title> | |
| <p class="mt-1 text-sm text-slate-400">Direct robot joint manipulation</p> | |
| </div> | |
| </div> | |
| </div> | |
| </Sheet.Header> | |
| {#if robot} | |
| <!-- Content --> | |
| <div | |
| class="scrollbar-thin scrollbar-track-slate-700 scrollbar-thumb-slate-500 flex-1 overflow-y-auto px-4" | |
| > | |
| <div class="space-y-6 py-4"> | |
| <!-- Calibration Section (for USB slaves) --> | |
| {#if robot.connectedSlaves.some((slave) => slave.name.includes("USB"))} | |
| <div class="space-y-4"> | |
| <div class="mb-3 flex items-center gap-3"> | |
| <span class="icon-[mdi--crosshairs-gps] size-5 text-purple-400"></span> | |
| <h3 class="text-lg font-medium text-slate-100">USB Robot Calibration</h3> | |
| {#if robot.isCalibrated} | |
| <Badge variant="default" class="ml-auto bg-green-600 text-xs"> | |
| <span class="icon-[mdi--check] mr-1 size-3"></span> | |
| OK | |
| </Badge> | |
| {:else} | |
| <Badge variant="destructive" class="ml-auto text-xs"> | |
| <span class="icon-[mdi--alert] mr-1 size-3"></span> | |
| Required | |
| </Badge> | |
| {/if} | |
| </div> | |
| {#if !robot.isCalibrated} | |
| <div class="space-y-4"> | |
| <div class="space-y-1 text-xs text-purple-300/70"> | |
| <p>1. Position robot to match rest pose</p> | |
| <p>2. Click Calibrate when ready</p> | |
| </div> | |
| <div class="flex gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onclick={moveToRest} | |
| class="h-8 flex-1 border-slate-600 text-xs text-slate-200 hover:bg-slate-700" | |
| > | |
| <span class="icon-[mdi--human-handsup] mr-1 size-3"></span> | |
| Rest Pose | |
| </Button> | |
| <Button | |
| variant="default" | |
| size="sm" | |
| onclick={calibrateRobot} | |
| class="h-8 flex-1 bg-green-600 text-xs hover:bg-green-700" | |
| > | |
| <span class="icon-[mdi--crosshairs-gps] mr-1 size-3"></span> | |
| Calibrate | |
| </Button> | |
| </div> | |
| </div> | |
| {:else} | |
| <div class="space-y-3"> | |
| <div class="flex items-center gap-2 text-xs text-green-300"> | |
| <span class="icon-[mdi--check-circle] size-3"></span> | |
| Calibrated at {robot.calibrationState.calibrationTime?.toLocaleTimeString()} | |
| </div> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onclick={clearCalibration} | |
| class="h-8 w-full border-slate-600 text-xs text-slate-200 hover:bg-slate-700" | |
| > | |
| <span class="icon-[mdi--refresh] mr-1 size-3"></span> | |
| Clear Calibration | |
| </Button> | |
| </div> | |
| {/if} | |
| </div> | |
| <Separator class="bg-slate-600" /> | |
| {/if} | |
| <!-- Manual Joint Controls --> | |
| {#if robot.manualControlEnabled} | |
| <div class="space-y-4"> | |
| <div class="mb-3 flex items-center gap-3"> | |
| <span class="icon-[lucide--rotate-3d] size-5 text-purple-400"></span> | |
| <h3 class="text-lg font-medium text-slate-100">Joint Controls</h3> | |
| <Badge variant="default" class="ml-auto bg-purple-600 text-xs"> | |
| {robot.activeJoints.length} | |
| </Badge> | |
| </div> | |
| <p class="text-xs text-slate-400"> | |
| Each joint can be moved independently using sliders. Values show virtual position | |
| (degrees) and real position in parentheses when available. | |
| </p> | |
| {#if robot.activeJoints.length === 0} | |
| <p class="py-4 text-center text-xs text-slate-500 italic">No active joints</p> | |
| {:else} | |
| <div class="space-y-3"> | |
| {#each robot.activeJoints as joint (joint.name)} | |
| {@const lower = | |
| joint.urdfJoint.limit?.lower != undefined | |
| ? (joint.urdfJoint.limit.lower * 180) / Math.PI | |
| : -180} | |
| {@const upper = | |
| joint.urdfJoint.limit?.upper != undefined | |
| ? (joint.urdfJoint.limit.upper * 180) / Math.PI | |
| : 180} | |
| <div class="space-y-2 rounded-lg border border-slate-600 bg-slate-800/50 p-3"> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm font-medium text-slate-200">{joint.name}</span> | |
| <div class="flex items-center gap-2 text-xs"> | |
| <span class="font-mono text-purple-400" | |
| >{joint.virtualValue.toFixed(0)}°</span | |
| > | |
| {#if joint.realValue !== undefined} | |
| <span class="font-mono text-green-400" | |
| >({joint.realValue.toFixed(0)}°)</span | |
| > | |
| {/if} | |
| </div> | |
| </div> | |
| <div class="space-y-1"> | |
| <input | |
| type="range" | |
| min={lower} | |
| max={upper} | |
| step="0.001" | |
| value={joint.virtualValue} | |
| oninput={(e) => { | |
| const val = parseFloat((e.target as HTMLInputElement).value); | |
| robot.updateJointValue(joint.name, val); | |
| }} | |
| class="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-slate-600" | |
| /> | |
| <div class="flex justify-between text-xs text-slate-500"> | |
| <span>{lower.toFixed(0)}°</span> | |
| <span>{upper.toFixed(0)}°</span> | |
| </div> | |
| </div> | |
| </div> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div class="space-y-4"> | |
| <div class="mb-3 flex items-center gap-3"> | |
| <span class="icon-[mdi--gamepad-variant] size-5 text-purple-400"></span> | |
| <h3 class="text-lg font-medium text-slate-100">Master Control Active</h3> | |
| </div> | |
| <Alert.Root class="border-purple-500/30 bg-purple-500/10"> | |
| <span class="icon-[mdi--gamepad-variant] size-4"></span> | |
| <Alert.Title class="text-sm text-purple-200">Master Control Active</Alert.Title> | |
| <Alert.Description class="text-xs text-purple-300"> | |
| Robot controlled by: <strong>{robot.controlState.masterName}</strong><br /> | |
| Disconnect master to enable manual control. | |
| </Alert.Description> | |
| </Alert.Root> | |
| </div> | |
| {/if} | |
| </div> | |
| </div> | |
| {/if} | |
| </Sheet.Content> | |
| </Sheet.Root> | |
| <style> | |
| /* Slider styling (classic <input type="range">) */ | |
| .slider::-webkit-slider-thumb { | |
| appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #a855f7; | |
| cursor: pointer; | |
| border: 2px solid #1e293b; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); | |
| transition: all 0.15s ease; | |
| } | |
| .slider::-webkit-slider-thumb:hover { | |
| background: #9333ea; | |
| transform: scale(1.1); | |
| } | |
| .slider::-moz-range-thumb { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #a855f7; | |
| cursor: pointer; | |
| border: 2px solid #1e293b; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); | |
| transition: all 0.15s ease; | |
| } | |
| .slider::-moz-range-track { | |
| height: 6px; | |
| background: #374151; | |
| border-radius: 3px; | |
| border: none; | |
| } | |
| .slider:focus { | |
| box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.5); | |
| } | |
| </style> | |