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} /> | |