pepijn223 HF Staff commited on
Commit
cb1fbc7
·
unverified ·
1 Parent(s): 9333aea

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 { isSO101Robot } from "@/lib/so101-robot";
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
- {isSO101Robot(datasetInfo.robot_type) && (
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 getUrdfUrl(robotType: string | null): string {
21
  const lower = (robotType ?? "").toLowerCase();
22
- if (lower.includes("so100") && !lower.includes("so101")) return "/urdf/so101/so100.urdf";
23
- return "/urdf/so101/so101_new_calib.urdf";
 
 
 
 
 
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
- const exactMatch = columnKeys.find((k) => {
52
- const suffix = (k.split(SERIES_DELIM).pop()?.trim() ?? k).toLowerCase();
53
- return suffix === lower;
54
- });
55
- if (exactMatch) { mapping[jointName] = exactMatch; continue; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 so100 naming)
63
- const TIP_LINK_NAMES = ["gripper_frame_link", "gripperframe", "gripper_link", "gripper"];
 
 
 
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
- const isMotor = url.includes("sts3215");
134
- const material = new THREE.MeshStandardMaterial({
135
- color: isMotor ? "#1a1a1a" : "#FFD700",
136
- metalness: isMotor ? 0.7 : 0.1,
137
- roughness: isMotor ? 0.3 : 0.6,
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(SCALE, SCALE, SCALE);
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 revolute = Object.values(robot.joints)
161
- .filter((j) => j.jointType === "revolute" || j.jointType === "continuous")
162
  .map((j) => j.name);
163
- onJointsLoaded(revolute);
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 urdfUrl = useMemo(() => getUrdfUrl(datasetInfo.robot_type), [datasetInfo.robot_type]);
 
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 rawValues: number[] = [];
377
- const names: string[] = [];
 
378
 
379
  for (const jn of urdfJointNames) {
 
380
  const col = mapping[jn];
381
- if (col && typeof row[col] === "number") {
382
- rawValues.push(row[col]);
383
- names.push(jn);
 
 
 
 
 
 
 
 
 
 
 
 
384
  }
385
  }
386
 
387
- const converted = detectAndConvert(rawValues);
388
- const values: Record<string, number> = {};
389
- names.forEach((n, i) => { values[n] = converted[i]; });
 
 
 
 
 
 
 
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 * SCALE, 0.25 * SCALE, 0.3 * SCALE], fov: 45, near: 0.01, far: 100 }}>
410
- <ambientLight intensity={0.5} />
411
- <directionalLight position={[3, 5, 4]} intensity={1.2} />
412
- <directionalLight position={[-2, 3, -2]} intensity={0.4} />
413
- <hemisphereLight args={["#b1e1ff", "#444444", 0.4]} />
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
- {/* Data source + joint mapping */}
481
- <div className="flex gap-4 items-start">
482
- <div className="space-y-1 shrink-0">
483
- <label className="text-xs text-slate-400">Data source</label>
484
- <div className="flex gap-1 flex-wrap">
485
- {groupNames.map((name) => (
486
- <button key={name} onClick={() => setSelectedGroup(name)}
487
- className={`px-2 py-1 text-xs rounded transition-colors ${
488
- selectedGroup === name ? "bg-orange-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"
489
- }`}>{name}</button>
490
- ))}
 
 
 
 
 
 
 
 
 
 
 
491
  </div>
492
- </div>
493
 
494
- <div className="flex-1 overflow-x-auto">
495
- <table className="w-full text-xs">
496
- <thead>
497
- <tr className="text-slate-500">
498
- <th className="text-left font-normal px-1">URDF Joint</th>
499
- <th className="text-left font-normal px-1">→</th>
500
- <th className="text-left font-normal px-1">Dataset Column</th>
501
- <th className="text-right font-normal px-1">Value (rad)</th>
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
- </tbody>
526
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  </div>
528
- </div>
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
+ }