blanchon's picture
Mostly UI Update
18b0fa5
<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} />