diff --git a/package.json b/package.json index 9e62ea1..aef6130 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,34 @@ -{ - "name": "mujoco-robot-demo", - "version": "1.0.0", - "private": true, - "description": "Open-source robot teleoperation demo using MuJoCo physics simulation", - "scripts": { - "copy-mujoco": "mkdir -p public/mujoco-js/dist && cp -r node_modules/mujoco-js/dist/* public/mujoco-js/dist/", - "postinstall": "npm run copy-mujoco", - "dev": "next dev --webpack", - "build": "npm run copy-mujoco && next build --webpack", - "start": "next start", - "lint": "eslint" - }, - "dependencies": { - "@types/three": "^0.181.0", - "fflate": "^0.8.2", - "mujoco-js": "^0.0.7", - "next": "^16.1.0", - "react": "19.2.1", - "react-dom": "19.2.1", - "three": "^0.181.1" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "^16.1.0", - "ignore-loader": "^0.1.2", - "tailwindcss": "^4", - "typescript": "^5" - } -} +{ + "name": "mujoco-robot-demo", + "version": "1.0.0", + "private": true, + "description": "Open-source robot teleoperation demo using MuJoCo physics simulation", + "scripts": { + "copy-mujoco": "node scripts/copy-mujoco.mjs", + "postinstall": "npm run copy-mujoco", + "dev": "next dev --webpack", + "build": "npm run copy-mujoco && next build --webpack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@types/three": "^0.181.0", + "fflate": "^0.8.2", + "mujoco-js": "^0.0.7", + "next": "^16.1.0", + "react": "19.2.1", + "react-dom": "19.2.1", + "three": "^0.181.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16.1.0", + "ignore-loader": "^0.1.2", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 4edcfa8..70ea70c 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -1,77 +1,77 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { promises as fs } from 'fs'; -import path from 'path'; - -type Task = { - id: number; - name: string; - description: string; - difficulty_stars: number; - expected_duration: number; - success_rate: number; - thumbnail: string; - time_limit: number | null; - mjcf_xml: string | null; - checker_config: Record | null; - initial_state: Record | null; - steps: { order: number; description: string }[] | null; - rarity: string | null; - base_points: number | null; - category: string | null; -}; - -type TasksData = { - tasks: Task[]; -}; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - const taskId = parseInt(id, 10); - - if (isNaN(taskId)) { - return NextResponse.json( - { error: 'Invalid task ID' }, - { status: 400 } - ); - } - - // Read tasks from local JSON file - const tasksFilePath = path.join(process.cwd(), 'public', 'data', 'tasks.json'); - const tasksFileContent = await fs.readFile(tasksFilePath, 'utf-8'); - const tasksData: TasksData = JSON.parse(tasksFileContent); - - // Find the task by ID - const task = tasksData.tasks.find(t => t.id === taskId); - - if (!task) { - return NextResponse.json( - { error: 'Task not found' }, - { status: 404 } - ); - } - - // If task has mjcf_xml path, read the XML content - if (task.mjcf_xml && !task.mjcf_xml.startsWith('<')) { - try { - const xmlPath = path.join(process.cwd(), 'public', 'mujoco-assets', task.mjcf_xml); - const xmlContent = await fs.readFile(xmlPath, 'utf-8'); - task.mjcf_xml = xmlContent; - } catch (error) { - console.error(`[API] Failed to read XML file for task ${taskId}:`, error); - // Keep the original path if file read fails - } - } - - return NextResponse.json(task); - } catch (error) { - console.error('[API] Error fetching task:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} +import { NextRequest, NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; + +type Task = { + id: number; + name: string; + description: string; + difficulty_stars: number; + expected_duration: number; + success_rate: number; + thumbnail: string; + time_limit: number | null; + mjcf_xml: string | null; + checker_config: Record | null; + initial_state: Record | null; + steps: { order: number; description: string }[] | null; + rarity: string | null; + base_points: number | null; + category: string | null; +}; + +type TasksData = { + tasks: Task[]; +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const taskId = parseInt(id, 10); + + if (isNaN(taskId)) { + return NextResponse.json( + { error: 'Invalid task ID' }, + { status: 400 } + ); + } + + // Read tasks from local JSON file + const tasksFilePath = path.join(process.cwd(), 'public', 'data', 'tasks.json'); + const tasksFileContent = await fs.readFile(tasksFilePath, 'utf-8'); + const tasksData: TasksData = JSON.parse(tasksFileContent); + + // Find the task by ID + const task = tasksData.tasks.find(t => t.id === taskId); + + if (!task) { + return NextResponse.json( + { error: 'Task not found' }, + { status: 404 } + ); + } + + // If task has mjcf_xml path, read the XML content + if (task.mjcf_xml && !task.mjcf_xml.startsWith('<')) { + try { + const xmlPath = path.join(process.cwd(), 'public', 'mujoco-assets', task.mjcf_xml); + const xmlContent = await fs.readFile(xmlPath, 'utf-8'); + task.mjcf_xml = xmlContent; + } catch (error) { + console.error(`[API] Failed to read XML file for task ${taskId}:`, error); + // Keep the original path if file read fails + } + } + + return NextResponse.json(task); + } catch (error) { + console.error('[API] Error fetching task:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/task-running/page.tsx b/src/app/task-running/page.tsx index 5b24c4b..ff5219e 100644 --- a/src/app/task-running/page.tsx +++ b/src/app/task-running/page.tsx @@ -1,588 +1,592 @@ -"use client"; - -import { Suspense, useState, useEffect, useRef, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { useSearchParams } from "next/navigation"; - -import { MuJoCoDemo } from "@/components/mujoco-framework-next/main.js"; -import { getTask, downloadTrajectory, type Task } from "@/lib/local-task-api"; -import { getTaskMetadata } from "@/lib/task-metadata"; - -type MissionStep = { - id: number; - label: string; - completed: boolean; -}; - -type DemoLoadingProgress = { - phase: string; - message?: string; - loaded?: number; - total?: number; - percent?: number; - currentFile?: string; -}; - -function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; -} - -type ControlsPanelItem = { - keys: string[]; - label: string; - description?: string; -}; - -type ControlsPanelSection = { - title: string; - items: ControlsPanelItem[]; -}; - -function humanizeKey(codeOrLabel: string): string { - if (!codeOrLabel) return ""; - const upper = String(codeOrLabel).toUpperCase(); - if (upper === "SPACE") return "SPACE"; - if (upper === "ESC" || upper === "ESCAPE") return "ESC"; - if (codeOrLabel === "ArrowUp") return "↑"; - if (codeOrLabel === "ArrowDown") return "↓"; - if (codeOrLabel === "ArrowLeft") return "←"; - if (codeOrLabel === "ArrowRight") return "→"; - if (codeOrLabel === "Space") return "SPACE"; - if (/^Key[A-Z]$/.test(codeOrLabel)) return codeOrLabel.replace("Key", ""); - if (/^Digit[0-9]$/.test(codeOrLabel)) return codeOrLabel.replace("Digit", ""); - return codeOrLabel; -} - -function getDefaultControlsSections(): ControlsPanelSection[] { - return [ - { - title: "Move gripper", - items: [ - { keys: ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"], label: "Translate (XY)" }, - { keys: ["KeyE", "KeyD"], label: "Up / Down" }, - ], - }, - { - title: "Rotate gripper", - items: [ - { keys: ["KeyQ", "KeyW"], label: "Roll (+ / -)" }, - { keys: ["KeyA", "KeyS"], label: "Pitch (+ / -)" }, - { keys: ["KeyZ", "KeyX"], label: "Yaw (+ / -)" }, - ], - }, - { - title: "Actions", - items: [ - { keys: ["Space"], label: "Toggle gripper" }, - { keys: ["KeyR"], label: "Reset episode" }, - ], - }, - { - title: "Camera", - items: [ - { keys: ["Mouse"], label: "Left drag: orbit · Right drag: pan · Scroll: zoom" }, - ], - }, - ]; -} - -function TaskRunningPageContent() { - const searchParams = useSearchParams(); - const router = useRouter(); - const idParam = searchParams?.get("id"); - const taskId = Number(idParam); - - const [task, setTask] = useState(null); - const [status, setStatus] = useState({ - time: 0, - trajectoryCount: 0, - isSuccess: false, - currentTrajectory: { - recording: false, - samples: 0, - durationMs: 0, - durationSec: "0.0", - }, - completedTrajectories: 0, - trajectorySteps: [] as any[], - }); - const [isCompleted, setIsCompleted] = useState(false); - const [finalStatus, setFinalStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showSuccessDialog, setShowSuccessDialog] = useState(false); - const [fps, setFps] = useState(60); - const [missionSteps, setMissionSteps] = useState([]); - const [demoLoading, setDemoLoading] = useState(false); - const [demoLoadingProgress, setDemoLoadingProgress] = useState(null); - const [lastTrajectory, setLastTrajectory] = useState(null); - - const containerRef = useRef(null); - const demoRef = useRef(null); - const taskMetadata = useMemo(() => getTaskMetadata(taskId), [taskId]); - - const displayStatus = isCompleted && finalStatus ? finalStatus : status; - const goalAchieved = displayStatus.isSuccess; - const simulationTime = displayStatus.time; - const timerSeconds = Math.floor(simulationTime / 1000); - const controlsPanelSections = getDefaultControlsSections(); - - // Fetch task data - useEffect(() => { - if (!idParam || Number.isNaN(taskId) || taskId <= 0) { - setError("Invalid task ID"); - setLoading(false); - return; - } - - getTask(taskId) - .then((taskData) => { - if (!taskData) { - setError("Task not found"); - return; - } - - setTask(taskData); - - // Initialize mission steps from task steps - if (taskData.steps && taskData.steps.length > 0) { - const sortedSteps = [...taskData.steps].sort((a, b) => a.order - b.order); - setMissionSteps( - sortedSteps.map((step) => ({ - id: step.order, - label: step.description, - completed: false, - })) - ); - } else { - setMissionSteps([]); - } - - setLoading(false); - }) - .catch((err) => { - setError(err instanceof Error ? err.message : "Failed to load task"); - setLoading(false); - }); - }, [idParam, taskId]); - - // Initialize MuJoCo demo - useEffect(() => { - if (!task || isCompleted) return; - if (!containerRef.current) return; - - let cancelled = false; - const parentElement = containerRef.current; - let demo: MuJoCoDemo | null = null; - - demo = new MuJoCoDemo({ - parentElement, - checkerConfig: task.checker_config || null, - initialState: task.initial_state || null, - domainRandomization: taskMetadata?.domain_randomization || null, - onLoadingProgress: (p: DemoLoadingProgress) => { - if (cancelled) return; - setDemoLoadingProgress(p); - if (p?.phase === "ready") { - setDemoLoading(false); - } else if (p?.phase === "error") { - setDemoLoading(false); - } else { - setDemoLoading(true); - } - }, - }); - - demo.onStatusUpdate = (statusData: any) => { - if (!cancelled && !isCompleted) { - setStatus(statusData); - if (statusData.fps) { - setFps(Math.round(statusData.fps)); - } - } - }; - - demo.onTrajectoryExport = (trajectory: any[], metadata: any) => { - if (cancelled) return; - - setStatus((currentStatus) => { - setIsCompleted(true); - setFinalStatus(currentStatus); - return currentStatus; - }); - - // Store trajectory for download - setLastTrajectory({ - trajectory, - metadata, - exportedAt: new Date().toISOString(), - }); - - setShowSuccessDialog(true); - }; - - demoRef.current = demo; - - (async () => { - try { - setDemoLoading(true); - setDemoLoadingProgress({ phase: "init", message: "Initializing simulation…", percent: 0 }); - await demo!.init(); - } catch (error) { - if (!cancelled) { - console.error("[task-running] Failed to init MuJoCoDemo:", error); - setError("Failed to initialize simulation"); - setDemoLoading(false); - } - } - })(); - - return () => { - cancelled = true; - if (!demo) return; - - if (demo.stopStatusBroadcasting) { - demo.stopStatusBroadcasting(); - } - - if ((demo as any).resizeObserver) { - (demo as any).resizeObserver.disconnect(); - } - - try { - if ((demo as any).renderer?.setAnimationLoop) { - (demo as any).renderer.setAnimationLoop(null); - } - if ((demo as any).container?.parentElement) { - (demo as any).container.parentElement.removeChild((demo as any).container); - } - } catch (e) { - console.warn("[task-running] Cleanup warning:", e); - } - - demoRef.current = null; - }; - }, [task, isCompleted, taskMetadata]); - - // Update mission steps on success - useEffect(() => { - if (goalAchieved) { - setMissionSteps((steps) => - steps.map((step) => ({ ...step, completed: true })) - ); - } - }, [goalAchieved]); - - // Handle timeout - useEffect(() => { - if (!task?.time_limit || isCompleted || goalAchieved) return; - - if (timerSeconds >= task.time_limit) { - console.log("[task-running] Time limit reached!"); - setIsCompleted(true); - setFinalStatus(status); - } - }, [timerSeconds, task?.time_limit, isCompleted, goalAchieved, status]); - - const handleDownloadTrajectory = () => { - if (lastTrajectory) { - downloadTrajectory(lastTrajectory, `trajectory_task_${taskId}_${Date.now()}.json`); - } - }; - - const handleReset = () => { - setIsCompleted(false); - setFinalStatus(null); - setShowSuccessDialog(false); - setLastTrajectory(null); - setMissionSteps((steps) => steps.map((s) => ({ ...s, completed: false }))); - - if (demoRef.current) { - demoRef.current.reset(); - } - }; - - if (loading) { - return ( -
-
Loading...
-
- ); - } - - if (error || !task) { - return ( -
-
-
{error || "Task not found"}
- -
-
- ); - } - - const progressPercent = - typeof demoLoadingProgress?.percent === "number" - ? Math.max(0, Math.min(100, demoLoadingProgress.percent)) - : typeof demoLoadingProgress?.loaded === "number" && - typeof demoLoadingProgress?.total === "number" && - demoLoadingProgress.total > 0 - ? Math.max(0, Math.min(100, Math.round((demoLoadingProgress.loaded / demoLoadingProgress.total) * 100))) - : null; - - return ( - <> - {/* Loading Overlay */} - {demoLoading && ( -
-
-
-
- Loading MuJoCo Demo -
-
- {demoLoadingProgress?.message || "Loading…"} -
- - {demoLoadingProgress?.currentFile && ( -
- {demoLoadingProgress.currentFile} -
- )} - -
- {progressPercent === null ? ( -
- ) : ( -
- )} -
- -
-
- {typeof demoLoadingProgress?.loaded === "number" && - typeof demoLoadingProgress?.total === "number" - ? `${demoLoadingProgress.loaded}/${demoLoadingProgress.total}` - : demoLoadingProgress?.phase || "loading"} -
-
- {progressPercent === null ? "…" : `${progressPercent}%`} -
-
-
-
- )} - - {/* Success Dialog */} - {showSuccessDialog && ( -
setShowSuccessDialog(false)} - > -
-
e.stopPropagation()} - > -

- 🎉 Task Completed! -

-

- Time: {formatTime(timerSeconds)} -

-

- Trajectory recorded with {lastTrajectory?.trajectory?.length || 0} samples -

- -
- - - -
-
-
- )} - -
- {/* MuJoCo Canvas - Full Background */} -
-
- - {/* Header */} -
-
-
- Task {task.id}: {task.name} -
-
-
-
- {formatTime(timerSeconds)} -
- {task.time_limit != null && ( -
- Limit: {formatTime(task.time_limit)} - {timerSeconds >= task.time_limit && ( - TIME UP - )} -
- )} -
-
-
FPS: {fps}
- -
-
- - {/* Controls Panel - Top Left */} -
-
- Controls -
-
-
- {controlsPanelSections.map((section) => ( -
-
- {section.title} -
-
- {section.items.map((item) => ( -
-
- {item.keys.map((k) => ( - - {humanizeKey(k)} - - ))} -
-
-
{item.label}
-
-
- ))} -
-
- ))} -
-
- - {/* Sidebar - Top Right */} -
- {/* Mission Steps */} -
-

- Mission Steps -

-
    - {missionSteps.length > 0 ? ( - missionSteps.map((step) => ( -
  • - {step.completed ? ( - - ) : ( - {step.id}. - )} - - {step.label} - -
  • - )) - ) : ( -
  • No steps defined
  • - )} -
-
- - {/* Status */} -
-

- Status -

-
-
- Recording: - {displayStatus.currentTrajectory.recording ? "Yes" : "No"} -
-
- Samples: - {displayStatus.currentTrajectory.samples} -
-
- Goal: - - {goalAchieved ? "✓ Achieved" : "In Progress"} - -
-
-
-
-
- - ); -} - -export default function TaskRunningPage() { - return ( - Loading...
}> - - - ); -} +"use client"; + +import { Suspense, useState, useEffect, useRef, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useSearchParams } from "next/navigation"; + +import { MuJoCoDemo } from "@/components/mujoco-framework-next/main.js"; +import { getTask, downloadTrajectory, type Task } from "@/lib/local-task-api"; +import { getTaskMetadata } from "@/lib/task-metadata"; + +type MissionStep = { + id: number; + label: string; + completed: boolean; +}; + +type DemoStatus = { isSuccess: boolean; time: number; fps?: number; [key: string]: unknown }; +type ExportedTrajectory = { trajectory: unknown[]; metadata: Record; exportedAt: string }; +type DemoInternals = { resizeObserver?: ResizeObserver; renderer?: { setAnimationLoop?: (callback: (() => void) | null) => void }; container?: HTMLElement }; + +type DemoLoadingProgress = { + phase: string; + message?: string; + loaded?: number; + total?: number; + percent?: number; + currentFile?: string; +}; + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; +} + +type ControlsPanelItem = { + keys: string[]; + label: string; + description?: string; +}; + +type ControlsPanelSection = { + title: string; + items: ControlsPanelItem[]; +}; + +function humanizeKey(codeOrLabel: string): string { + if (!codeOrLabel) return ""; + const upper = String(codeOrLabel).toUpperCase(); + if (upper === "SPACE") return "SPACE"; + if (upper === "ESC" || upper === "ESCAPE") return "ESC"; + if (codeOrLabel === "ArrowUp") return "↑"; + if (codeOrLabel === "ArrowDown") return "↓"; + if (codeOrLabel === "ArrowLeft") return "←"; + if (codeOrLabel === "ArrowRight") return "→"; + if (codeOrLabel === "Space") return "SPACE"; + if (/^Key[A-Z]$/.test(codeOrLabel)) return codeOrLabel.replace("Key", ""); + if (/^Digit[0-9]$/.test(codeOrLabel)) return codeOrLabel.replace("Digit", ""); + return codeOrLabel; +} + +function getDefaultControlsSections(): ControlsPanelSection[] { + return [ + { + title: "Move gripper", + items: [ + { keys: ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"], label: "Translate (XY)" }, + { keys: ["KeyE", "KeyD"], label: "Up / Down" }, + ], + }, + { + title: "Rotate gripper", + items: [ + { keys: ["KeyQ", "KeyW"], label: "Roll (+ / -)" }, + { keys: ["KeyA", "KeyS"], label: "Pitch (+ / -)" }, + { keys: ["KeyZ", "KeyX"], label: "Yaw (+ / -)" }, + ], + }, + { + title: "Actions", + items: [ + { keys: ["Space"], label: "Toggle gripper" }, + { keys: ["KeyR"], label: "Reset episode" }, + ], + }, + { + title: "Camera", + items: [ + { keys: ["Mouse"], label: "Left drag: orbit · Right drag: pan · Scroll: zoom" }, + ], + }, + ]; +} + +function TaskRunningPageContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const idParam = searchParams?.get("id"); + const taskId = Number(idParam); + + const [task, setTask] = useState(null); + const [status, setStatus] = useState({ + time: 0, + trajectoryCount: 0, + isSuccess: false, + currentTrajectory: { + recording: false, + samples: 0, + durationMs: 0, + durationSec: "0.0", + }, + completedTrajectories: 0, + trajectorySteps: [] as unknown[], + }); + const [isCompleted, setIsCompleted] = useState(false); + const [finalStatus, setFinalStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [fps, setFps] = useState(60); + const [missionSteps, setMissionSteps] = useState([]); + const [demoLoading, setDemoLoading] = useState(false); + const [demoLoadingProgress, setDemoLoadingProgress] = useState(null); + const [lastTrajectory, setLastTrajectory] = useState(null); + + const containerRef = useRef(null); + const demoRef = useRef(null); + const taskMetadata = useMemo(() => getTaskMetadata(taskId), [taskId]); + + const displayStatus = isCompleted && finalStatus ? finalStatus : status; + const goalAchieved = displayStatus.isSuccess; + const simulationTime = displayStatus.time; + const timerSeconds = Math.floor(simulationTime / 1000); + const controlsPanelSections = getDefaultControlsSections(); + + // Fetch task data + useEffect(() => { + if (!idParam || Number.isNaN(taskId) || taskId <= 0) { + setError("Invalid task ID"); + setLoading(false); + return; + } + + getTask(taskId) + .then((taskData) => { + if (!taskData) { + setError("Task not found"); + return; + } + + setTask(taskData); + + // Initialize mission steps from task steps + if (taskData.steps && taskData.steps.length > 0) { + const sortedSteps = [...taskData.steps].sort((a, b) => a.order - b.order); + setMissionSteps( + sortedSteps.map((step) => ({ + id: step.order, + label: step.description, + completed: false, + })) + ); + } else { + setMissionSteps([]); + } + + setLoading(false); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Failed to load task"); + setLoading(false); + }); + }, [idParam, taskId]); + + // Initialize MuJoCo demo + useEffect(() => { + if (!task || isCompleted) return; + if (!containerRef.current) return; + + let cancelled = false; + const parentElement = containerRef.current; + let demo: MuJoCoDemo | null = null; + + demo = new MuJoCoDemo({ + parentElement, + checkerConfig: task.checker_config || null, + initialState: task.initial_state || null, + domainRandomization: taskMetadata?.domain_randomization || null, + onLoadingProgress: (p: DemoLoadingProgress) => { + if (cancelled) return; + setDemoLoadingProgress(p); + if (p?.phase === "ready") { + setDemoLoading(false); + } else if (p?.phase === "error") { + setDemoLoading(false); + } else { + setDemoLoading(true); + } + }, + }); + + demo.onStatusUpdate = (statusData: DemoStatus) => { + if (!cancelled && !isCompleted) { + setStatus(statusData); + if (statusData.fps) { + setFps(Math.round(statusData.fps)); + } + } + }; + + demo.onTrajectoryExport = (trajectory: unknown[], metadata: Record) => { + if (cancelled) return; + + setStatus((currentStatus) => { + setIsCompleted(true); + setFinalStatus(currentStatus); + return currentStatus; + }); + + // Store trajectory for download + setLastTrajectory({ + trajectory, + metadata, + exportedAt: new Date().toISOString(), + }); + + setShowSuccessDialog(true); + }; + + demoRef.current = demo; + + (async () => { + try { + setDemoLoading(true); + setDemoLoadingProgress({ phase: "init", message: "Initializing simulation…", percent: 0 }); + await demo!.init(); + } catch (error) { + if (!cancelled) { + console.error("[task-running] Failed to init MuJoCoDemo:", error); + setError("Failed to initialize simulation"); + setDemoLoading(false); + } + } + })(); + + return () => { + cancelled = true; + if (!demo) return; + + if (demo.stopStatusBroadcasting) { + demo.stopStatusBroadcasting(); + } + + if ((demo as MuJoCoDemo & DemoInternals).resizeObserver) { + (demo as MuJoCoDemo & DemoInternals).resizeObserver.disconnect(); + } + + try { + if ((demo as MuJoCoDemo & DemoInternals).renderer?.setAnimationLoop) { + (demo as MuJoCoDemo & DemoInternals).renderer.setAnimationLoop(null); + } + if ((demo as MuJoCoDemo & DemoInternals).container?.parentElement) { + (demo as MuJoCoDemo & DemoInternals).container.parentElement.removeChild((demo as MuJoCoDemo & DemoInternals).container); + } + } catch (e) { + console.warn("[task-running] Cleanup warning:", e); + } + + demoRef.current = null; + }; + }, [task, isCompleted, taskMetadata]); + + // Update mission steps on success + useEffect(() => { + if (goalAchieved) { + setMissionSteps((steps) => + steps.map((step) => ({ ...step, completed: true })) + ); + } + }, [goalAchieved]); + + // Handle timeout + useEffect(() => { + if (!task?.time_limit || isCompleted || goalAchieved) return; + + if (timerSeconds >= task.time_limit) { + console.log("[task-running] Time limit reached!"); + setIsCompleted(true); + setFinalStatus(status); + } + }, [timerSeconds, task?.time_limit, isCompleted, goalAchieved, status]); + + const handleDownloadTrajectory = () => { + if (lastTrajectory) { + downloadTrajectory(lastTrajectory, `trajectory_task_${taskId}_${Date.now()}.json`); + } + }; + + const handleReset = () => { + setIsCompleted(false); + setFinalStatus(null); + setShowSuccessDialog(false); + setLastTrajectory(null); + setMissionSteps((steps) => steps.map((s) => ({ ...s, completed: false }))); + + if (demoRef.current) { + demoRef.current.reset(); + } + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (error || !task) { + return ( +
+
+
{error || "Task not found"}
+ +
+
+ ); + } + + const progressPercent = + typeof demoLoadingProgress?.percent === "number" + ? Math.max(0, Math.min(100, demoLoadingProgress.percent)) + : typeof demoLoadingProgress?.loaded === "number" && + typeof demoLoadingProgress?.total === "number" && + demoLoadingProgress.total > 0 + ? Math.max(0, Math.min(100, Math.round((demoLoadingProgress.loaded / demoLoadingProgress.total) * 100))) + : null; + + return ( + <> + {/* Loading Overlay */} + {demoLoading && ( +
+
+
+
+ Loading MuJoCo Demo +
+
+ {demoLoadingProgress?.message || "Loading…"} +
+ + {demoLoadingProgress?.currentFile && ( +
+ {demoLoadingProgress.currentFile} +
+ )} + +
+ {progressPercent === null ? ( +
+ ) : ( +
+ )} +
+ +
+
+ {typeof demoLoadingProgress?.loaded === "number" && + typeof demoLoadingProgress?.total === "number" + ? `${demoLoadingProgress.loaded}/${demoLoadingProgress.total}` + : demoLoadingProgress?.phase || "loading"} +
+
+ {progressPercent === null ? "…" : `${progressPercent}%`} +
+
+
+
+ )} + + {/* Success Dialog */} + {showSuccessDialog && ( +
setShowSuccessDialog(false)} + > +
+
e.stopPropagation()} + > +

+ 🎉 Task Completed! +

+

+ Time: {formatTime(timerSeconds)} +

+

+ Trajectory recorded with {lastTrajectory?.trajectory?.length || 0} samples +

+ +
+ + + +
+
+
+ )} + +
+ {/* MuJoCo Canvas - Full Background */} +
+
+ + {/* Header */} +
+
+
+ Task {task.id}: {task.name} +
+
+
+
+ {formatTime(timerSeconds)} +
+ {task.time_limit != null && ( +
+ Limit: {formatTime(task.time_limit)} + {timerSeconds >= task.time_limit && ( + TIME UP + )} +
+ )} +
+
+
FPS: {fps}
+ +
+
+ + {/* Controls Panel - Top Left */} +
+
+ Controls +
+
+
+ {controlsPanelSections.map((section) => ( +
+
+ {section.title} +
+
+ {section.items.map((item) => ( +
+
+ {item.keys.map((k) => ( + + {humanizeKey(k)} + + ))} +
+
+
{item.label}
+
+
+ ))} +
+
+ ))} +
+
+ + {/* Sidebar - Top Right */} +
+ {/* Mission Steps */} +
+

+ Mission Steps +

+
    + {missionSteps.length > 0 ? ( + missionSteps.map((step) => ( +
  • + {step.completed ? ( + + ) : ( + {step.id}. + )} + + {step.label} + +
  • + )) + ) : ( +
  • No steps defined
  • + )} +
+
+ + {/* Status */} +
+

+ Status +

+
+
+ Recording: + {displayStatus.currentTrajectory.recording ? "Yes" : "No"} +
+
+ Samples: + {displayStatus.currentTrajectory.samples} +
+
+ Goal: + + {goalAchieved ? "✓ Achieved" : "In Progress"} + +
+
+
+
+
+ + ); +} + +export default function TaskRunningPage() { + return ( + Loading...
}> + + + ); +} diff --git a/src/lib/local-task-api.ts b/src/lib/local-task-api.ts index 16ddd43..d4a4a12 100644 --- a/src/lib/local-task-api.ts +++ b/src/lib/local-task-api.ts @@ -1,118 +1,118 @@ -/** - * Local Task API - 本地任务数据接口 - * 用于开源精简版,从本地 JSON 文件读取任务数据 - */ - -export type TaskStep = { - order: number; - description: string; -}; - -export type Task = { - id: number; - name: string; - description: string; - difficulty_stars: number; - expected_duration: number; - success_rate: number; - thumbnail: string; - time_limit: number | null; - mjcf_xml: string | null; - checker_config: Record | null; - initial_state: Record | null; - steps: TaskStep[] | null; - rarity: string | null; - base_points: number | null; - category: string | null; -}; - -type TasksData = { - tasks: Task[]; -}; - -let cachedTasks: Task[] | null = null; - -/** - * 获取所有任务列表 - */ -export async function getTasks(): Promise { - if (cachedTasks) { - return cachedTasks; - } - - try { - const response = await fetch('/data/tasks.json'); - if (!response.ok) { - throw new Error(`Failed to load tasks: ${response.status}`); - } - const data: TasksData = await response.json(); - cachedTasks = data.tasks; - return data.tasks; - } catch (error) { - console.error('[local-task-api] Failed to load tasks:', error); - return []; - } -} - -/** - * 根据 ID 获取单个任务 - */ -export async function getTask(id: number): Promise { - const tasks = await getTasks(); - return tasks.find(t => t.id === id) || null; -} - -/** - * 下载轨迹数据为 JSON 文件 - */ -export function downloadTrajectory(trajectory: any, filename?: string): void { - const blob = new Blob([JSON.stringify(trajectory, null, 2)], { - type: 'application/json' - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename || `trajectory_${Date.now()}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -/** - * 保存轨迹到本地存储(可选) - */ -export function saveTrajectoryToLocal(taskId: number, trajectory: any): void { - try { - const key = `trajectory_task_${taskId}_${Date.now()}`; - localStorage.setItem(key, JSON.stringify(trajectory)); - console.log(`[local-task-api] Trajectory saved to localStorage: ${key}`); - } catch (error) { - console.error('[local-task-api] Failed to save trajectory to localStorage:', error); - } -} - -/** - * 获取本地存储的轨迹列表 - */ -export function getLocalTrajectories(taskId?: number): { key: string; data: any }[] { - const trajectories: { key: string; data: any }[] = []; - - try { - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith('trajectory_task_')) { - if (taskId === undefined || key.includes(`trajectory_task_${taskId}_`)) { - const data = localStorage.getItem(key); - if (data) { - trajectories.push({ key, data: JSON.parse(data) }); - } - } - } - } - } catch (error) { - console.error('[local-task-api] Failed to load trajectories from localStorage:', error); - } - - return trajectories; -} +/** + * Local Task API - 本地任务数据接口 + * 用于开源精简版,从本地 JSON 文件读取任务数据 + */ + +export type TaskStep = { + order: number; + description: string; +}; + +export type Task = { + id: number; + name: string; + description: string; + difficulty_stars: number; + expected_duration: number; + success_rate: number; + thumbnail: string; + time_limit: number | null; + mjcf_xml: string | null; + checker_config: Record | null; + initial_state: Record | null; + steps: TaskStep[] | null; + rarity: string | null; + base_points: number | null; + category: string | null; +}; + +type TasksData = { + tasks: Task[]; +}; + +let cachedTasks: Task[] | null = null; + +/** + * 获取所有任务列表 + */ +export async function getTasks(): Promise { + if (cachedTasks) { + return cachedTasks; + } + + try { + const response = await fetch('/data/tasks.json'); + if (!response.ok) { + throw new Error(`Failed to load tasks: ${response.status}`); + } + const data: TasksData = await response.json(); + cachedTasks = data.tasks; + return data.tasks; + } catch (error) { + console.error('[local-task-api] Failed to load tasks:', error); + return []; + } +} + +/** + * 根据 ID 获取单个任务 + */ +export async function getTask(id: number): Promise { + const tasks = await getTasks(); + return tasks.find(t => t.id === id) || null; +} + +/** + * 下载轨迹数据为 JSON 文件 + */ +export function downloadTrajectory(trajectory: unknown, filename?: string): void { + const blob = new Blob([JSON.stringify(trajectory, null, 2)], { + type: 'application/json' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || `trajectory_${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * 保存轨迹到本地存储(可选) + */ +export function saveTrajectoryToLocal(taskId: number, trajectory: unknown): void { + try { + const key = `trajectory_task_${taskId}_${Date.now()}`; + localStorage.setItem(key, JSON.stringify(trajectory)); + console.log(`[local-task-api] Trajectory saved to localStorage: ${key}`); + } catch (error) { + console.error('[local-task-api] Failed to save trajectory to localStorage:', error); + } +} + +/** + * 获取本地存储的轨迹列表 + */ +export function getLocalTrajectories(taskId?: number): { key: string; data: unknown }[] { + const trajectories: { key: string; data: unknown }[] = []; + + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('trajectory_task_')) { + if (taskId === undefined || key.includes(`trajectory_task_${taskId}_`)) { + const data = localStorage.getItem(key); + if (data) { + trajectories.push({ key, data: JSON.parse(data) }); + } + } + } + } + } catch (error) { + console.error('[local-task-api] Failed to load trajectories from localStorage:', error); + } + + return trajectories; +} diff --git a/src/lib/mujoco-asset-loader.ts b/src/lib/mujoco-asset-loader.ts index cff4cf3..da92832 100644 --- a/src/lib/mujoco-asset-loader.ts +++ b/src/lib/mujoco-asset-loader.ts @@ -1,236 +1,238 @@ -/** - * Utility functions for loading MuJoCo assets (textures, meshes) into the virtual file system - */ -import { unzipSync } from "fflate"; - -type AssetZipIndex = Map; - -const zipIndexCache = new Map(); - -function normalizeZipPath(path: string): string { - const raw = path.replace(/\\/g, "/").replace(/^\.?\//, ""); - const parts = raw.split("/").filter((part) => part.length > 0); - const stack: string[] = []; - for (const part of parts) { - if (part === ".") { - continue; - } - if (part === "..") { - stack.pop(); - continue; - } - stack.push(part); - } - return stack.join("/"); -} - -function buildZipCandidates(assetPath: string): string[] { - const normalized = normalizeZipPath(assetPath); - const withoutUnderscorePrefix = normalized.startsWith("mujoco_assets/") - ? normalized.slice("mujoco_assets/".length) - : normalized; - const withoutHyphenPrefix = normalized.startsWith("mujoco-assets/") - ? normalized.slice("mujoco-assets/".length) - : normalized; - const candidates = new Set(); - - candidates.add(normalized); - candidates.add(withoutUnderscorePrefix); - candidates.add(withoutHyphenPrefix); - candidates.add(`mujoco-assets/${withoutUnderscorePrefix}`); - candidates.add(`mujoco_assets/${withoutUnderscorePrefix}`); - - return Array.from(candidates).filter(Boolean); -} - -async function getZipIndex(zipUrl: string): Promise { - const cached = zipIndexCache.get(zipUrl); - if (cached) { - return cached; - } - - const response = await fetch(zipUrl); - if (!response.ok) { - throw new Error(`Failed to fetch asset zip ${zipUrl}: ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - const entries = unzipSync(new Uint8Array(arrayBuffer)); - const index = new Map(); - - for (const [name, data] of Object.entries(entries)) { - index.set(normalizeZipPath(name), data); - } - - zipIndexCache.set(zipUrl, index); - return index; -} - -/** - * Load a file from a URL and write it to MuJoCo's virtual file system - */ -export async function loadAssetToVFS( - mujoco: any, - url: string, - vfsPath: string -): Promise { - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.statusText}`); - } - - // For binary files (meshes, images), use arrayBuffer - const isBinary = url.endsWith('.msh') || url.endsWith('.obj') || url.endsWith('.stl') || url.endsWith('.png') || url.endsWith('.jpg'); - - if (isBinary) { - const arrayBuffer = await response.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - mujoco.FS.writeFile(vfsPath, uint8Array); - } else { - // For text files - const text = await response.text(); - mujoco.FS.writeFile(vfsPath, text); - } - } catch (error) { - console.error(`Error loading asset ${url} to ${vfsPath}:`, error); - throw error; - } -} - -/** - * Parse XML to extract asset file paths (textures and meshes) - */ -export function extractAssetPaths(xml: string): { textures: string[]; meshes: string[] } { - const textures: string[] = []; - const meshes: string[] = []; - - // Extract texture file paths - const textureRegex = /]*file=["']([^"']+)["'][^>]*>/gi; - let match; - while ((match = textureRegex.exec(xml)) !== null) { - textures.push(match[1]); - } - - // Extract mesh file paths - const meshRegex = /]*file=["']([^"']+)["'][^>]*>/gi; - while ((match = meshRegex.exec(xml)) !== null) { - meshes.push(match[1]); - } - - return { textures, meshes }; -} - -/** - * Load all assets referenced in the XML into the virtual file system - * Assets are loaded from the frontend public directory /mujoco-assets/ - */ -export async function loadMuJoCoAssets( - mujoco: any, - xml: string, - baseUrlOrOptions?: string | { baseUrl?: string; zipUrl?: string; zipOnly?: boolean } -): Promise { - // Use API base URL if not provided - let apiBase = `/mujoco-assets/`; - let zipUrl: string | undefined; - let zipOnly = false; - if (typeof baseUrlOrOptions === "string") { - apiBase = baseUrlOrOptions; - } else if (baseUrlOrOptions) { - apiBase = baseUrlOrOptions.baseUrl || apiBase; - zipUrl = baseUrlOrOptions.zipUrl; - zipOnly = Boolean(baseUrlOrOptions.zipOnly); - } - const { textures, meshes } = extractAssetPaths(xml); - const allAssets = [...textures, ...meshes]; - - // Remove duplicates - const uniqueAssets = Array.from(new Set(allAssets)); - - let zipIndex: AssetZipIndex | null = null; - if (zipUrl) { - try { - zipIndex = await getZipIndex(zipUrl); - } catch (error) { - console.warn(`⚠ Failed to load asset zip ${zipUrl}, falling back to individual fetches.`, error); - } - } - if (zipOnly && !zipIndex) { - throw new Error(`zipOnly is enabled but asset zip could not be loaded: ${zipUrl || "(missing url)"}`); - } - - // Create necessary directories and load each asset - for (const assetPath of uniqueAssets) { - // Strip directory prefix from API path (backend already knows this directory) - // But keep it for VFS path (MuJoCo needs the full path as specified in XML) - let apiPath = assetPath; - if (assetPath.startsWith('mujoco_assets/')) { - apiPath = assetPath.substring('mujoco_assets/'.length); - } - - // Create directory structure in VFS - const pathParts = assetPath.split('/'); - const fileName = pathParts.pop()!; - const dirPath = pathParts.join('/'); - - if (dirPath) { - // Create directory path in VFS (relative to /working) - const vfsDirPath = `/working/${dirPath}`; - try { - // Create parent directories recursively - const parts = dirPath.split('/'); - let currentPath = '/working'; - for (const part of parts) { - currentPath = `${currentPath}/${part}`; - try { - mujoco.FS.mkdir(currentPath); - } catch (e: any) { - if (!e.message?.includes('File exists')) { - throw e; - } - } - } - } catch (e: any) { - console.warn(`Could not create directory ${vfsDirPath}:`, e); - } - } - - // Load asset from backend (use apiPath without mujoco_assets/ prefix) - const assetUrl = `${apiBase}/${apiPath}`; - // VFS path keeps the full path as specified in XML - const vfsPath = `/working/${assetPath}`; - - try { - let loadedFromZip = false; - if (zipIndex) { - const candidates = buildZipCandidates(assetPath); - for (const candidate of candidates) { - const entry = zipIndex.get(candidate); - if (entry) { - mujoco.FS.writeFile(vfsPath, entry); - loadedFromZip = true; - break; - } - } - } - - if (!loadedFromZip) { - if (zipOnly) { - throw new Error(`Asset not found in zip: ${assetPath}`); - } - await loadAssetToVFS(mujoco, assetUrl, vfsPath); - } - - // Verify the file was written to VFS - try { - const stats = mujoco.FS.stat(vfsPath); - // console.log(`✓ Loaded asset: ${assetPath} (API: ${apiPath}) -> VFS: ${vfsPath} (${stats.size} bytes)`); - } catch (statError) { - console.warn(`⚠ Asset loaded but cannot verify in VFS: ${vfsPath}`, statError); - } - } catch (error) { - console.warn(`⚠ Failed to load asset ${assetPath} (API: ${apiPath}), continuing...`, error); - // Continue loading other assets even if one fails - } - } -} +type MujocoRuntime = { FS: { writeFile: (path: string, data: string | Uint8Array) => void; mkdir: (path: string) => void } }; + +/** + * Utility functions for loading MuJoCo assets (textures, meshes) into the virtual file system + */ +import { unzipSync } from "fflate"; + +type AssetZipIndex = Map; + +const zipIndexCache = new Map(); + +function normalizeZipPath(path: string): string { + const raw = path.replace(/\\/g, "/").replace(/^\.?\//, ""); + const parts = raw.split("/").filter((part) => part.length > 0); + const stack: string[] = []; + for (const part of parts) { + if (part === ".") { + continue; + } + if (part === "..") { + stack.pop(); + continue; + } + stack.push(part); + } + return stack.join("/"); +} + +function buildZipCandidates(assetPath: string): string[] { + const normalized = normalizeZipPath(assetPath); + const withoutUnderscorePrefix = normalized.startsWith("mujoco_assets/") + ? normalized.slice("mujoco_assets/".length) + : normalized; + const withoutHyphenPrefix = normalized.startsWith("mujoco-assets/") + ? normalized.slice("mujoco-assets/".length) + : normalized; + const candidates = new Set(); + + candidates.add(normalized); + candidates.add(withoutUnderscorePrefix); + candidates.add(withoutHyphenPrefix); + candidates.add(`mujoco-assets/${withoutUnderscorePrefix}`); + candidates.add(`mujoco_assets/${withoutUnderscorePrefix}`); + + return Array.from(candidates).filter(Boolean); +} + +async function getZipIndex(zipUrl: string): Promise { + const cached = zipIndexCache.get(zipUrl); + if (cached) { + return cached; + } + + const response = await fetch(zipUrl); + if (!response.ok) { + throw new Error(`Failed to fetch asset zip ${zipUrl}: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const entries = unzipSync(new Uint8Array(arrayBuffer)); + const index = new Map(); + + for (const [name, data] of Object.entries(entries)) { + index.set(normalizeZipPath(name), data); + } + + zipIndexCache.set(zipUrl, index); + return index; +} + +/** + * Load a file from a URL and write it to MuJoCo's virtual file system + */ +export async function loadAssetToVFS( + mujoco: MujocoRuntime, + url: string, + vfsPath: string +): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + + // For binary files (meshes, images), use arrayBuffer + const isBinary = url.endsWith('.msh') || url.endsWith('.obj') || url.endsWith('.stl') || url.endsWith('.png') || url.endsWith('.jpg'); + + if (isBinary) { + const arrayBuffer = await response.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + mujoco.FS.writeFile(vfsPath, uint8Array); + } else { + // For text files + const text = await response.text(); + mujoco.FS.writeFile(vfsPath, text); + } + } catch (error) { + console.error(`Error loading asset ${url} to ${vfsPath}:`, error); + throw error; + } +} + +/** + * Parse XML to extract asset file paths (textures and meshes) + */ +export function extractAssetPaths(xml: string): { textures: string[]; meshes: string[] } { + const textures: string[] = []; + const meshes: string[] = []; + + // Extract texture file paths + const textureRegex = /]*file=["']([^"']+)["'][^>]*>/gi; + let match; + while ((match = textureRegex.exec(xml)) !== null) { + textures.push(match[1]); + } + + // Extract mesh file paths + const meshRegex = /]*file=["']([^"']+)["'][^>]*>/gi; + while ((match = meshRegex.exec(xml)) !== null) { + meshes.push(match[1]); + } + + return { textures, meshes }; +} + +/** + * Load all assets referenced in the XML into the virtual file system + * Assets are loaded from the frontend public directory /mujoco-assets/ + */ +export async function loadMuJoCoAssets( + mujoco: MujocoRuntime, + xml: string, + baseUrlOrOptions?: string | { baseUrl?: string; zipUrl?: string; zipOnly?: boolean } +): Promise { + // Use API base URL if not provided + let apiBase = `/mujoco-assets/`; + let zipUrl: string | undefined; + let zipOnly = false; + if (typeof baseUrlOrOptions === "string") { + apiBase = baseUrlOrOptions; + } else if (baseUrlOrOptions) { + apiBase = baseUrlOrOptions.baseUrl || apiBase; + zipUrl = baseUrlOrOptions.zipUrl; + zipOnly = Boolean(baseUrlOrOptions.zipOnly); + } + const { textures, meshes } = extractAssetPaths(xml); + const allAssets = [...textures, ...meshes]; + + // Remove duplicates + const uniqueAssets = Array.from(new Set(allAssets)); + + let zipIndex: AssetZipIndex | null = null; + if (zipUrl) { + try { + zipIndex = await getZipIndex(zipUrl); + } catch (error) { + console.warn(`⚠ Failed to load asset zip ${zipUrl}, falling back to individual fetches.`, error); + } + } + if (zipOnly && !zipIndex) { + throw new Error(`zipOnly is enabled but asset zip could not be loaded: ${zipUrl || "(missing url)"}`); + } + + // Create necessary directories and load each asset + for (const assetPath of uniqueAssets) { + // Strip directory prefix from API path (backend already knows this directory) + // But keep it for VFS path (MuJoCo needs the full path as specified in XML) + let apiPath = assetPath; + if (assetPath.startsWith('mujoco_assets/')) { + apiPath = assetPath.substring('mujoco_assets/'.length); + } + + // Create directory structure in VFS + const pathParts = assetPath.split('/'); + const fileName = pathParts.pop()!; + const dirPath = pathParts.join('/'); + + if (dirPath) { + // Create directory path in VFS (relative to /working) + const vfsDirPath = `/working/${dirPath}`; + try { + // Create parent directories recursively + const parts = dirPath.split('/'); + let currentPath = '/working'; + for (const part of parts) { + currentPath = `${currentPath}/${part}`; + try { + mujoco.FS.mkdir(currentPath); + } catch (e: unknown) { + if (!(e instanceof Error) || !e.message.includes('File exists')) { + throw e; + } + } + } + } catch (e: unknown) { + console.warn(`Could not create directory ${vfsDirPath}:`, e); + } + } + + // Load asset from backend (use apiPath without mujoco_assets/ prefix) + const assetUrl = `${apiBase}/${apiPath}`; + // VFS path keeps the full path as specified in XML + const vfsPath = `/working/${assetPath}`; + + try { + let loadedFromZip = false; + if (zipIndex) { + const candidates = buildZipCandidates(assetPath); + for (const candidate of candidates) { + const entry = zipIndex.get(candidate); + if (entry) { + mujoco.FS.writeFile(vfsPath, entry); + loadedFromZip = true; + break; + } + } + } + + if (!loadedFromZip) { + if (zipOnly) { + throw new Error(`Asset not found in zip: ${assetPath}`); + } + await loadAssetToVFS(mujoco, assetUrl, vfsPath); + } + + // Verify the file was written to VFS + try { + const stats = mujoco.FS.stat(vfsPath); + // console.log(`✓ Loaded asset: ${assetPath} (API: ${apiPath}) -> VFS: ${vfsPath} (${stats.size} bytes)`); + } catch (statError) { + console.warn(`⚠ Asset loaded but cannot verify in VFS: ${vfsPath}`, statError); + } + } catch (error) { + console.warn(`⚠ Failed to load asset ${assetPath} (API: ${apiPath}), continuing...`, error); + // Continue loading other assets even if one fails + } + } +} diff --git a/src/lib/mujoco-utils.ts b/src/lib/mujoco-utils.ts index 3aa513c..dc9ec56 100644 --- a/src/lib/mujoco-utils.ts +++ b/src/lib/mujoco-utils.ts @@ -1,119 +1,130 @@ -/** - * Shared MuJoCo utilities used by both MuJoCoViewer component and mujoco test page - */ - -// Declare global type for mujoco module loaded via script tag -declare global { - interface Window { - load_mujoco?: () => Promise; - } -} - -/** - * Load MuJoCo module using script tag (bypasses webpack completely) - * This is the working strategy that loads MuJoCo WASM at runtime - */ -export async function loadMujoco() { - if (typeof window === "undefined") { - throw new Error("MuJoCo can only be loaded in the browser"); - } - - // Strategy: Load via script tag (completely bypasses webpack) - const loadViaScript = (): Promise => { - return new Promise((resolve, reject) => { - // Check if already loaded - if (window.load_mujoco) { - resolve(window.load_mujoco); - return; - } - - // Remove any existing script - const existingScript = document.getElementById("mujoco-wasm-script"); - if (existingScript) { - existingScript.remove(); - } - - // Create script tag - const script = document.createElement("script"); - script.id = "mujoco-wasm-script"; - script.type = "module"; - script.src = "/mujoco-js/dist/mujoco_wasm.js"; - - script.onload = () => { - // Import the module after script loads (it should be cached) - setTimeout(async () => { - try { - const importModule = new Function( - 'return import("/mujoco-js/dist/mujoco_wasm.js")' - ) as () => Promise; - - const module = await importModule(); - const loader = module.default || module; - window.load_mujoco = loader; - resolve(loader); - } catch (importError: any) { - reject(importError); - } - }, 100); - }; - - script.onerror = () => { - reject(new Error("Failed to load MuJoCo script")); - }; - - document.head.appendChild(script); - }); - }; - - try { - const load_mujoco = await loadViaScript(); - - if (!load_mujoco || typeof load_mujoco !== "function") { - throw new Error("MuJoCo loader is not a function"); - } - - const mujocoInstance = await load_mujoco(); - - if (!mujocoInstance) { - throw new Error("MuJoCo instance is null or undefined"); - } - - return mujocoInstance; - } catch (error) { - console.error("[MuJoCo Utils] Failed to load MuJoCo module:", error); - throw error; - } -} - -/** - * Helper function to convert MuJoCo position buffer to Three.js coordinates - * MuJoCo uses different coordinate system than Three.js (Y and Z are swapped) - */ -export function getPos( - buffer: Float32Array | Float64Array, - index: number, -): [number, number, number] { - return [ - buffer[index * 3 + 0], - buffer[index * 3 + 2], // Y and Z swapped - -buffer[index * 3 + 1], - ]; -} - -/** - * Helper function to convert MuJoCo quaternion buffer to Three.js quaternion - * MuJoCo uses different quaternion convention than Three.js - */ -export function getQuat( - buffer: Float32Array | Float64Array, - index: number, -): [number, number, number, number] { - return [ - -buffer[index * 4 + 1], - -buffer[index * 4 + 3], - buffer[index * 4 + 2], - -buffer[index * 4 + 0], - ]; -} - - +/** + * Shared MuJoCo utilities used by both MuJoCoViewer component and mujoco test page + */ + +// Declare global type for mujoco module loaded via script tag +type MujocoLoader = () => Promise; +type MujocoImport = { default?: MujocoLoader }; + +declare global { + interface Window { + load_mujoco?: MujocoLoader; + } +} + +/** + * Load MuJoCo module using script tag (bypasses webpack completely) + * This is the working strategy that loads MuJoCo WASM at runtime + */ +export async function loadMujoco(): Promise { + if (typeof window === "undefined") { + throw new Error("MuJoCo can only be loaded in the browser"); + } + + // Strategy: Load via script tag (completely bypasses webpack) + const loadViaScript = (): Promise => { + return new Promise((resolve, reject) => { + // Check if already loaded + if (window.load_mujoco) { + resolve(window.load_mujoco); + return; + } + + // Remove any existing script + const existingScript = document.getElementById("mujoco-wasm-script"); + if (existingScript) { + existingScript.remove(); + } + + // Create script tag + const script = document.createElement("script"); + script.id = "mujoco-wasm-script"; + script.type = "module"; + script.src = "/mujoco-js/dist/mujoco_wasm.js"; + + script.onload = () => { + // Import the module after script loads (it should be cached) + setTimeout(async () => { + try { + const importModule = new Function( + 'return import("/mujoco-js/dist/mujoco_wasm.js")' + ) as () => Promise; + + const loadedModule = await importModule(); + // Runtime narrowing for the dynamically imported module + const loader = + typeof loadedModule === "function" + ? loadedModule + : loadedModule.default; + + if (!loader) { + throw new Error("MuJoCo module did not expose a loader"); + } + + window.load_mujoco = loader; + resolve(loader); + } catch (importError: unknown) { + reject(importError); + } + }, 100); + }; + + script.onerror = () => { + reject(new Error("Failed to load MuJoCo script")); + }; + + document.head.appendChild(script); + }); + }; + + try { + const load_mujoco = await loadViaScript(); + + if (!load_mujoco || typeof load_mujoco !== "function") { + throw new Error("MuJoCo loader is not a function"); + } + + const mujocoInstance = await load_mujoco(); + + if (!mujocoInstance) { + throw new Error("MuJoCo instance is null or undefined"); + } + + return mujocoInstance; + } catch (error: unknown) { + // Import failures remain propagated as errors rather than being hidden behind an untyped value + console.error("[MuJoCo Utils] Failed to load MuJoCo module:", error); + throw error; + } +} + +/** + * Helper function to convert MuJoCo position buffer to Three.js coordinates + * MuJoCo uses different coordinate system than Three.js (Y and Z are swapped) + */ +export function getPos( + buffer: Float32Array | Float64Array, + index: number, +): [number, number, number] { + return [ + buffer[index * 3 + 0], + buffer[index * 3 + 2], // Y and Z swapped + -buffer[index * 3 + 1], + ]; +} + +/** + * Helper function to convert MuJoCo quaternion buffer to Three.js quaternion + * MuJoCo uses different quaternion convention than Three.js + */ +export function getQuat( + buffer: Float32Array | Float64Array, + index: number, +): [number, number, number, number] { + return [ + -buffer[index * 4 + 1], + -buffer[index * 4 + 3], + buffer[index * 4 + 2], + -buffer[index * 4 + 0], + ]; +} \ No newline at end of file diff --git a/src/lib/task-metadata.ts b/src/lib/task-metadata.ts index 81408b5..059ac23 100644 --- a/src/lib/task-metadata.ts +++ b/src/lib/task-metadata.ts @@ -1,31 +1,31 @@ -/** - * Task Metadata - Domain Randomization Configuration - * - * 用于配置任务的域随机化参数,如物体位置偏移、旋转偏移等。 - * 当前开源版本只包含一个示例任务,此文件可用于扩展更多任务。 - */ - -export type DomainRandomizationConfig = { - objects?: Record; - robots?: Record; -}; - -export type TaskMetadata = { - domain_randomization?: DomainRandomizationConfig; -}; - -/** - * Task metadata registry - * Add domain randomization config for each task here - */ -const TASK_METADATA: Record = { - // Task 1: Close the Box - // 当前不需要域随机化,保持默认状态 -}; - -export function getTaskMetadata(taskId: number | string | null | undefined): TaskMetadata | null { - if (taskId == null) return null; - const idNumber = typeof taskId === "string" ? Number(taskId) : taskId; - if (!Number.isFinite(idNumber)) return null; - return TASK_METADATA[idNumber] ?? null; -} +/** + * Task Metadata - Domain Randomization Configuration + * + * 用于配置任务的域随机化参数,如物体位置偏移、旋转偏移等。 + * 当前开源版本只包含一个示例任务,此文件可用于扩展更多任务。 + */ + +export type DomainRandomizationConfig = { + objects?: Record; + robots?: Record; +}; + +export type TaskMetadata = { + domain_randomization?: DomainRandomizationConfig; +}; + +/** + * Task metadata registry + * Add domain randomization config for each task here + */ +const TASK_METADATA: Record = { + // Task 1: Close the Box + // 当前不需要域随机化,保持默认状态 +}; + +export function getTaskMetadata(taskId: number | string | null | undefined): TaskMetadata | null { + if (taskId == null) return null; + const idNumber = typeof taskId === "string" ? Number(taskId) : taskId; + if (!Number.isFinite(idNumber)) return null; + return TASK_METADATA[idNumber] ?? null; +} diff --git a/src/scripts/copy-mujoco.mjs b/src/scripts/copy-mujoco.mjs new file mode 100644 index 0000000..87ffb33 --- /dev/null +++ b/src/scripts/copy-mujoco.mjs @@ -0,0 +1,7 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const source = resolve("node_modules/mujoco-js/dist"); +const destination = resolve("public/mujoco-js/dist"); +await mkdir(dirname(destination), { recursive: true }); +await cp(source, destination, { recursive: true });