Spaces:
Running
Running
import React, { | |
useEffect, | |
useRef, | |
useState, | |
useMemo, | |
useCallback, | |
} from "react"; | |
import { cn } from "@/lib/utils"; | |
import URDFManipulator from "urdf-loader/src/urdf-manipulator-element.js"; | |
import { useUrdf } from "@/hooks/useUrdf"; | |
import { useRealTimeJoints } from "@/hooks/useRealTimeJoints"; | |
import { | |
createUrdfViewer, | |
setupMeshLoader, | |
setupJointHighlighting, | |
setupModelLoading, | |
URDFViewerElement, | |
} from "@/lib/urdfViewerHelpers"; | |
// Register the URDFManipulator as a custom element if it hasn't been already | |
if (typeof window !== "undefined" && !customElements.get("urdf-viewer")) { | |
customElements.define("urdf-viewer", URDFManipulator); | |
} | |
import * as THREE from "three"; | |
// Extend the interface for the URDF viewer element to include background property | |
interface UrdfViewerElement extends HTMLElement { | |
background?: string; | |
setJointValue?: (jointName: string, value: number) => void; | |
} | |
const UrdfViewer: React.FC = () => { | |
const containerRef = useRef<HTMLDivElement>(null); | |
const [highlightedJoint, setHighlightedJoint] = useState<string | null>(null); | |
const { registerUrdfProcessor, alternativeUrdfModels, isDefaultModel } = | |
useUrdf(); | |
// Add state for animation control | |
useState<boolean>(isDefaultModel); | |
const cleanupAnimationRef = useRef<(() => void) | null>(null); | |
const viewerRef = useRef<URDFViewerElement | null>(null); | |
const hasInitializedRef = useRef<boolean>(false); | |
// Real-time joint updates via WebSocket | |
const { isConnected: isWebSocketConnected } = useRealTimeJoints({ | |
viewerRef, | |
enabled: isDefaultModel, // Only enable WebSocket for default model | |
}); | |
// Add state for custom URDF path | |
const [customUrdfPath, setCustomUrdfPath] = useState<string | null>(null); | |
const [urlModifierFunc, setUrlModifierFunc] = useState< | |
((url: string) => string) | null | |
>(null); | |
const packageRef = useRef<string>(""); | |
// Implement UrdfProcessor interface for drag and drop | |
const urdfProcessor = useMemo( | |
() => ({ | |
loadUrdf: (urdfPath: string) => { | |
setCustomUrdfPath(urdfPath); | |
}, | |
setUrlModifierFunc: (func: (url: string) => string) => { | |
setUrlModifierFunc(() => func); | |
}, | |
getPackage: () => { | |
return packageRef.current; | |
}, | |
}), | |
[] | |
); | |
// Register the URDF processor with the global drag and drop context | |
useEffect(() => { | |
registerUrdfProcessor(urdfProcessor); | |
}, [registerUrdfProcessor, urdfProcessor]); | |
// Create URL modifier function for default model | |
const defaultUrlModifier = useCallback((url: string) => { | |
console.log(`🔗 defaultUrlModifier called with: ${url}`); | |
// Handle various package:// URL formats for the default SO-101 model | |
if (url.startsWith("package://so_arm_description/meshes/")) { | |
const modifiedUrl = url.replace( | |
"package://so_arm_description/meshes/", | |
"/so-101-urdf/meshes/" | |
); | |
console.log(`🔗 Modified URL (package): ${modifiedUrl}`); | |
return modifiedUrl; | |
} | |
// Handle case where package path might be partially resolved | |
if (url.includes("so_arm_description/meshes/")) { | |
const modifiedUrl = url.replace( | |
/.*so_arm_description\/meshes\//, | |
"/so-101-urdf/meshes/" | |
); | |
console.log(`🔗 Modified URL (partial): ${modifiedUrl}`); | |
return modifiedUrl; | |
} | |
// Handle the specific problematic path pattern we're seeing in logs | |
if (url.includes("/so-101-urdf/so_arm_description/meshes/")) { | |
const modifiedUrl = url.replace( | |
"/so-101-urdf/so_arm_description/meshes/", | |
"/so-101-urdf/meshes/" | |
); | |
console.log(`🔗 Modified URL (problematic path): ${modifiedUrl}`); | |
return modifiedUrl; | |
} | |
// Handle relative paths that might need mesh folder prefix | |
if ( | |
url.endsWith(".stl") && | |
!url.startsWith("/") && | |
!url.startsWith("http") | |
) { | |
const modifiedUrl = `/so-101-urdf/meshes/${url}`; | |
console.log(`🔗 Modified URL (relative): ${modifiedUrl}`); | |
return modifiedUrl; | |
} | |
console.log(`🔗 Unmodified URL: ${url}`); | |
return url; | |
}, []); | |
// Main effect to create and setup the viewer only once | |
useEffect(() => { | |
if (!containerRef.current) return; | |
// Create and configure the URDF viewer element | |
const viewer = createUrdfViewer(containerRef.current, true); | |
viewerRef.current = viewer; // Store reference to the viewer | |
// Setup mesh loading function with appropriate URL modifier | |
const activeUrlModifier = isDefaultModel | |
? defaultUrlModifier | |
: urlModifierFunc; | |
setupMeshLoader(viewer, activeUrlModifier); | |
// Determine which URDF to load - fixed path to match the actual available file | |
const urdfPath = isDefaultModel | |
? "/so-101-urdf/urdf/so101_new_calib.urdf" | |
: customUrdfPath || ""; | |
// Set the package path for the default model | |
if (isDefaultModel) { | |
packageRef.current = "/"; // Set to root so we can handle full path resolution in URL modifier | |
} | |
// Setup model loading if a path is available | |
let cleanupModelLoading = () => {}; | |
if (urdfPath) { | |
cleanupModelLoading = setupModelLoading( | |
viewer, | |
urdfPath, | |
packageRef.current, | |
setCustomUrdfPath, | |
alternativeUrdfModels | |
); | |
} | |
// Setup joint highlighting | |
const cleanupJointHighlighting = setupJointHighlighting( | |
viewer, | |
setHighlightedJoint | |
); | |
// Function to fit the robot to the camera view | |
const fitRobotToView = (viewer: URDFViewerElement) => { | |
if (!viewer || !viewer.robot) { | |
console.log( | |
"[RobotViewer] Cannot fit to view: No viewer or robot available" | |
); | |
return; | |
} | |
try { | |
// Create a bounding box for the robot | |
const boundingBox = new THREE.Box3().setFromObject(viewer.robot); | |
// Calculate the center of the bounding box | |
const center = new THREE.Vector3(); | |
boundingBox.getCenter(center); | |
// Calculate the size of the bounding box | |
const size = new THREE.Vector3(); | |
boundingBox.getSize(size); | |
// Get the maximum dimension to ensure the entire robot is visible | |
const maxDim = Math.max(size.x, size.y, size.z); | |
// Position camera to see the center of the model | |
viewer.camera.position.copy(center); | |
// Move the camera back to see the entire robot | |
// Use the model's up direction to determine which axis to move along | |
const upVector = new THREE.Vector3(); | |
if (viewer.up === "+Z" || viewer.up === "Z") { | |
upVector.set(1, 1, 1); // Move back in a diagonal | |
} else if (viewer.up === "+Y" || viewer.up === "Y") { | |
upVector.set(1, 1, 1); // Move back in a diagonal | |
} else { | |
upVector.set(1, 1, 1); // Default direction | |
} | |
// Normalize the vector and multiply by the size | |
upVector.normalize().multiplyScalar(maxDim * 1.3); | |
viewer.camera.position.add(upVector); | |
// Make the camera look at the center of the model | |
viewer.controls.target.copy(center); | |
// Update controls and mark for redraw | |
viewer.controls.update(); | |
viewer.redraw(); | |
console.log("[RobotViewer] Robot auto-fitted to view"); | |
} catch (error) { | |
console.error("[RobotViewer] Error fitting robot to view:", error); | |
} | |
}; | |
// Add event listener for when the robot is loaded to auto-fit to view | |
const onRobotLoad = () => { | |
fitRobotToView(viewer); | |
}; | |
// Setup animation event handler for the default model or when hasAnimation is true | |
const onModelProcessed = () => { | |
hasInitializedRef.current = true; | |
if ("setJointValue" in viewer) { | |
// Clear any existing animation | |
if (cleanupAnimationRef.current) { | |
cleanupAnimationRef.current(); | |
cleanupAnimationRef.current = null; | |
} | |
} | |
// Auto-fit the robot to view when the model is processed | |
onRobotLoad(); | |
}; | |
viewer.addEventListener("urdf-processed", onModelProcessed); | |
// Return cleanup function | |
return () => { | |
if (cleanupAnimationRef.current) { | |
cleanupAnimationRef.current(); | |
cleanupAnimationRef.current = null; | |
} | |
hasInitializedRef.current = false; | |
cleanupJointHighlighting(); | |
cleanupModelLoading(); | |
viewer.removeEventListener("urdf-processed", onModelProcessed); | |
}; | |
}, [ | |
isDefaultModel, | |
customUrdfPath, | |
urlModifierFunc, | |
defaultUrlModifier, | |
alternativeUrdfModels, | |
]); | |
return ( | |
<div | |
className={cn( | |
"w-full h-full transition-all duration-300 ease-in-out relative", | |
"bg-gradient-to-br from-gray-900 to-gray-800" | |
)} | |
> | |
<div ref={containerRef} className="w-full h-full" /> | |
{/* Joint highlight indicator */} | |
{highlightedJoint && ( | |
<div className="absolute bottom-4 right-4 bg-black/70 text-white px-3 py-2 rounded-md text-sm font-mono z-10"> | |
Joint: {highlightedJoint} | |
</div> | |
)} | |
{/* WebSocket connection status */} | |
{isDefaultModel && ( | |
<div className="absolute top-4 right-4 z-10"> | |
<div | |
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono ${ | |
isWebSocketConnected | |
? "bg-green-900/70 text-green-300" | |
: "bg-red-900/70 text-red-300" | |
}`} | |
> | |
<div | |
className={`w-2 h-2 rounded-full ${ | |
isWebSocketConnected ? "bg-green-400" : "bg-red-400" | |
}`} | |
/> | |
{isWebSocketConnected ? "Live Robot Data" : "Disconnected"} | |
</div> | |
</div> | |
)} | |
</div> | |
); | |
}; | |
export default UrdfViewer; | |