/** * 3D Robot Arm Pusher Simulation Engine * Uses Three.js for rendering, Inverse Kinematics (IK), and Physics simulation */ class PusherSimulator { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.container = this.canvas.parentElement; // Sim State this.currentStep = 50000; // default 50k Master this.isRunning = true; this.speedMultiplier = 1.0; this.episode = 1; this.episodeSteps = 0; this.maxEpisodeSteps = 100; // Interactive drag state this.dragTarget = null; this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this.plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Physics & Objects coordinates this.tableRadius = 3.5; this.puckPos = new THREE.Vector3(0.8, 0.1, 0.4); this.puckVel = new THREE.Vector3(0, 0, 0); this.goalPos = new THREE.Vector3(1.2, 0.02, -0.6); this.targetObject = "puck"; // "puck" or "goal" // Arm Kinematics Config (3-link planar articulated arm in 3D) this.arm = { basePos: new THREE.Vector3(-1.0, 0, 0), l1: 1.4, // Upper arm length l2: 1.2, // Forearm length l3: 0.7, // End effector length theta1: 0.2, // Base rotation (Y-axis) theta2: 0.4, // Shoulder theta3: -0.6, // Elbow theta4: 0.2, // Wrist tipPos: new THREE.Vector3(0, 0.1, 0) }; // Trajectory history this.tipTrailPoints = []; this.puckTrailPoints = []; this.maxTrail = 60; // Metrics for HUD this.currentReward = 0; this.distToPuck = 0; this.distToGoal = 0; this.successCount = 0; this.initThree(); this.initScene(); this.setupEvents(); this.animate = this.animate.bind(this); requestAnimationFrame(this.animate); } initThree() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x070b14); this.scene.fog = new THREE.FogExp2(0x070b14, 0.08); const width = this.container.clientWidth; const height = this.container.clientHeight; this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); this.setCameraPreset("iso"); this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, powerPreference: "high-performance" }); this.renderer.setSize(width, height); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Orbit Controls this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.05; this.controls.maxPolarAngle = Math.PI / 2 - 0.02; // Don't go below floor this.controls.minDistance = 2; this.controls.maxDistance = 12; this.controls.target.set(0.3, 0.2, 0); // Resize Handler window.addEventListener("resize", () => this.onResize()); } initScene() { // Lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xa5b4fc, 1.2); dirLight.position.set(5, 10, 7); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 25; dirLight.shadow.camera.left = -5; dirLight.shadow.camera.right = 5; dirLight.shadow.camera.top = 5; dirLight.shadow.camera.bottom = -5; this.scene.add(dirLight); // Accent Cyber Point Lights const cyanLight = new THREE.PointLight(0x00f2fe, 1.5, 8); cyanLight.position.set(1.5, 1.5, -1.0); this.scene.add(cyanLight); const purpleLight = new THREE.PointLight(0x9d4edd, 1.2, 8); purpleLight.position.set(-1.5, 1.5, 1.5); this.scene.add(purpleLight); // Floor & Grid Table this.createTable(); // Robot Arm Mesh this.createRobotArm(); // Puck & Goal Mesh this.createInteractiveObjects(); // Trail lines this.createTrails(); } createTable() { // Circular Table / Platform const tableGeo = new THREE.CylinderGeometry(this.tableRadius, this.tableRadius + 0.2, 0.2, 64); const tableMat = new THREE.MeshStandardMaterial({ color: 0x0f172a, roughness: 0.4, metalness: 0.3 }); const table = new THREE.Mesh(tableGeo, tableMat); table.position.y = -0.1; table.receiveShadow = true; this.scene.add(table); // Grid Overlay on Table const grid = new THREE.GridHelper(6, 24, 0x00f2fe, 0x1e293b); grid.position.y = 0.005; this.scene.add(grid); // Outer Glow Ring const ringGeo = new THREE.RingGeometry(this.tableRadius - 0.05, this.tableRadius + 0.05, 64); const ringMat = new THREE.MeshBasicMaterial({ color: 0x00f2fe, side: THREE.DoubleSide, transparent: true, opacity: 0.4 }); const ring = new THREE.Mesh(ringGeo, ringMat); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.01; this.scene.add(ring); } createRobotArm() { this.armGroup = new THREE.Group(); this.armGroup.position.copy(this.arm.basePos); const metalMat = new THREE.MeshStandardMaterial({ color: 0x1e293b, roughness: 0.2, metalness: 0.8 }); const jointMat = new THREE.MeshStandardMaterial({ color: 0x00f2fe, emissive: 0x00f2fe, emissiveIntensity: 0.3, roughness: 0.3, metalness: 0.5 }); const linkMat = new THREE.MeshStandardMaterial({ color: 0x334155, roughness: 0.3, metalness: 0.6 }); // 1. Base pedestal const baseGeo = new THREE.CylinderGeometry(0.35, 0.45, 0.3, 32); const base = new THREE.Mesh(baseGeo, metalMat); base.position.y = 0.15; base.castShadow = true; this.armGroup.add(base); // Joint 1: Shoulder Root this.shoulderJoint = new THREE.Group(); this.shoulderJoint.position.y = 0.3; const j1Sphere = new THREE.Mesh(new THREE.SphereGeometry(0.2, 24, 24), jointMat); j1Sphere.castShadow = true; this.shoulderJoint.add(j1Sphere); this.armGroup.add(this.shoulderJoint); // Link 1 (Upper arm) this.link1 = new THREE.Group(); const link1Mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, this.arm.l1, 24), linkMat); link1Mesh.position.y = this.arm.l1 / 2; link1Mesh.castShadow = true; this.link1.add(link1Mesh); this.shoulderJoint.add(this.link1); // Joint 2: Elbow this.elbowJoint = new THREE.Group(); this.elbowJoint.position.y = this.arm.l1; const j2Sphere = new THREE.Mesh(new THREE.SphereGeometry(0.16, 24, 24), jointMat); j2Sphere.castShadow = true; this.elbowJoint.add(j2Sphere); this.link1.add(this.elbowJoint); // Link 2 (Forearm) this.link2 = new THREE.Group(); const link2Mesh = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, this.arm.l2, 24), linkMat); link2Mesh.position.y = this.arm.l2 / 2; link2Mesh.castShadow = true; this.link2.add(link2Mesh); this.elbowJoint.add(this.link2); // Joint 3: Wrist & End-Effector Tip (Pusher) this.wristJoint = new THREE.Group(); this.wristJoint.position.y = this.arm.l2; const j3Sphere = new THREE.Mesh(new THREE.SphereGeometry(0.12, 24, 24), jointMat); j3Sphere.castShadow = true; this.wristJoint.add(j3Sphere); // Pusher Tip Tool (Curved/Flat Pusher Plate) const toolGeo = new THREE.BoxGeometry(0.35, 0.15, 0.08); const toolMat = new THREE.MeshStandardMaterial({ color: 0x9d4edd, emissive: 0x9d4edd, emissiveIntensity: 0.4, metalness: 0.7 }); const toolMesh = new THREE.Mesh(toolGeo, toolMat); toolMesh.position.y = this.arm.l3; toolMesh.castShadow = true; this.wristJoint.add(toolMesh); this.link2.add(this.wristJoint); this.scene.add(this.armGroup); } createInteractiveObjects() { // 1. Goal Ring const goalGeo = new THREE.RingGeometry(0.25, 0.32, 32); const goalMat = new THREE.MeshBasicMaterial({ color: 0x10b981, side: THREE.DoubleSide, transparent: true, opacity: 0.8 }); this.goalMesh = new THREE.Mesh(goalGeo, goalMat); this.goalMesh.rotation.x = -Math.PI / 2; this.goalMesh.position.copy(this.goalPos); this.scene.add(this.goalMesh); // Goal Center Beacon const beaconGeo = new THREE.CylinderGeometry(0.02, 0.02, 0.8, 16); const beaconMat = new THREE.MeshBasicMaterial({ color: 0x10b981, transparent: true, opacity: 0.3 }); const beacon = new THREE.Mesh(beaconGeo, beaconMat); beacon.position.y = 0.4; this.goalMesh.add(beacon); // 2. Puck (Movable Object) const puckGeo = new THREE.CylinderGeometry(0.2, 0.2, 0.16, 32); const puckMat = new THREE.MeshStandardMaterial({ color: 0xf59e0b, emissive: 0xf59e0b, emissiveIntensity: 0.25, roughness: 0.2, metalness: 0.6 }); this.puckMesh = new THREE.Mesh(puckGeo, puckMat); this.puckMesh.position.copy(this.puckPos); this.puckMesh.castShadow = true; this.scene.add(this.puckMesh); } createTrails() { // Tip Trajectory Line const trailMat1 = new THREE.LineBasicMaterial({ color: 0x9d4edd, transparent: true, opacity: 0.8, linewidth: 2 }); this.tipTrailGeo = new THREE.BufferGeometry(); this.tipTrailLine = new THREE.Line(this.tipTrailGeo, trailMat1); this.scene.add(this.tipTrailLine); // Puck Trajectory Line const trailMat2 = new THREE.LineBasicMaterial({ color: 0xf59e0b, transparent: true, opacity: 0.8, linewidth: 2 }); this.puckTrailGeo = new THREE.BufferGeometry(); this.puckTrailLine = new THREE.Line(this.puckTrailGeo, trailMat2); this.scene.add(this.puckTrailLine); } setCameraPreset(type) { if (type === "iso") { this.camera.position.set(2.8, 3.2, 3.2); } else if (type === "top") { this.camera.position.set(0.2, 6.0, 0.01); } else if (type === "side") { this.camera.position.set(4.5, 1.2, 0); } } setupEvents() { // Mouse Interaction for dragging Puck or Goal const dom = this.renderer.domElement; let isDragging = false; dom.addEventListener("pointerdown", (e) => { const rect = dom.getBoundingClientRect(); this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; this.raycaster.setFromCamera(this.mouse, this.camera); const intersects = this.raycaster.intersectObjects([this.puckMesh, this.goalMesh], true); if (intersects.length > 0) { this.controls.enabled = false; isDragging = true; const obj = intersects[0].object; this.dragTarget = (obj === this.puckMesh) ? "puck" : "goal"; } }); window.addEventListener("pointermove", (e) => { if (!isDragging) return; const rect = dom.getBoundingClientRect(); this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; this.raycaster.setFromCamera(this.mouse, this.camera); const targetPoint = new THREE.Vector3(); this.raycaster.ray.intersectPlane(this.plane, targetPoint); if (targetPoint) { // Clamp within table radius if (targetPoint.length() < this.tableRadius - 0.4) { if (this.dragTarget === "puck") { this.puckPos.set(targetPoint.x, 0.1, targetPoint.z); this.puckVel.set(0, 0, 0); } else if (this.dragTarget === "goal") { this.goalPos.set(targetPoint.x, 0.02, targetPoint.z); this.goalMesh.position.copy(this.goalPos); } } } }); window.addEventListener("pointerup", () => { isDragging = false; this.controls.enabled = true; this.dragTarget = null; }); } onResize() { const width = this.container.clientWidth; const height = this.container.clientHeight; this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); } // ------------------------------------------------------------- // RL Policy Execution Logic (4 Checkpoint Modes) // ------------------------------------------------------------- setCheckpointStep(step) { this.currentStep = step; this.resetEpisode(); } resetEpisode() { this.episodeSteps = 0; this.episode++; this.tipTrailPoints = []; this.puckTrailPoints = []; // Randomize initial positions slightly const angle = (Math.random() - 0.5) * 1.2; this.puckPos.set(0.6 + Math.cos(angle) * 0.4, 0.1, (Math.sin(angle) * 0.5)); this.puckVel.set(0, 0, 0); this.puckMesh.position.copy(this.puckPos); } computePolicyAction() { const puck = this.puckPos; const goal = this.goalPos; const base = this.arm.basePos; // Vector from puck to goal const puckToGoal = new THREE.Vector3().subVectors(goal, puck).normalize(); // Desired End-Effector Target Point let desiredTipPos = new THREE.Vector3(); if (this.currentStep === 0) { // Step 0: Random Walk & Chaotic Flailing const t = performance.now() * 0.003; desiredTipPos.set( base.x + 1.2 + Math.sin(t * 2.1) * 0.8, 0.15 + Math.abs(Math.sin(t * 3.5)) * 0.3, Math.cos(t * 1.7) * 1.1 ); } else if (this.currentStep === 10000) { // Step 10k (Novice): Moves towards puck but misses angle or stops short const noise = (Math.sin(performance.now() * 0.005) * 0.3); desiredTipPos.copy(puck).add(new THREE.Vector3(-0.35 + noise, 0.05, noise)); } else if (this.currentStep === 25000) { // Step 25k (Intermediate): Aligns behind puck and pushes, but overshoots slightly const behindPuck = puck.clone().sub(puckToGoal.clone().multiplyScalar(0.25)); if (this.arm.tipPos.distanceTo(behindPuck) > 0.15) { desiredTipPos.copy(behindPuck); } else { desiredTipPos.copy(puck).add(puckToGoal.clone().multiplyScalar(0.4)); } } else { // Step 50k (Master): Smooth 2-Phase optimal trajectory // Phase 1: Swing smoothly behind puck // Phase 2: Push precisely towards center of goal with deceleration const distToPuck = this.arm.tipPos.distanceTo(puck); const behindPuck = puck.clone().sub(puckToGoal.clone().multiplyScalar(0.22)); behindPuck.y = 0.08; if (distToPuck > 0.28 && this.puckPos.distanceTo(this.goalPos) > 0.15) { desiredTipPos.copy(behindPuck); } else { desiredTipPos.copy(goal); } } return desiredTipPos; } // 2D Planar Inverse Kinematics in X-Z plane solveIK(targetPos) { const base = this.arm.basePos; const dx = targetPos.x - base.x; const dz = targetPos.z - base.z; const dist = Math.sqrt(dx * dx + dz * dz); // Clamp reach const maxReach = this.arm.l1 + this.arm.l2 + this.arm.l3 - 0.05; const targetDist = Math.min(dist, maxReach); // Base angle const targetAngle = Math.atan2(dz, dx); // Planar 2-link IK for shoulder & elbow const l1 = this.arm.l1; const l2 = this.arm.l2 + this.arm.l3 * 0.7; let cosElbow = (targetDist * targetDist - l1 * l1 - l2 * l2) / (2 * l1 * l2); cosElbow = Math.max(-1, Math.min(1, cosElbow)); const elbowAngle = Math.acos(cosElbow); const k1 = l1 + l2 * cosElbow; const k2 = l2 * Math.sin(elbowAngle); const shoulderAngle = targetAngle - Math.atan2(k2, k1); // Smooth Joint Interpolation (PD Control) const alpha = 0.12 * this.speedMultiplier; this.arm.theta1 = THREE.MathUtils.lerp(this.arm.theta1, targetAngle, alpha); this.arm.theta2 = THREE.MathUtils.lerp(this.arm.theta2, shoulderAngle, alpha); this.arm.theta3 = THREE.MathUtils.lerp(this.arm.theta3, elbowAngle, alpha); // Apply rotations to Three.js Joint hierarchy this.armGroup.rotation.y = -this.arm.theta1; this.link1.rotation.z = -(Math.PI / 2 - 0.2); this.elbowJoint.rotation.z = this.arm.theta3 * 0.8; this.wristJoint.rotation.z = -this.arm.theta3 * 0.5; // Calculate actual forward tip position in world coordinates const tipWorld = new THREE.Vector3(); this.wristJoint.getWorldPosition(tipWorld); this.arm.tipPos.copy(tipWorld); } updatePhysics() { // 1. Pusher Tip vs Puck Collision (Rigid Sphere/Cylinder approximation) const distTipPuck = this.arm.tipPos.distanceTo(this.puckPos); const collideRadius = 0.28; if (distTipPuck < collideRadius) { const pushDir = new THREE.Vector3().subVectors(this.puckPos, this.arm.tipPos); pushDir.y = 0; pushDir.normalize(); const force = (collideRadius - distTipPuck) * 1.8; this.puckVel.add(pushDir.multiplyScalar(force)); } // 2. Puck Velocity & Floor Friction this.puckPos.add(this.puckVel.clone().multiplyScalar(this.speedMultiplier)); this.puckVel.multiplyScalar(0.90); // Ground damping friction // Clamp puck on table if (this.puckPos.length() > this.tableRadius - 0.2) { this.puckPos.normalize().multiplyScalar(this.tableRadius - 0.2); this.puckVel.set(0, 0, 0); } this.puckMesh.position.copy(this.puckPos); // 3. Compute Distance & Rewards this.distToPuck = this.arm.tipPos.distanceTo(this.puckPos); this.distToGoal = this.puckPos.distanceTo(this.goalPos); // Dense MuJoCo Pusher Reward formulation: // r = - dist(tip, puck) - 1.25 * dist(puck, goal) + success_bonus let stepReward = -(this.distToPuck * 0.4) - (this.distToGoal * 1.5); if (this.distToGoal < 0.25) { stepReward += 2.0; // Goal bonus this.successCount++; } this.currentReward = stepReward; // 4. Update Trail Lines this.tipTrailPoints.push(this.arm.tipPos.clone()); this.puckTrailPoints.push(this.puckPos.clone()); if (this.tipTrailPoints.length > this.maxTrail) this.tipTrailPoints.shift(); if (this.puckTrailPoints.length > this.maxTrail) this.puckTrailPoints.shift(); this.tipTrailGeo.setFromPoints(this.tipTrailPoints); this.puckTrailGeo.setFromPoints(this.puckTrailPoints); // 5. Episode Termination & Reset Loop this.episodeSteps++; if (this.episodeSteps >= this.maxEpisodeSteps) { this.resetEpisode(); } } animate() { requestAnimationFrame(this.animate); if (this.isRunning) { const desiredTip = this.computePolicyAction(); this.solveIK(desiredTip); this.updatePhysics(); } this.controls.update(); this.renderer.render(this.scene, this.camera); // Callback hook for external HUD update if (this.onFrameUpdate) { this.onFrameUpdate({ episode: this.episode, step: this.episodeSteps, reward: this.currentReward, distToPuck: this.distToPuck, distToGoal: this.distToGoal, checkpoint: this.currentStep }); } } } window.PusherSimulator = PusherSimulator;