Spaces:
Running
Running
add openarm
Browse files
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import Sidebar from "@/components/side-nav";
|
|
| 11 |
import StatsPanel from "@/components/stats-panel";
|
| 12 |
import OverviewPanel from "@/components/overview-panel";
|
| 13 |
import Loading from "@/components/loading-component";
|
| 14 |
-
import {
|
| 15 |
import {
|
| 16 |
getAdjacentEpisodesVideoInfo,
|
| 17 |
computeColumnMinMax,
|
|
@@ -317,7 +317,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 317 |
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 318 |
)}
|
| 319 |
</button>
|
| 320 |
-
{
|
| 321 |
<button
|
| 322 |
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 323 |
activeTab === "urdf"
|
|
|
|
| 11 |
import StatsPanel from "@/components/stats-panel";
|
| 12 |
import OverviewPanel from "@/components/overview-panel";
|
| 13 |
import Loading from "@/components/loading-component";
|
| 14 |
+
import { hasURDFSupport } from "@/lib/so101-robot";
|
| 15 |
import {
|
| 16 |
getAdjacentEpisodesVideoInfo,
|
| 17 |
computeColumnMinMax,
|
|
|
|
| 317 |
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 318 |
)}
|
| 319 |
</button>
|
| 320 |
+
{hasURDFSupport(datasetInfo.robot_type) && (
|
| 321 |
<button
|
| 322 |
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 323 |
activeTab === "urdf"
|
src/components/urdf-viewer.tsx
CHANGED
|
@@ -7,20 +7,26 @@ import * as THREE from "three";
|
|
| 7 |
import URDFLoader from "urdf-loader";
|
| 8 |
import type { URDFRobot } from "urdf-loader";
|
| 9 |
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
|
|
|
| 10 |
import { Line2 } from "three/examples/jsm/lines/Line2.js";
|
| 11 |
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
|
| 12 |
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
|
| 13 |
import type { EpisodeData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 14 |
import { fetchEpisodeChartData } from "@/app/[org]/[dataset]/[episode]/actions";
|
|
|
|
| 15 |
|
| 16 |
const SERIES_DELIM = " | ";
|
| 17 |
-
const SCALE = 10;
|
| 18 |
const DEG2RAD = Math.PI / 180;
|
| 19 |
|
| 20 |
-
function
|
| 21 |
const lower = (robotType ?? "").toLowerCase();
|
| 22 |
-
if (lower.includes("
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
// Detect unit: servo ticks (0-4096), degrees (>6.28), or radians
|
|
@@ -46,34 +52,60 @@ function groupColumnsByPrefix(keys: string[]): Record<string, string[]> {
|
|
| 46 |
|
| 47 |
function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record<string, string> {
|
| 48 |
const mapping: Record<string, string> = {};
|
|
|
|
|
|
|
| 49 |
for (const jointName of urdfJointNames) {
|
| 50 |
const lower = jointName.toLowerCase();
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const fuzzy = columnKeys.find((k) => k.toLowerCase().includes(lower));
|
| 57 |
if (fuzzy) mapping[jointName] = fuzzy;
|
| 58 |
}
|
| 59 |
return mapping;
|
| 60 |
}
|
| 61 |
|
| 62 |
-
// Tip link names to try (so101 then
|
| 63 |
-
const TIP_LINK_NAMES = [
|
|
|
|
|
|
|
|
|
|
| 64 |
const TRAIL_DURATION = 1.0; // seconds
|
| 65 |
const TRAIL_COLOR = new THREE.Color("#ff6600");
|
| 66 |
const MAX_TRAIL_POINTS = 300;
|
| 67 |
|
| 68 |
// ─── Robot scene (imperative, inside Canvas) ───
|
| 69 |
function RobotScene({
|
| 70 |
-
urdfUrl, jointValues, onJointsLoaded, trailEnabled, trailResetKey,
|
| 71 |
}: {
|
| 72 |
urdfUrl: string;
|
| 73 |
jointValues: Record<string, number>;
|
| 74 |
onJointsLoaded: (names: string[]) => void;
|
| 75 |
trailEnabled: boolean;
|
| 76 |
trailResetKey: number;
|
|
|
|
| 77 |
}) {
|
| 78 |
const { scene, size } = useThree();
|
| 79 |
const robotRef = useRef<URDFRobot | null>(null);
|
|
@@ -123,19 +155,32 @@ function RobotScene({
|
|
| 123 |
useEffect(() => {
|
| 124 |
setLoading(true);
|
| 125 |
setError(null);
|
|
|
|
| 126 |
const manager = new THREE.LoadingManager();
|
| 127 |
const loader = new URDFLoader(manager);
|
| 128 |
loader.loadMeshCb = (url, mgr, onLoad) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
const stlLoader = new STLLoader(mgr);
|
| 130 |
stlLoader.load(
|
| 131 |
url,
|
| 132 |
(geometry) => {
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
onLoad(new THREE.Mesh(geometry, material));
|
| 140 |
},
|
| 141 |
undefined,
|
|
@@ -149,18 +194,17 @@ function RobotScene({
|
|
| 149 |
robot.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
| 150 |
robot.traverse((c) => { c.castShadow = true; });
|
| 151 |
robot.updateMatrixWorld(true);
|
| 152 |
-
robot.scale.set(
|
| 153 |
scene.add(robot);
|
| 154 |
|
| 155 |
-
// Find the tip link for the trail
|
| 156 |
for (const name of TIP_LINK_NAMES) {
|
| 157 |
if (robot.frames[name]) { tipLinkRef.current = robot.frames[name]; break; }
|
| 158 |
}
|
| 159 |
|
| 160 |
-
const
|
| 161 |
-
.filter((j) => j.jointType === "revolute" || j.jointType === "continuous")
|
| 162 |
.map((j) => j.name);
|
| 163 |
-
onJointsLoaded(
|
| 164 |
setLoading(false);
|
| 165 |
},
|
| 166 |
undefined,
|
|
@@ -170,7 +214,7 @@ function RobotScene({
|
|
| 170 |
if (robotRef.current) { scene.remove(robotRef.current); robotRef.current = null; }
|
| 171 |
tipLinkRef.current = null;
|
| 172 |
};
|
| 173 |
-
}, [urdfUrl, scene, onJointsLoaded]);
|
| 174 |
|
| 175 |
const tipWorldPos = useMemo(() => new THREE.Vector3(), []);
|
| 176 |
|
|
@@ -290,7 +334,8 @@ export default function URDFViewer({
|
|
| 290 |
}) {
|
| 291 |
const { datasetInfo, episodes } = data;
|
| 292 |
const fps = datasetInfo.fps || 30;
|
| 293 |
-
const
|
|
|
|
| 294 |
|
| 295 |
// Episode selection & chart data
|
| 296 |
const [selectedEpisode, setSelectedEpisode] = useState(data.episodeId);
|
|
@@ -357,6 +402,7 @@ export default function URDFViewer({
|
|
| 357 |
|
| 358 |
// Trail
|
| 359 |
const [trailEnabled, setTrailEnabled] = useState(true);
|
|
|
|
| 360 |
|
| 361 |
// Playback
|
| 362 |
const [frame, setFrame] = useState(0);
|
|
@@ -369,24 +415,68 @@ export default function URDFViewer({
|
|
| 369 |
frameRef.current = f;
|
| 370 |
}, []);
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
// Compute joint values for current frame
|
| 373 |
const jointValues = useMemo(() => {
|
| 374 |
if (totalFrames === 0 || urdfJointNames.length === 0) return {};
|
| 375 |
const row = chartData[Math.min(frame, totalFrames - 1)];
|
| 376 |
-
const
|
| 377 |
-
const
|
|
|
|
| 378 |
|
| 379 |
for (const jn of urdfJointNames) {
|
|
|
|
| 380 |
const col = mapping[jn];
|
| 381 |
-
if (col
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
}
|
| 385 |
}
|
| 386 |
|
| 387 |
-
const converted = detectAndConvert(
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
return values;
|
| 391 |
}, [chartData, frame, mapping, totalFrames, urdfJointNames]);
|
| 392 |
|
|
@@ -406,12 +496,12 @@ export default function URDFViewer({
|
|
| 406 |
<span className="text-white text-lg animate-pulse">Loading episode {selectedEpisode}…</span>
|
| 407 |
</div>
|
| 408 |
)}
|
| 409 |
-
<Canvas camera={{ position: [0.3 *
|
| 410 |
-
<ambientLight intensity={0.
|
| 411 |
-
<directionalLight position={[3, 5, 4]} intensity={1.
|
| 412 |
-
<directionalLight position={[-2, 3, -2]} intensity={0.
|
| 413 |
-
<hemisphereLight args={["#b1e1ff", "#
|
| 414 |
-
<RobotScene urdfUrl={urdfUrl} jointValues={jointValues} onJointsLoaded={onJointsLoaded} trailEnabled={trailEnabled} trailResetKey={selectedEpisode} />
|
| 415 |
<Grid
|
| 416 |
args={[10, 10]} cellSize={0.2} cellThickness={0.5} cellColor="#334155"
|
| 417 |
sectionSize={1} sectionThickness={1} sectionColor="#475569"
|
|
@@ -477,55 +567,66 @@ export default function URDFViewer({
|
|
| 477 |
<span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">F {frame}/{Math.max(totalFrames - 1, 0)}</span>
|
| 478 |
</div>
|
| 479 |
|
| 480 |
-
{/*
|
| 481 |
-
<
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
</div>
|
| 492 |
-
</div>
|
| 493 |
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
</tr>
|
| 503 |
-
</thead>
|
| 504 |
-
<tbody>
|
| 505 |
-
{urdfJointNames.map((jointName) => (
|
| 506 |
-
<tr key={jointName} className="border-t border-slate-700/50">
|
| 507 |
-
<td className="px-1 py-0.5 text-slate-300 font-mono">{jointName}</td>
|
| 508 |
-
<td className="px-1 text-slate-600">→</td>
|
| 509 |
-
<td className="px-1 py-0.5">
|
| 510 |
-
<select value={mapping[jointName] ?? ""}
|
| 511 |
-
onChange={(e) => setMapping((m) => ({ ...m, [jointName]: e.target.value }))}
|
| 512 |
-
className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]">
|
| 513 |
-
<option value="">-- unmapped --</option>
|
| 514 |
-
{selectedColumns.map((col) => {
|
| 515 |
-
const label = col.split(SERIES_DELIM).pop() ?? col;
|
| 516 |
-
return <option key={col} value={col}>{label}</option>;
|
| 517 |
-
})}
|
| 518 |
-
</select>
|
| 519 |
-
</td>
|
| 520 |
-
<td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
|
| 521 |
-
{jointValues[jointName] !== undefined ? jointValues[jointName].toFixed(3) : "—"}
|
| 522 |
-
</td>
|
| 523 |
</tr>
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
</div>
|
| 528 |
-
|
| 529 |
</div>
|
| 530 |
</div>
|
| 531 |
);
|
|
|
|
| 7 |
import URDFLoader from "urdf-loader";
|
| 8 |
import type { URDFRobot } from "urdf-loader";
|
| 9 |
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
| 10 |
+
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
|
| 11 |
import { Line2 } from "three/examples/jsm/lines/Line2.js";
|
| 12 |
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
|
| 13 |
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
|
| 14 |
import type { EpisodeData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 15 |
import { fetchEpisodeChartData } from "@/app/[org]/[dataset]/[episode]/actions";
|
| 16 |
+
import { isOpenArmRobot } from "@/lib/so101-robot";
|
| 17 |
|
| 18 |
const SERIES_DELIM = " | ";
|
|
|
|
| 19 |
const DEG2RAD = Math.PI / 180;
|
| 20 |
|
| 21 |
+
function getRobotConfig(robotType: string | null) {
|
| 22 |
const lower = (robotType ?? "").toLowerCase();
|
| 23 |
+
if (lower.includes("openarm")) {
|
| 24 |
+
return { urdfUrl: "/urdf/openarm/openarm_bimanual.urdf", scale: 3, isDualArm: true };
|
| 25 |
+
}
|
| 26 |
+
if (lower.includes("so100") && !lower.includes("so101")) {
|
| 27 |
+
return { urdfUrl: "/urdf/so101/so100.urdf", scale: 10, isDualArm: false };
|
| 28 |
+
}
|
| 29 |
+
return { urdfUrl: "/urdf/so101/so101_new_calib.urdf", scale: 10, isDualArm: false };
|
| 30 |
}
|
| 31 |
|
| 32 |
// Detect unit: servo ticks (0-4096), degrees (>6.28), or radians
|
|
|
|
| 52 |
|
| 53 |
function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record<string, string> {
|
| 54 |
const mapping: Record<string, string> = {};
|
| 55 |
+
const suffixes = columnKeys.map((k) => (k.split(SERIES_DELIM).pop()?.trim() ?? k).toLowerCase());
|
| 56 |
+
|
| 57 |
for (const jointName of urdfJointNames) {
|
| 58 |
const lower = jointName.toLowerCase();
|
| 59 |
+
|
| 60 |
+
// Exact match on column suffix
|
| 61 |
+
const exactIdx = suffixes.findIndex((s) => s === lower);
|
| 62 |
+
if (exactIdx >= 0) { mapping[jointName] = columnKeys[exactIdx]; continue; }
|
| 63 |
+
|
| 64 |
+
// OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
|
| 65 |
+
const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
|
| 66 |
+
if (armMatch) {
|
| 67 |
+
const pattern = `${armMatch[1]}_joint_${armMatch[2]}`;
|
| 68 |
+
const idx = suffixes.findIndex((s) => s.includes(pattern));
|
| 69 |
+
if (idx >= 0) { mapping[jointName] = columnKeys[idx]; continue; }
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// OpenArm: openarm_(left|right)_finger_joint1 → (left|right)_gripper
|
| 73 |
+
const fingerMatch = lower.match(/^openarm_(left|right)_finger_joint1$/);
|
| 74 |
+
if (fingerMatch) {
|
| 75 |
+
const pattern = `${fingerMatch[1]}_gripper`;
|
| 76 |
+
const idx = suffixes.findIndex((s) => s.includes(pattern));
|
| 77 |
+
if (idx >= 0) { mapping[jointName] = columnKeys[idx]; continue; }
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// finger_joint2 is a mimic joint — skip
|
| 81 |
+
if (lower.includes("finger_joint2")) continue;
|
| 82 |
+
|
| 83 |
+
// Generic fuzzy fallback
|
| 84 |
const fuzzy = columnKeys.find((k) => k.toLowerCase().includes(lower));
|
| 85 |
if (fuzzy) mapping[jointName] = fuzzy;
|
| 86 |
}
|
| 87 |
return mapping;
|
| 88 |
}
|
| 89 |
|
| 90 |
+
// Tip link names to try (so101, so100, then openarm naming)
|
| 91 |
+
const TIP_LINK_NAMES = [
|
| 92 |
+
"gripper_frame_link", "gripperframe", "gripper_link", "gripper",
|
| 93 |
+
"openarm_left_hand_tcp", "openarm_right_hand_tcp",
|
| 94 |
+
];
|
| 95 |
const TRAIL_DURATION = 1.0; // seconds
|
| 96 |
const TRAIL_COLOR = new THREE.Color("#ff6600");
|
| 97 |
const MAX_TRAIL_POINTS = 300;
|
| 98 |
|
| 99 |
// ─── Robot scene (imperative, inside Canvas) ───
|
| 100 |
function RobotScene({
|
| 101 |
+
urdfUrl, jointValues, onJointsLoaded, trailEnabled, trailResetKey, scale,
|
| 102 |
}: {
|
| 103 |
urdfUrl: string;
|
| 104 |
jointValues: Record<string, number>;
|
| 105 |
onJointsLoaded: (names: string[]) => void;
|
| 106 |
trailEnabled: boolean;
|
| 107 |
trailResetKey: number;
|
| 108 |
+
scale: number;
|
| 109 |
}) {
|
| 110 |
const { scene, size } = useThree();
|
| 111 |
const robotRef = useRef<URDFRobot | null>(null);
|
|
|
|
| 155 |
useEffect(() => {
|
| 156 |
setLoading(true);
|
| 157 |
setError(null);
|
| 158 |
+
const isOpenArm = urdfUrl.includes("openarm");
|
| 159 |
const manager = new THREE.LoadingManager();
|
| 160 |
const loader = new URDFLoader(manager);
|
| 161 |
loader.loadMeshCb = (url, mgr, onLoad) => {
|
| 162 |
+
// DAE (Collada) files — load with embedded materials
|
| 163 |
+
if (url.endsWith(".dae")) {
|
| 164 |
+
const colladaLoader = new ColladaLoader(mgr);
|
| 165 |
+
colladaLoader.load(url, (collada) => onLoad(collada.scene), undefined,
|
| 166 |
+
(err) => onLoad(new THREE.Object3D(), err as Error));
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
// STL files — apply custom materials
|
| 170 |
const stlLoader = new STLLoader(mgr);
|
| 171 |
stlLoader.load(
|
| 172 |
url,
|
| 173 |
(geometry) => {
|
| 174 |
+
let color = "#FFD700";
|
| 175 |
+
let metalness = 0.1;
|
| 176 |
+
let roughness = 0.6;
|
| 177 |
+
if (url.includes("sts3215")) {
|
| 178 |
+
color = "#1a1a1a"; metalness = 0.7; roughness = 0.3;
|
| 179 |
+
} else if (isOpenArm) {
|
| 180 |
+
color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
|
| 181 |
+
metalness = 0.15; roughness = 0.6;
|
| 182 |
+
}
|
| 183 |
+
const material = new THREE.MeshStandardMaterial({ color, metalness, roughness });
|
| 184 |
onLoad(new THREE.Mesh(geometry, material));
|
| 185 |
},
|
| 186 |
undefined,
|
|
|
|
| 194 |
robot.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
| 195 |
robot.traverse((c) => { c.castShadow = true; });
|
| 196 |
robot.updateMatrixWorld(true);
|
| 197 |
+
robot.scale.set(scale, scale, scale);
|
| 198 |
scene.add(robot);
|
| 199 |
|
|
|
|
| 200 |
for (const name of TIP_LINK_NAMES) {
|
| 201 |
if (robot.frames[name]) { tipLinkRef.current = robot.frames[name]; break; }
|
| 202 |
}
|
| 203 |
|
| 204 |
+
const movable = Object.values(robot.joints)
|
| 205 |
+
.filter((j) => j.jointType === "revolute" || j.jointType === "continuous" || j.jointType === "prismatic")
|
| 206 |
.map((j) => j.name);
|
| 207 |
+
onJointsLoaded(movable);
|
| 208 |
setLoading(false);
|
| 209 |
},
|
| 210 |
undefined,
|
|
|
|
| 214 |
if (robotRef.current) { scene.remove(robotRef.current); robotRef.current = null; }
|
| 215 |
tipLinkRef.current = null;
|
| 216 |
};
|
| 217 |
+
}, [urdfUrl, scale, scene, onJointsLoaded]);
|
| 218 |
|
| 219 |
const tipWorldPos = useMemo(() => new THREE.Vector3(), []);
|
| 220 |
|
|
|
|
| 334 |
}) {
|
| 335 |
const { datasetInfo, episodes } = data;
|
| 336 |
const fps = datasetInfo.fps || 30;
|
| 337 |
+
const robotConfig = useMemo(() => getRobotConfig(datasetInfo.robot_type), [datasetInfo.robot_type]);
|
| 338 |
+
const { urdfUrl, scale, isDualArm } = robotConfig;
|
| 339 |
|
| 340 |
// Episode selection & chart data
|
| 341 |
const [selectedEpisode, setSelectedEpisode] = useState(data.episodeId);
|
|
|
|
| 402 |
|
| 403 |
// Trail
|
| 404 |
const [trailEnabled, setTrailEnabled] = useState(true);
|
| 405 |
+
const [showMapping, setShowMapping] = useState(false);
|
| 406 |
|
| 407 |
// Playback
|
| 408 |
const [frame, setFrame] = useState(0);
|
|
|
|
| 415 |
frameRef.current = f;
|
| 416 |
}, []);
|
| 417 |
|
| 418 |
+
// Filter out mimic joints (finger_joint2) from the UI list
|
| 419 |
+
const displayJointNames = useMemo(
|
| 420 |
+
() => urdfJointNames.filter((n) => !n.toLowerCase().includes("finger_joint2")),
|
| 421 |
+
[urdfJointNames],
|
| 422 |
+
);
|
| 423 |
+
|
| 424 |
+
// Auto-detect gripper column range for linear mapping to 0-0.044m
|
| 425 |
+
const gripperRanges = useMemo(() => {
|
| 426 |
+
const ranges: Record<string, { min: number; max: number }> = {};
|
| 427 |
+
for (const jn of urdfJointNames) {
|
| 428 |
+
if (!jn.toLowerCase().includes("finger_joint1")) continue;
|
| 429 |
+
const col = mapping[jn];
|
| 430 |
+
if (!col) continue;
|
| 431 |
+
let min = Infinity, max = -Infinity;
|
| 432 |
+
for (const row of chartData) {
|
| 433 |
+
const v = row[col];
|
| 434 |
+
if (typeof v === "number") { if (v < min) min = v; if (v > max) max = v; }
|
| 435 |
+
}
|
| 436 |
+
if (min < max) ranges[jn] = { min, max };
|
| 437 |
+
}
|
| 438 |
+
return ranges;
|
| 439 |
+
}, [chartData, mapping, urdfJointNames]);
|
| 440 |
+
|
| 441 |
// Compute joint values for current frame
|
| 442 |
const jointValues = useMemo(() => {
|
| 443 |
if (totalFrames === 0 || urdfJointNames.length === 0) return {};
|
| 444 |
const row = chartData[Math.min(frame, totalFrames - 1)];
|
| 445 |
+
const revoluteValues: number[] = [];
|
| 446 |
+
const revoluteNames: string[] = [];
|
| 447 |
+
const values: Record<string, number> = {};
|
| 448 |
|
| 449 |
for (const jn of urdfJointNames) {
|
| 450 |
+
if (jn.toLowerCase().includes("finger_joint2")) continue;
|
| 451 |
const col = mapping[jn];
|
| 452 |
+
if (!col || typeof row[col] !== "number") continue;
|
| 453 |
+
const raw = row[col];
|
| 454 |
+
|
| 455 |
+
if (jn.toLowerCase().includes("finger_joint1")) {
|
| 456 |
+
// Map gripper range → 0-0.044m using auto-detected min/max
|
| 457 |
+
const range = gripperRanges[jn];
|
| 458 |
+
if (range) {
|
| 459 |
+
const t = (raw - range.min) / (range.max - range.min);
|
| 460 |
+
values[jn] = t * 0.044;
|
| 461 |
+
} else {
|
| 462 |
+
values[jn] = (raw / 100) * 0.044; // fallback: assume 0-100
|
| 463 |
+
}
|
| 464 |
+
} else {
|
| 465 |
+
revoluteValues.push(raw);
|
| 466 |
+
revoluteNames.push(jn);
|
| 467 |
}
|
| 468 |
}
|
| 469 |
|
| 470 |
+
const converted = detectAndConvert(revoluteValues);
|
| 471 |
+
revoluteNames.forEach((n, i) => { values[n] = converted[i]; });
|
| 472 |
+
|
| 473 |
+
// Copy finger_joint1 → finger_joint2 (mimic joints)
|
| 474 |
+
for (const jn of urdfJointNames) {
|
| 475 |
+
if (jn.toLowerCase().includes("finger_joint2")) {
|
| 476 |
+
const j1 = jn.replace(/finger_joint2/, "finger_joint1");
|
| 477 |
+
if (values[j1] !== undefined) values[jn] = values[j1];
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
return values;
|
| 481 |
}, [chartData, frame, mapping, totalFrames, urdfJointNames]);
|
| 482 |
|
|
|
|
| 496 |
<span className="text-white text-lg animate-pulse">Loading episode {selectedEpisode}…</span>
|
| 497 |
</div>
|
| 498 |
)}
|
| 499 |
+
<Canvas camera={{ position: [0.3 * scale, 0.25 * scale, 0.3 * scale], fov: 45, near: 0.01, far: 100 }}>
|
| 500 |
+
<ambientLight intensity={0.7} />
|
| 501 |
+
<directionalLight position={[3, 5, 4]} intensity={1.5} />
|
| 502 |
+
<directionalLight position={[-2, 3, -2]} intensity={0.6} />
|
| 503 |
+
<hemisphereLight args={["#b1e1ff", "#666666", 0.5]} />
|
| 504 |
+
<RobotScene urdfUrl={urdfUrl} jointValues={jointValues} onJointsLoaded={onJointsLoaded} trailEnabled={trailEnabled} trailResetKey={selectedEpisode} scale={scale} />
|
| 505 |
<Grid
|
| 506 |
args={[10, 10]} cellSize={0.2} cellThickness={0.5} cellColor="#334155"
|
| 507 |
sectionSize={1} sectionThickness={1} sectionColor="#475569"
|
|
|
|
| 567 |
<span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">F {frame}/{Math.max(totalFrames - 1, 0)}</span>
|
| 568 |
</div>
|
| 569 |
|
| 570 |
+
{/* Collapsible joint mapping */}
|
| 571 |
+
<button
|
| 572 |
+
onClick={() => setShowMapping((v) => !v)}
|
| 573 |
+
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
| 574 |
+
>
|
| 575 |
+
<span className={`transition-transform ${showMapping ? "rotate-90" : ""}`}>▶</span>
|
| 576 |
+
Joint Mapping
|
| 577 |
+
<span className="text-slate-600">({Object.keys(mapping).filter((k) => mapping[k]).length}/{displayJointNames.length} mapped)</span>
|
| 578 |
+
</button>
|
| 579 |
+
|
| 580 |
+
{showMapping && (
|
| 581 |
+
<div className="flex gap-4 items-start">
|
| 582 |
+
<div className="space-y-1 shrink-0">
|
| 583 |
+
<label className="text-xs text-slate-400">Data source</label>
|
| 584 |
+
<div className="flex gap-1 flex-wrap">
|
| 585 |
+
{groupNames.map((name) => (
|
| 586 |
+
<button key={name} onClick={() => setSelectedGroup(name)}
|
| 587 |
+
className={`px-2 py-1 text-xs rounded transition-colors ${
|
| 588 |
+
selectedGroup === name ? "bg-orange-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
| 589 |
+
}`}>{name}</button>
|
| 590 |
+
))}
|
| 591 |
+
</div>
|
| 592 |
</div>
|
|
|
|
| 593 |
|
| 594 |
+
<div className="flex-1 overflow-x-auto max-h-48 overflow-y-auto">
|
| 595 |
+
<table className="w-full text-xs">
|
| 596 |
+
<thead className="sticky top-0 bg-slate-800">
|
| 597 |
+
<tr className="text-slate-500">
|
| 598 |
+
<th className="text-left font-normal px-1">URDF Joint</th>
|
| 599 |
+
<th className="text-left font-normal px-1">→</th>
|
| 600 |
+
<th className="text-left font-normal px-1">Dataset Column</th>
|
| 601 |
+
<th className="text-right font-normal px-1">Value</th>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
</tr>
|
| 603 |
+
</thead>
|
| 604 |
+
<tbody>
|
| 605 |
+
{displayJointNames.map((jointName) => (
|
| 606 |
+
<tr key={jointName} className="border-t border-slate-700/50">
|
| 607 |
+
<td className="px-1 py-0.5 text-slate-300 font-mono">{jointName}</td>
|
| 608 |
+
<td className="px-1 text-slate-600">→</td>
|
| 609 |
+
<td className="px-1 py-0.5">
|
| 610 |
+
<select value={mapping[jointName] ?? ""}
|
| 611 |
+
onChange={(e) => setMapping((m) => ({ ...m, [jointName]: e.target.value }))}
|
| 612 |
+
className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]">
|
| 613 |
+
<option value="">-- unmapped --</option>
|
| 614 |
+
{selectedColumns.map((col) => {
|
| 615 |
+
const label = col.split(SERIES_DELIM).pop() ?? col;
|
| 616 |
+
return <option key={col} value={col}>{label}</option>;
|
| 617 |
+
})}
|
| 618 |
+
</select>
|
| 619 |
+
</td>
|
| 620 |
+
<td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
|
| 621 |
+
{jointValues[jointName] !== undefined ? jointValues[jointName].toFixed(3) : "—"}
|
| 622 |
+
</td>
|
| 623 |
+
</tr>
|
| 624 |
+
))}
|
| 625 |
+
</tbody>
|
| 626 |
+
</table>
|
| 627 |
+
</div>
|
| 628 |
</div>
|
| 629 |
+
)}
|
| 630 |
</div>
|
| 631 |
</div>
|
| 632 |
);
|
src/lib/so101-robot.ts
CHANGED
|
@@ -3,3 +3,12 @@ export function isSO101Robot(robotType: string | null): boolean {
|
|
| 3 |
const lower = robotType.toLowerCase();
|
| 4 |
return lower.includes("so100") || lower.includes("so101") || lower === "so_follower";
|
| 5 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
const lower = robotType.toLowerCase();
|
| 4 |
return lower.includes("so100") || lower.includes("so101") || lower === "so_follower";
|
| 5 |
}
|
| 6 |
+
|
| 7 |
+
export function isOpenArmRobot(robotType: string | null): boolean {
|
| 8 |
+
if (!robotType) return false;
|
| 9 |
+
return robotType.toLowerCase().includes("openarm");
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function hasURDFSupport(robotType: string | null): boolean {
|
| 13 |
+
return isSO101Robot(robotType) || isOpenArmRobot(robotType);
|
| 14 |
+
}
|