|
87 | 87 | class="mt-3 w-full bg-gray-700 hover:bg-gray-600 p-2 rounded text-gray-300 text-xs flex items-center justify-center gap-2 transition"> |
88 | 88 | <i class="fas fa-redo"></i> Reset All |
89 | 89 | </button> |
| 90 | + <a href="https://github.com/alphaonelabs/alphaonelabs-virtual-robotics-playground" |
| 91 | + target="_blank" |
| 92 | + rel="noopener noreferrer" |
| 93 | + class="mt-2 w-full bg-indigo-700 hover:bg-indigo-600 p-2 rounded text-white text-xs flex items-center justify-center gap-2 transition font-medium"> |
| 94 | + <i class="fas fa-code-branch"></i> Contribute |
| 95 | + </a> |
90 | 96 | </div> |
91 | 97 | <div class="flex-1 relative bg-gray-950" id="viewport-wrapper"> |
92 | 98 | <canvas id="sim-canvas" |
|
176 | 182 | Blocks: <span id="block-count">0</span> |
177 | 183 | </div> |
178 | 184 | </div> |
| 185 | + <!-- LiDAR Detection Panel --> |
| 186 | + <div class="mt-3 pt-3 border-t border-gray-700"> |
| 187 | + <div class="text-gray-400 text-xs mb-1 flex items-center gap-1.5"> |
| 188 | + <span class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse hidden" id="detection-indicator"></span> |
| 189 | + 📡 Detection: |
| 190 | + </div> |
| 191 | + <div id="detection-info" class="text-[10px] space-y-0.5"> |
| 192 | + <div class="text-gray-500">LiDAR not installed</div> |
| 193 | + </div> |
| 194 | + </div> |
179 | 195 | </div> |
180 | 196 | </div> |
181 | 197 | </div> |
|
230 | 246 |
|
231 | 247 | function generateBlocks() { |
232 | 248 | blocks = []; |
| 249 | + const shapes = ['square', 'circle', 'triangle']; |
233 | 250 | for (let i = 0; i < 12; i++) { |
234 | 251 | blocks.push({ |
235 | 252 | id: i, |
236 | 253 | x: 80 + Math.random() * (canvas.width - 160), |
237 | 254 | y: 80 + Math.random() * (canvas.height - 160), |
238 | 255 | size: 25, |
239 | 256 | color: `hsl(${i * 30}, 70%, 55%)`, |
| 257 | + shape: shapes[Math.floor(Math.random() * shapes.length)], |
240 | 258 | held: false, |
241 | 259 | }); |
242 | 260 | } |
|
331 | 349 | if (type === 'camera') { |
332 | 350 | document.getElementById('camera-feed').classList.remove('hidden'); |
333 | 351 | } |
| 352 | + if (type === 'lidar') { |
| 353 | + lastDetectedId = null; |
| 354 | + updateDetectionUI(null); |
| 355 | + } |
334 | 356 | updatePartsList(); |
335 | 357 | showMsg(`${type.charAt(0).toUpperCase() + type.slice(1)} added!`); |
336 | 358 | } |
|
360 | 382 | robot.holdingBlock = null; |
361 | 383 | } |
362 | 384 |
|
| 385 | + // Handle lidar removal: clear detection |
| 386 | + if (type === 'lidar') { |
| 387 | + lastDetectedId = null; |
| 388 | + document.getElementById('detection-info').innerHTML = '<div class="text-gray-500">LiDAR not installed</div>'; |
| 389 | + document.getElementById('detection-indicator').classList.add('hidden'); |
| 390 | + } |
| 391 | + |
363 | 392 | // Handle wheels removal: stop the robot |
364 | 393 | if (type === 'wheels') { |
365 | 394 | robot.speed = 0; |
|
389 | 418 | document.getElementById('start-prompt').classList.remove('hidden'); |
390 | 419 | document.getElementById('camera-feed').classList.add('hidden'); |
391 | 420 | document.getElementById('block-count').textContent = '0'; |
| 421 | + lastDetectedId = null; |
| 422 | + document.getElementById('detection-info').innerHTML = '<div class="text-gray-500">LiDAR not installed</div>'; |
| 423 | + document.getElementById('detection-indicator').classList.add('hidden'); |
392 | 424 | updatePartsList(); |
393 | 425 | } |
394 | 426 |
|
|
442 | 474 | document.getElementById('pos-display').textContent = `${Math.round(robot.x)}, ${Math.round(robot.y)}`; |
443 | 475 | document.getElementById('angle-display').textContent = `${Math.round((robot.angle * 180) / Math.PI)}°`; |
444 | 476 | document.getElementById('speed-display').textContent = robot.speed.toFixed(1); |
| 477 | + |
| 478 | + // LiDAR object detection |
| 479 | + if (robot.parts.lidar) { |
| 480 | + const detected = getLidarDetection(); |
| 481 | + const newId = detected ? detected.id : null; |
| 482 | + if (newId !== lastDetectedId) { |
| 483 | + lastDetectedId = newId; |
| 484 | + updateDetectionUI(detected); |
| 485 | + } |
| 486 | + } |
445 | 487 | } |
446 | 488 |
|
447 | 489 | function drawGrid() { |
|
465 | 507 | blocks.forEach((b) => { |
466 | 508 | if (b.held) return; // draw held block with robot |
467 | 509 | ctx.fillStyle = b.color; |
468 | | - ctx.fillRect(b.x, b.y, b.size, b.size); |
469 | 510 | ctx.strokeStyle = '#fff'; |
470 | 511 | ctx.lineWidth = 2; |
471 | | - ctx.strokeRect(b.x, b.y, b.size, b.size); |
| 512 | + |
| 513 | + if (b.shape === 'circle') { |
| 514 | + ctx.beginPath(); |
| 515 | + ctx.arc(b.x + b.size / 2, b.y + b.size / 2, b.size / 2, 0, Math.PI * 2); |
| 516 | + ctx.fill(); |
| 517 | + ctx.stroke(); |
| 518 | + } else if (b.shape === 'triangle') { |
| 519 | + ctx.beginPath(); |
| 520 | + ctx.moveTo(b.x + b.size / 2, b.y); |
| 521 | + ctx.lineTo(b.x, b.y + b.size); |
| 522 | + ctx.lineTo(b.x + b.size, b.y + b.size); |
| 523 | + ctx.closePath(); |
| 524 | + ctx.fill(); |
| 525 | + ctx.stroke(); |
| 526 | + } else { |
| 527 | + ctx.fillRect(b.x, b.y, b.size, b.size); |
| 528 | + ctx.strokeRect(b.x, b.y, b.size, b.size); |
| 529 | + } |
472 | 530 |
|
473 | 531 | // Glow if near gripper and arm attached |
474 | 532 | if (robot.parts.arm && robot.holdingBlock === null) { |
|
477 | 535 | if (Math.hypot(gx - (b.x + b.size / 2), gy - (b.y + b.size / 2)) < 45) { |
478 | 536 | ctx.shadowColor = '#fff'; |
479 | 537 | ctx.shadowBlur = 12; |
480 | | - ctx.strokeRect(b.x, b.y, b.size, b.size); |
| 538 | + if (b.shape === 'circle') { |
| 539 | + ctx.beginPath(); |
| 540 | + ctx.arc(b.x + b.size / 2, b.y + b.size / 2, b.size / 2, 0, Math.PI * 2); |
| 541 | + ctx.stroke(); |
| 542 | + } else if (b.shape === 'triangle') { |
| 543 | + ctx.beginPath(); |
| 544 | + ctx.moveTo(b.x + b.size / 2, b.y); |
| 545 | + ctx.lineTo(b.x, b.y + b.size); |
| 546 | + ctx.lineTo(b.x + b.size, b.y + b.size); |
| 547 | + ctx.closePath(); |
| 548 | + ctx.stroke(); |
| 549 | + } else { |
| 550 | + ctx.strokeRect(b.x, b.y, b.size, b.size); |
| 551 | + } |
481 | 552 | ctx.shadowBlur = 0; |
482 | 553 | } |
483 | 554 | } |
|
527 | 598 | const sw = (((Date.now() / 15) % 360) * Math.PI) / 180; |
528 | 599 | ctx.beginPath(); |
529 | 600 | ctx.moveTo(0, -32); |
530 | | - ctx.lineTo(Math.cos(sw - Math.PI / 2) * 70, -32 + Math.sin(sw - Math.PI / 2) * 70); |
| 601 | + ctx.lineTo(Math.cos(sw - Math.PI / 2) * 100, -32 + Math.sin(sw - Math.PI / 2) * 100); |
531 | 602 | ctx.stroke(); |
532 | 603 | } |
533 | 604 |
|
|
567 | 638 | const b = blocks.find((bl) => bl.id === robot.holdingBlock); |
568 | 639 | if (b) { |
569 | 640 | ctx.fillStyle = b.color; |
570 | | - ctx.fillRect(-b.size / 2, 55, b.size, b.size); |
571 | 641 | ctx.strokeStyle = '#fff'; |
572 | 642 | ctx.lineWidth = 2; |
573 | | - ctx.strokeRect(-b.size / 2, 55, b.size, b.size); |
| 643 | + if (b.shape === 'circle') { |
| 644 | + ctx.beginPath(); |
| 645 | + ctx.arc(0, 55 + b.size / 2, b.size / 2, 0, Math.PI * 2); |
| 646 | + ctx.fill(); |
| 647 | + ctx.stroke(); |
| 648 | + } else if (b.shape === 'triangle') { |
| 649 | + ctx.beginPath(); |
| 650 | + ctx.moveTo(0, 55); |
| 651 | + ctx.lineTo(-b.size / 2, 55 + b.size); |
| 652 | + ctx.lineTo(b.size / 2, 55 + b.size); |
| 653 | + ctx.closePath(); |
| 654 | + ctx.fill(); |
| 655 | + ctx.stroke(); |
| 656 | + } else { |
| 657 | + ctx.fillRect(-b.size / 2, 55, b.size, b.size); |
| 658 | + ctx.strokeRect(-b.size / 2, 55, b.size, b.size); |
| 659 | + } |
574 | 660 | } |
575 | 661 | } |
576 | 662 | } |
|
647 | 733 | render(); |
648 | 734 | requestAnimationFrame(loop); |
649 | 735 | } |
| 736 | + |
| 737 | + // ========== LIDAR DETECTION ========== |
| 738 | + let lastDetectedId = null; |
| 739 | + |
650 | 740 | loop(); |
| 741 | + |
| 742 | + function segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { |
| 743 | + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); |
| 744 | + if (denom === 0) return false; |
| 745 | + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; |
| 746 | + const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom; |
| 747 | + return t >= 0 && t <= 1 && u >= 0 && u <= 1; |
| 748 | + } |
| 749 | + |
| 750 | + function segmentIntersectsRect(x1, y1, x2, y2, rx, ry, rw, rh) { |
| 751 | + if (x2 >= rx && x2 <= rx + rw && y2 >= ry && y2 <= ry + rh) return true; |
| 752 | + return ( |
| 753 | + segmentsIntersect(x1, y1, x2, y2, rx, ry, rx + rw, ry) || |
| 754 | + segmentsIntersect(x1, y1, x2, y2, rx + rw, ry, rx + rw, ry + rh) || |
| 755 | + segmentsIntersect(x1, y1, x2, y2, rx, ry + rh, rx + rw, ry + rh) || |
| 756 | + segmentsIntersect(x1, y1, x2, y2, rx, ry, rx, ry + rh) |
| 757 | + ); |
| 758 | + } |
| 759 | + |
| 760 | + function pointToSegmentDist(px, py, x1, y1, x2, y2) { |
| 761 | + const A = px - x1, B = py - y1, C = x2 - x1, D = y2 - y1; |
| 762 | + const lenSq = C * C + D * D; |
| 763 | + const param = lenSq !== 0 ? Math.max(0, Math.min(1, (A * C + B * D) / lenSq)) : 0; |
| 764 | + return Math.hypot(px - (x1 + param * C), py - (y1 + param * D)); |
| 765 | + } |
| 766 | + |
| 767 | + function beamIntersectsBlock(x1, y1, x2, y2, b) { |
| 768 | + const cx = b.x + b.size / 2, cy = b.y + b.size / 2; |
| 769 | + if (b.shape === 'circle') { |
| 770 | + return pointToSegmentDist(cx, cy, x1, y1, x2, y2) < b.size / 2; |
| 771 | + } else if (b.shape === 'triangle') { |
| 772 | + const tx1 = b.x + b.size / 2, ty1 = b.y; |
| 773 | + const tx2 = b.x, ty2 = b.y + b.size; |
| 774 | + const tx3 = b.x + b.size, ty3 = b.y + b.size; |
| 775 | + // Check all three edges, or if beam endpoint is inside triangle |
| 776 | + return ( |
| 777 | + segmentsIntersect(x1, y1, x2, y2, tx1, ty1, tx2, ty2) || |
| 778 | + segmentsIntersect(x1, y1, x2, y2, tx2, ty2, tx3, ty3) || |
| 779 | + segmentsIntersect(x1, y1, x2, y2, tx3, ty3, tx1, ty1) |
| 780 | + ); |
| 781 | + } else { |
| 782 | + return segmentIntersectsRect(x1, y1, x2, y2, b.x, b.y, b.size, b.size); |
| 783 | + } |
| 784 | + } |
| 785 | + |
| 786 | + function getLidarDetection() { |
| 787 | + if (!robot.parts.lidar || blocks.length === 0) return null; |
| 788 | + const sw = (((Date.now() / 15) % 360) * Math.PI) / 180; |
| 789 | + // Lidar center in world coordinates (local offset: 0, -32) |
| 790 | + const lidarX = robot.x + 32 * Math.sin(robot.angle); |
| 791 | + const lidarY = robot.y - 32 * Math.cos(robot.angle); |
| 792 | + // World beam direction angle |
| 793 | + const worldBeamAngle = sw - Math.PI / 2 + robot.angle; |
| 794 | + const beamRange = 100; |
| 795 | + const beamEndX = lidarX + Math.cos(worldBeamAngle) * beamRange; |
| 796 | + const beamEndY = lidarY + Math.sin(worldBeamAngle) * beamRange; |
| 797 | + for (const b of blocks) { |
| 798 | + if (b.held) continue; |
| 799 | + if (beamIntersectsBlock(lidarX, lidarY, beamEndX, beamEndY, b)) return b; |
| 800 | + } |
| 801 | + return null; |
| 802 | + } |
| 803 | + |
| 804 | + function getColorName(hslColor) { |
| 805 | + const match = hslColor.match(/hsl\((\d+)/); |
| 806 | + if (!match) return hslColor; |
| 807 | + const hue = parseInt(match[1]); |
| 808 | + const names = [ |
| 809 | + [15, 'Red'], [45, 'Orange'], [75, 'Yellow'], [105, 'Yellow-Green'], |
| 810 | + [135, 'Green'], [165, 'Teal'], [195, 'Cyan'], [225, 'Sky Blue'], |
| 811 | + [255, 'Blue'], [285, 'Purple'], [315, 'Magenta'], [345, 'Pink'], |
| 812 | + ]; |
| 813 | + for (const [max, name] of names) { |
| 814 | + if (hue < max) return name; |
| 815 | + } |
| 816 | + return 'Red'; |
| 817 | + } |
| 818 | + |
| 819 | + function updateDetectionUI(detected) { |
| 820 | + const el = document.getElementById('detection-info'); |
| 821 | + const indicator = document.getElementById('detection-indicator'); |
| 822 | + if (detected) { |
| 823 | + const colorName = getColorName(detected.color); |
| 824 | + const shapeName = detected.shape.charAt(0).toUpperCase() + detected.shape.slice(1); |
| 825 | + el.innerHTML = |
| 826 | + '<div class="text-green-400 font-semibold">● OBJECT DETECTED</div>' + |
| 827 | + '<div class="text-gray-300 mt-0.5">Shape: <span class="text-yellow-300">' + shapeName + '</span></div>' + |
| 828 | + '<div class="text-gray-300">Color: <span style="color:' + detected.color + ';font-weight:600">' + colorName + '</span></div>'; |
| 829 | + indicator.classList.remove('hidden'); |
| 830 | + } else { |
| 831 | + el.innerHTML = '<div class="text-gray-500">No objects detected</div>'; |
| 832 | + indicator.classList.add('hidden'); |
| 833 | + } |
| 834 | + } |
651 | 835 | </script> |
652 | 836 | </body> |
653 | 837 | </html> |
0 commit comments