Spaces:
Running
Running
| <script lang="ts"> | |
| import * as THREE from "three"; | |
| import { T, useThrelte } from "@threlte/core"; | |
| import { getRootLinks } from "@/components/3d/robot/URDF/utils/UrdfParser"; | |
| import UrdfLink from "@/components/3d/robot/URDF/primitives/UrdfLink.svelte"; | |
| import { robotManager } from "$lib/robot/RobotManager.svelte"; | |
| import { Billboard, HTML } from "@threlte/extras"; | |
| import { scale } from "svelte/transition"; | |
| import { onMount } from "svelte"; | |
| import { robotUrdfConfigMap } from "@/configs/robotUrdfConfig"; | |
| import MasterConnectionModal from "@/components/interface/overlay/MasterConnectionModal.svelte"; | |
| import SlaveConnectionModal from "@/components/interface/overlay/SlaveConnectionModal.svelte"; | |
| import ManualControlModal from "@/components/interface/overlay/ManualControlSheet.svelte"; | |
| import type { Robot } from "$lib/robot/Robot.svelte"; | |
| import Hoverable from "@/components/utils/Hoverable.svelte"; | |
| import { generateName } from "$lib/utils/generateName"; | |
| interface Props {} | |
| let {}: Props = $props(); | |
| // Get camera controls | |
| const { camera } = useThrelte(); | |
| // Modal state using runes | |
| let isMasterModalOpen = $state(false); | |
| let isSlaveModalOpen = $state(false); | |
| let isManualControlModalOpen = $state(false); | |
| let selectedRobot = $state<Robot | null>(null); | |
| // Get all robots from the manager | |
| const robots = $derived(robotManager.robots); | |
| // Helper function to get connection status | |
| function getConnectionStatus(robot: any) { | |
| const status = robotManager.getRobotStatus(robot.id); | |
| if (!status) return "offline"; | |
| if (status.hasActiveMaster && status.connectedSlaves > 0) { | |
| return "active"; | |
| } else if (status.hasActiveMaster) { | |
| return "Master Only"; | |
| } else if (status.connectedSlaves > 0) { | |
| return "Slave Only"; | |
| } | |
| return "idle"; | |
| } | |
| // Helper function to get status color | |
| function getStatusVariant(status: string) { | |
| switch (status) { | |
| case "Master + Slaves": | |
| return "default"; // Green | |
| case "Master Only": | |
| return "secondary"; // Yellow | |
| case "Slave Only": | |
| return "outline"; // Blue | |
| default: | |
| return "destructive"; // Red/Gray | |
| } | |
| } | |
| // Camera movement function | |
| function moveCameraToRobot(robot: Robot, index: number) { | |
| if (!camera.current) return; | |
| // Calculate robot position (same logic as used in the template) | |
| const gridWidth = 3; | |
| const spacing = 6; | |
| const totalRows = Math.ceil(robots.length / gridWidth); | |
| const row = Math.floor(index / gridWidth); | |
| const col = 1 + (index % gridWidth); | |
| const xPosition = (col - Math.floor(gridWidth / 2)) * spacing; | |
| const zPosition = (row - Math.floor(totalRows / 2)) * spacing; | |
| // Camera positioning parameters - adjust these to change the rotation angle | |
| const cameraDistance = 12; // Distance from robot | |
| const cameraHeight = 8; // Height above ground | |
| const angleOffset = Math.PI / 4; // 45 degrees - change this for different angles | |
| // Calculate camera position using polar coordinates for easy angle control | |
| const cameraX = xPosition + Math.cos(angleOffset) * cameraDistance; | |
| const cameraZ = zPosition + Math.sin(angleOffset) * cameraDistance; | |
| // Create target positions | |
| const targetPosition = new THREE.Vector3(cameraX, cameraHeight, cameraZ); | |
| const lookAtPosition = new THREE.Vector3(xPosition, 1, zPosition); // Look at robot center | |
| // Animate camera movement | |
| const startPosition = camera.current.position.clone(); | |
| const startTime = Date.now(); | |
| const duration = 1000; // 1 second animation | |
| function animateCamera() { | |
| const elapsed = Date.now() - startTime; | |
| const progress = Math.min(elapsed / duration, 1); | |
| // Use easing function for smooth animation | |
| const easeProgress = 1 - Math.pow(1 - progress, 3); // easeOutCubic | |
| // Interpolate position | |
| camera.current.position.lerpVectors(startPosition, targetPosition, easeProgress); | |
| camera.current.lookAt(lookAtPosition); | |
| if (progress < 1) { | |
| requestAnimationFrame(animateCamera); | |
| } | |
| } | |
| animateCamera(); | |
| } | |
| // Modal management functions | |
| function openMasterModal(robot: Robot) { | |
| console.log("Opening master modal for robot:", robot.id); | |
| selectedRobot = robot; | |
| isMasterModalOpen = true; | |
| } | |
| function openSlaveModal(robot: Robot) { | |
| console.log("Opening slave modal for robot:", robot.id); | |
| selectedRobot = robot; | |
| isSlaveModalOpen = true; | |
| } | |
| function openManualControlModal(robot: Robot) { | |
| console.log("Opening manual control modal for robot:", robot.id); | |
| selectedRobot = robot; | |
| isManualControlModalOpen = true; | |
| } | |
| // Handle stop propagation for nested buttons | |
| function handleAddButtonClick(event: Event, robot: Robot, tab: "master" | "slaves") { | |
| console.log("Handling add button click for robot:", robot.id, "tab:", tab); | |
| event.stopPropagation(); | |
| if (tab === "master") { | |
| openMasterModal(robot); | |
| } else { | |
| openSlaveModal(robot); | |
| } | |
| } | |
| // Handle box clicks (using mousedown since it works reliably in 3D context) | |
| function handleBoxClick(robot: Robot, type: "master" | "slaves" | "manual") { | |
| console.log("Box clicked:", type, "for robot:", robot.id); | |
| if (type === "master") { | |
| openMasterModal(robot); | |
| } else if (type === "slaves") { | |
| openSlaveModal(robot); | |
| } else if (type === "manual") { | |
| openManualControlModal(robot); | |
| } | |
| } | |
| onMount(() => { | |
| function createRobot() { | |
| const urdfConfig = robotUrdfConfigMap["so-arm100"]; | |
| if (!urdfConfig) { | |
| return; | |
| } | |
| const robotId = generateName(); | |
| console.log("Creating robot with ID:", robotId, "and config:", urdfConfig); | |
| robotManager.createRobot(robotId, urdfConfig); | |
| } | |
| // If no robot then create one | |
| if (robots.length === 0) { | |
| createRobot(); | |
| } | |
| }); | |
| function generateRobotName() { | |
| throw new Error("Function not implemented."); | |
| } | |
| </script> | |
| {#each robots as robot, index (robot.id)} | |
| {@const gridWidth = 3} | |
| <!-- Number of robots per row --> | |
| {@const spacing = 6} | |
| <!-- Space between robots --> | |
| {@const totalRows = Math.ceil(robots.length / gridWidth)} | |
| {@const row = Math.floor(index / gridWidth)} | |
| {@const col = 1 + (index % gridWidth)} | |
| {@const xPosition = (col - Math.floor(gridWidth / 2)) * spacing} | |
| <!-- Center the grid on x-axis --> | |
| {@const zPosition = (row - Math.floor(totalRows / 2)) * spacing} | |
| <!-- Center the grid on z-axis --> | |
| {@const robotStatus = robotManager.getRobotStatus(robot.id)} | |
| {@const connectionStatus = getConnectionStatus(robot)} | |
| {@const statusVariant = getStatusVariant(connectionStatus)} | |
| <T.Group | |
| position.x={xPosition} | |
| position.y={0} | |
| position.z={zPosition} | |
| quaternion={[0, 0, 0, 1]} | |
| scale={[10, 10, 10]} | |
| rotation={[-Math.PI / 2, 0, 0]} | |
| > | |
| <Hoverable | |
| onClick={() => { | |
| moveCameraToRobot(robot, index); | |
| handleBoxClick(robot, "manual"); | |
| }} | |
| > | |
| {#snippet content({ isHovered, isSelected })} | |
| {#each getRootLinks(robot.robotState.robot) as link} | |
| <UrdfLink | |
| robot={robot.robotState.robot} | |
| {link} | |
| textScale={0.2} | |
| showName={isHovered || isSelected} | |
| showVisual={true} | |
| showCollision={false} | |
| visualColor="#333333" | |
| visualOpacity={isHovered || isSelected ? 0.4 : 1.0} | |
| collisionOpacity={1.0} | |
| collisionColor="#813d9c" | |
| jointNames={isHovered || isSelected} | |
| joints={isHovered || isSelected} | |
| jointColor="#62a0ea" | |
| jointIndicatorColor="#f66151" | |
| nameHeight={0.1} | |
| selectedLink={robot.robotState.selection.selectedLink} | |
| selectedJoint={robot.robotState.selection.selectedJoint} | |
| highlightColor="#ffa348" | |
| showLine={isHovered || isSelected} | |
| opacity={1} | |
| isInteractive={false} | |
| /> | |
| {/each} | |
| <T.Group position.z={0.25} rotation={[Math.PI / 2, 0, 0]} scale={[0.12, 0.12, 0.12]}> | |
| <Billboard> | |
| <HTML | |
| transform | |
| autoRender={true} | |
| center={true} | |
| distanceFactor={3} | |
| pointerEvents="auto" | |
| style=" | |
| pointer-events: auto !important; | |
| image-rendering: auto; | |
| image-rendering: smooth; | |
| text-rendering: optimizeLegibility; | |
| -webkit-font-smoothing: subpixel-antialiased; | |
| -moz-osx-font-smoothing: auto; | |
| backface-visibility: hidden; | |
| transform-style: preserve-3d; | |
| will-change: transform; | |
| " | |
| > | |
| {#if isHovered || isSelected} | |
| <div | |
| class="pointer-events-auto select-none" | |
| style="pointer-events: auto !important;" | |
| in:scale={{ duration: 200, start: 0.5 }} | |
| > | |
| <div class="flex items-center gap-3"> | |
| <!-- Manual Control Box --> | |
| <button | |
| class={[ | |
| "relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-purple-500/40 bg-purple-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-purple-400/60", | |
| robotStatus?.hasActiveMaster | |
| ? "cursor-not-allowed border-dashed opacity-40" | |
| : robot.manualControlEnabled | |
| ? "" | |
| : "border-dashed opacity-60" | |
| ]} | |
| style="pointer-events: auto !important;" | |
| onmousedown={() => | |
| !robotStatus?.hasActiveMaster && handleBoxClick(robot, "manual")} | |
| onclick={() => | |
| !robotStatus?.hasActiveMaster && handleBoxClick(robot, "manual")} | |
| disabled={robotStatus?.hasActiveMaster} | |
| > | |
| {#if robotStatus?.hasActiveMaster} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[mdi--lock] size-3 text-purple-400/60"></span> | |
| <span | |
| class="text-xs leading-tight font-medium text-purple-400/60 uppercase" | |
| >CONTROL DISABLED</span | |
| > | |
| </div> | |
| <span class="mt-0.5 text-center text-xs leading-tight text-purple-300/60"> | |
| Control managed by | |
| </span> | |
| <span | |
| class="text-center text-xs leading-tight font-semibold text-purple-300/60" | |
| > | |
| {robot.master?.name?.slice(0, 20) || "Master"} | |
| </span> | |
| </div> | |
| {:else if robot.manualControlEnabled} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[mdi--tune] size-3 text-purple-400"></span> | |
| <span | |
| class="text-xs leading-tight font-semibold text-purple-400 uppercase" | |
| >MANUAL</span | |
| > | |
| </div> | |
| <span class="mt-0.5 text-xs leading-tight text-purple-200"> | |
| {robot.activeJoints.length} Joints Active | |
| </span> | |
| <span class="text-xs leading-tight text-purple-300/80"> | |
| Click to take manual control | |
| </span> | |
| </div> | |
| {:else} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[mdi--tune] size-3 text-purple-400/60"></span> | |
| <span class="text-xs leading-tight font-medium text-purple-400/60" | |
| >MANUAL OFF</span | |
| > | |
| </div> | |
| <span class="mt-0.5 text-xs leading-tight text-purple-300/40" | |
| >Click to Configure</span | |
| > | |
| <div class="mt-2 text-purple-400/60"> | |
| <span class="icon-[mdi--cog-outline] size-4"></span> | |
| </div> | |
| </div> | |
| {/if} | |
| </button> | |
| </div> | |
| </div> | |
| {:else} | |
| <div | |
| class="pointer-events-auto select-none" | |
| style="pointer-events: auto !important;" | |
| in:scale={{ duration: 200, start: 0.5 }} | |
| > | |
| <div class="flex items-center gap-3"> | |
| <!-- Master Box --> | |
| <button | |
| class={[ | |
| "relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-green-500/40 bg-green-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-green-400/60", | |
| robotStatus?.hasActiveMaster ? "" : "border-dashed opacity-60" | |
| ]} | |
| style="pointer-events: auto !important;" | |
| onmousedown={() => handleBoxClick(robot, "master")} | |
| onclick={() => handleBoxClick(robot, "master")} | |
| > | |
| {#if robotStatus?.hasActiveMaster} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[mdi--speak] size-3 text-green-400"></span> | |
| <span | |
| class="text-xs leading-tight font-semibold text-green-400 uppercase" | |
| >MASTER</span | |
| > | |
| </div> | |
| <span class="mt-0.5 text-xs leading-tight text-green-200"> | |
| {robot.master?.name.slice(0, 30) || "Unknown"} | |
| </span> | |
| {#if robot.master?.constructor.name} | |
| <span class="text-xs leading-tight text-green-300/80"> | |
| {robot.master?.constructor.name.replace("Driver", "").slice(0, 30)} | |
| </span> | |
| {:else} | |
| <span class="text-xs leading-tight text-red-300/80"> N/A </span> | |
| {/if} | |
| <div | |
| class="mt-1 h-1.5 w-1.5 animate-pulse rounded-full bg-green-400" | |
| ></div> | |
| </div> | |
| {:else} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[mdi--speak] size-3 text-green-400/60"></span> | |
| <span class="text-xs leading-tight font-medium text-green-400/60" | |
| >NO MASTER</span | |
| > | |
| </div> | |
| <span class="mt-0.5 text-xs leading-tight text-green-300/40" | |
| >Click to Connect</span | |
| > | |
| <div class="mt-2 text-green-400/60"> | |
| <span class="icon-[mdi--plus-circle] size-4"></span> | |
| </div> | |
| </div> | |
| {/if} | |
| </button> | |
| <!-- Arrow 1: Master to Robot --> | |
| <div class="font-mono text-sm text-slate-400"> | |
| {#if robotStatus?.hasActiveMaster} | |
| <span class="text-green-400">→</span> | |
| {:else} | |
| <span class="text-green-400/50">⇢</span> | |
| {/if} | |
| </div> | |
| <!-- Robot Box (Simplified) --> | |
| <div | |
| class={[ | |
| "min-h-[80px] min-w-[90px] rounded-lg border border-amber-500/40 bg-slate-900/80 px-3 py-3 backdrop-blur-sm" | |
| ]} | |
| > | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[mdi--connection] size-3 text-amber-400"></span> | |
| <span | |
| class="text-xs leading-tight font-semibold text-amber-400 uppercase" | |
| > | |
| Robot | |
| </span> | |
| </div> | |
| <span class="mt-0.5 text-xs leading-tight text-amber-200"> | |
| {robot.id} | |
| </span> | |
| </div> | |
| </div> | |
| <!-- Arrow 2: Robot to Slaves --> | |
| <div class="font-mono text-sm text-slate-400"> | |
| {#if robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0} | |
| <span class="text-blue-400">→</span> | |
| {:else} | |
| <span class="text-blue-400/50">⇢</span> | |
| {/if} | |
| </div> | |
| <!-- Slaves Box --> | |
| <button | |
| class={[ | |
| "relative min-h-[80px] min-w-[90px] cursor-pointer rounded-lg border border-blue-500/40 bg-blue-950/20 bg-slate-900/80 px-3 py-3 backdrop-blur-sm transition-colors hover:border-blue-400/60", | |
| robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0 | |
| ? "" | |
| : "border-dashed opacity-60" | |
| ]} | |
| style="pointer-events: auto !important;" | |
| onmousedown={() => handleBoxClick(robot, "slaves")} | |
| onclick={() => handleBoxClick(robot, "slaves")} | |
| > | |
| {#if robotStatus?.connectedSlaves && robotStatus.connectedSlaves > 0} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[fa6-solid--ear-listen] size-3 text-blue-400"></span> | |
| <span | |
| class="text-xs leading-tight font-semibold text-blue-400 uppercase" | |
| >SLAVES</span | |
| > | |
| </div> | |
| {#if robot.slaves.length > 0} | |
| <div class="mt-1 flex flex-col items-center gap-0.5"> | |
| {#each robot.slaves.slice(0, 2) as slave} | |
| <div class="mt-0.5 text-xs leading-tight text-blue-300/80"> | |
| {slave.name.slice(0, 30) || "Slave"} | |
| </div> | |
| <div class="text-xs leading-tight text-blue-200"> | |
| {slave.constructor.name.replace("Driver", "").slice(0, 30) || | |
| "N/A"} | |
| </div> | |
| {/each} | |
| {#if robot.slaves.length > 2} | |
| <span class="text-xs text-blue-400/60" | |
| >+{robot.slaves.length - 2} more</span | |
| > | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div class="flex flex-col items-center"> | |
| <div class="flex items-center gap-1"> | |
| <span class="icon-[fa6-solid--ear-listen] size-3 text-blue-400/60" | |
| ></span> | |
| <span class="text-xs leading-tight font-medium text-blue-400/60" | |
| >NO SLAVES</span | |
| > | |
| </div> | |
| <span class="mt-0.5 text-xs leading-tight text-blue-300/40" | |
| >Click to Connect</span | |
| > | |
| <div class="mt-2 text-blue-400/60"> | |
| <span class="icon-[mdi--plus-circle] size-4"></span> | |
| </div> | |
| </div> | |
| {/if} | |
| </button> | |
| </div> | |
| </div> | |
| {/if} | |
| </HTML> | |
| </Billboard> | |
| </T.Group> | |
| {/snippet} | |
| </Hoverable> | |
| </T.Group> | |
| {/each} | |
| <!-- Master Connection Modal --> | |
| <MasterConnectionModal bind:open={isMasterModalOpen} robot={selectedRobot} /> | |
| <!-- Slave Connection Modal --> | |
| <SlaveConnectionModal bind:open={isSlaveModalOpen} robot={selectedRobot} /> | |
| <!-- Manual Control Modal --> | |
| <ManualControlModal bind:open={isManualControlModalOpen} robot={selectedRobot} /> | |