diff --git a/README.md b/README.md
index 029a8a1..a778e73 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ A browser-based virtual robotics simulation environment for building, configurin
- **Modular Robot Builder** — Start from a blank chassis and attach sensors, wheels, and a manipulator arm.
- **Omni-Directional Movement** — Drive your robot with WASD or Arrow keys; adjust motor power and turning speed in real-time.
-- **LiDAR Scanning** — A rotating 360° LiDAR sensor casts a detection beam to detect nearby objects.
+- **LiDAR Scanning** — A rotating 360° LiDAR sensor casts a detection beam. When it passes over an object, the **Detection Panel** reports the object's **shape** and **color**.
- **First-Person Camera** — Live FPV camera feed rendered from the robot's perspective directly in the browser.
- **Robotic Arm** — Pick up and drop coloured blocks using the SPACE key.
- **Telemetry HUD** — Real-time position, heading, and speed readout overlaid on the simulation canvas.
@@ -23,22 +23,9 @@ No build tools or server required — just open the files directly in your brows
```bash
git clone https://github.com/alphaonelabs/alphaonelabs-virtual-robotics-playground.git
```
-
2. Open `index.html` in any modern web browser to view the landing page.
-
3. Click **Enter System** to launch the interactive playground (`home.html`).
-**Optional: Run with local server**
-```bash
-# Using Python
-python3 -m http.server 8000
-
-# Using Node.js
-npx http-server -p 8000
-
-# Visit: http://localhost:8000
-```
-
---
## 🎮 Controls
@@ -61,12 +48,21 @@ Add or remove components from the **Toolbox** panel on the left:
|---|---|
| **Chassis** | Core robot body — must be added before any other part |
| **Wheels (WASD)** | Enables keyboard-driven movement |
-| **LiDAR** | Rotating laser scanner with visual sweep animation |
+| **LiDAR** | Rotating laser scanner; detects nearby objects and reports shape & color |
| **Camera (View)** | First-person camera feed shown in the top-left overlay |
| **Arm (SPACE)** | Allows picking up and dropping blocks with the SPACE key |
---
+## 📡 LiDAR Detection
+
+When the LiDAR sensor is active, its beam sweeps 360° around the robot. When the beam intersects an object within range, the **Detection Panel** (bottom of the right sidebar) instantly displays:
+
+- The **shape** of the detected object — `Square`, `Circle`, or `Triangle`
+- The **color** of the detected object — e.g. `Red`, `Orange`, `Green`, `Blue`
+
+---
+
## ⚙️ Settings (Right Sidebar)
| Setting | Description |
@@ -149,4 +145,4 @@ This project is licensed under the [MIT License](LICENSE).
---
-*Built by [Alpha One Labs](https://github.com/alphaonelabs) — Advancing open science through education and robotics.*
\ No newline at end of file
+*Built by [Alpha One Labs](https://github.com/alphaonelabs) — Advancing open science through education and robotics.*
diff --git a/home.html b/home.html
index 1ee8960..18ca614 100644
--- a/home.html
+++ b/home.html
@@ -87,6 +87,12 @@
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">
Reset All
+
+ Contribute
+
+
+
@@ -230,6 +246,7 @@
function generateBlocks() {
blocks = [];
+ const shapes = ['square', 'circle', 'triangle'];
for (let i = 0; i < 12; i++) {
blocks.push({
id: i,
@@ -237,6 +254,7 @@
y: 80 + Math.random() * (canvas.height - 160),
size: 25,
color: `hsl(${i * 30}, 70%, 55%)`,
+ shape: shapes[Math.floor(Math.random() * shapes.length)],
held: false,
});
}
@@ -331,6 +349,10 @@
if (type === 'camera') {
document.getElementById('camera-feed').classList.remove('hidden');
}
+ if (type === 'lidar') {
+ lastDetectedId = null;
+ updateDetectionUI(null);
+ }
updatePartsList();
showMsg(`${type.charAt(0).toUpperCase() + type.slice(1)} added!`);
}
@@ -360,6 +382,13 @@
robot.holdingBlock = null;
}
+ // Handle lidar removal: clear detection
+ if (type === 'lidar') {
+ lastDetectedId = null;
+ document.getElementById('detection-info').innerHTML = 'LiDAR not installed
';
+ document.getElementById('detection-indicator').classList.add('hidden');
+ }
+
// Handle wheels removal: stop the robot
if (type === 'wheels') {
robot.speed = 0;
@@ -389,6 +418,9 @@
document.getElementById('start-prompt').classList.remove('hidden');
document.getElementById('camera-feed').classList.add('hidden');
document.getElementById('block-count').textContent = '0';
+ lastDetectedId = null;
+ document.getElementById('detection-info').innerHTML = 'LiDAR not installed
';
+ document.getElementById('detection-indicator').classList.add('hidden');
updatePartsList();
}
@@ -442,6 +474,16 @@
document.getElementById('pos-display').textContent = `${Math.round(robot.x)}, ${Math.round(robot.y)}`;
document.getElementById('angle-display').textContent = `${Math.round((robot.angle * 180) / Math.PI)}°`;
document.getElementById('speed-display').textContent = robot.speed.toFixed(1);
+
+ // LiDAR object detection
+ if (robot.parts.lidar) {
+ const detected = getLidarDetection();
+ const newId = detected ? detected.id : null;
+ if (newId !== lastDetectedId) {
+ lastDetectedId = newId;
+ updateDetectionUI(detected);
+ }
+ }
}
function drawGrid() {
@@ -465,10 +507,26 @@
blocks.forEach((b) => {
if (b.held) return; // draw held block with robot
ctx.fillStyle = b.color;
- ctx.fillRect(b.x, b.y, b.size, b.size);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
- ctx.strokeRect(b.x, b.y, b.size, b.size);
+
+ if (b.shape === 'circle') {
+ ctx.beginPath();
+ ctx.arc(b.x + b.size / 2, b.y + b.size / 2, b.size / 2, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ } else if (b.shape === 'triangle') {
+ ctx.beginPath();
+ ctx.moveTo(b.x + b.size / 2, b.y);
+ ctx.lineTo(b.x, b.y + b.size);
+ ctx.lineTo(b.x + b.size, b.y + b.size);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ } else {
+ ctx.fillRect(b.x, b.y, b.size, b.size);
+ ctx.strokeRect(b.x, b.y, b.size, b.size);
+ }
// Glow if near gripper and arm attached
if (robot.parts.arm && robot.holdingBlock === null) {
@@ -477,7 +535,20 @@
if (Math.hypot(gx - (b.x + b.size / 2), gy - (b.y + b.size / 2)) < 45) {
ctx.shadowColor = '#fff';
ctx.shadowBlur = 12;
- ctx.strokeRect(b.x, b.y, b.size, b.size);
+ if (b.shape === 'circle') {
+ ctx.beginPath();
+ ctx.arc(b.x + b.size / 2, b.y + b.size / 2, b.size / 2, 0, Math.PI * 2);
+ ctx.stroke();
+ } else if (b.shape === 'triangle') {
+ ctx.beginPath();
+ ctx.moveTo(b.x + b.size / 2, b.y);
+ ctx.lineTo(b.x, b.y + b.size);
+ ctx.lineTo(b.x + b.size, b.y + b.size);
+ ctx.closePath();
+ ctx.stroke();
+ } else {
+ ctx.strokeRect(b.x, b.y, b.size, b.size);
+ }
ctx.shadowBlur = 0;
}
}
@@ -527,7 +598,7 @@
const sw = (((Date.now() / 15) % 360) * Math.PI) / 180;
ctx.beginPath();
ctx.moveTo(0, -32);
- ctx.lineTo(Math.cos(sw - Math.PI / 2) * 70, -32 + Math.sin(sw - Math.PI / 2) * 70);
+ ctx.lineTo(Math.cos(sw - Math.PI / 2) * 100, -32 + Math.sin(sw - Math.PI / 2) * 100);
ctx.stroke();
}
@@ -567,10 +638,25 @@
const b = blocks.find((bl) => bl.id === robot.holdingBlock);
if (b) {
ctx.fillStyle = b.color;
- ctx.fillRect(-b.size / 2, 55, b.size, b.size);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
- ctx.strokeRect(-b.size / 2, 55, b.size, b.size);
+ if (b.shape === 'circle') {
+ ctx.beginPath();
+ ctx.arc(0, 55 + b.size / 2, b.size / 2, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ } else if (b.shape === 'triangle') {
+ ctx.beginPath();
+ ctx.moveTo(0, 55);
+ ctx.lineTo(-b.size / 2, 55 + b.size);
+ ctx.lineTo(b.size / 2, 55 + b.size);
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ } else {
+ ctx.fillRect(-b.size / 2, 55, b.size, b.size);
+ ctx.strokeRect(-b.size / 2, 55, b.size, b.size);
+ }
}
}
}
@@ -647,7 +733,105 @@
render();
requestAnimationFrame(loop);
}
+
+ // ========== LIDAR DETECTION ==========
+ let lastDetectedId = null;
+
loop();
+
+ function segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
+ const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
+ if (denom === 0) return false;
+ const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
+ const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
+ return t >= 0 && t <= 1 && u >= 0 && u <= 1;
+ }
+
+ function segmentIntersectsRect(x1, y1, x2, y2, rx, ry, rw, rh) {
+ if (x2 >= rx && x2 <= rx + rw && y2 >= ry && y2 <= ry + rh) return true;
+ return (
+ segmentsIntersect(x1, y1, x2, y2, rx, ry, rx + rw, ry) ||
+ segmentsIntersect(x1, y1, x2, y2, rx + rw, ry, rx + rw, ry + rh) ||
+ segmentsIntersect(x1, y1, x2, y2, rx, ry + rh, rx + rw, ry + rh) ||
+ segmentsIntersect(x1, y1, x2, y2, rx, ry, rx, ry + rh)
+ );
+ }
+
+ function pointToSegmentDist(px, py, x1, y1, x2, y2) {
+ const A = px - x1, B = py - y1, C = x2 - x1, D = y2 - y1;
+ const lenSq = C * C + D * D;
+ const param = lenSq !== 0 ? Math.max(0, Math.min(1, (A * C + B * D) / lenSq)) : 0;
+ return Math.hypot(px - (x1 + param * C), py - (y1 + param * D));
+ }
+
+ function beamIntersectsBlock(x1, y1, x2, y2, b) {
+ const cx = b.x + b.size / 2, cy = b.y + b.size / 2;
+ if (b.shape === 'circle') {
+ return pointToSegmentDist(cx, cy, x1, y1, x2, y2) < b.size / 2;
+ } else if (b.shape === 'triangle') {
+ const tx1 = b.x + b.size / 2, ty1 = b.y;
+ const tx2 = b.x, ty2 = b.y + b.size;
+ const tx3 = b.x + b.size, ty3 = b.y + b.size;
+ // Check all three edges, or if beam endpoint is inside triangle
+ return (
+ segmentsIntersect(x1, y1, x2, y2, tx1, ty1, tx2, ty2) ||
+ segmentsIntersect(x1, y1, x2, y2, tx2, ty2, tx3, ty3) ||
+ segmentsIntersect(x1, y1, x2, y2, tx3, ty3, tx1, ty1)
+ );
+ } else {
+ return segmentIntersectsRect(x1, y1, x2, y2, b.x, b.y, b.size, b.size);
+ }
+ }
+
+ function getLidarDetection() {
+ if (!robot.parts.lidar || blocks.length === 0) return null;
+ const sw = (((Date.now() / 15) % 360) * Math.PI) / 180;
+ // Lidar center in world coordinates (local offset: 0, -32)
+ const lidarX = robot.x + 32 * Math.sin(robot.angle);
+ const lidarY = robot.y - 32 * Math.cos(robot.angle);
+ // World beam direction angle
+ const worldBeamAngle = sw - Math.PI / 2 + robot.angle;
+ const beamRange = 100;
+ const beamEndX = lidarX + Math.cos(worldBeamAngle) * beamRange;
+ const beamEndY = lidarY + Math.sin(worldBeamAngle) * beamRange;
+ for (const b of blocks) {
+ if (b.held) continue;
+ if (beamIntersectsBlock(lidarX, lidarY, beamEndX, beamEndY, b)) return b;
+ }
+ return null;
+ }
+
+ function getColorName(hslColor) {
+ const match = hslColor.match(/hsl\((\d+)/);
+ if (!match) return hslColor;
+ const hue = parseInt(match[1]);
+ const names = [
+ [15, 'Red'], [45, 'Orange'], [75, 'Yellow'], [105, 'Yellow-Green'],
+ [135, 'Green'], [165, 'Teal'], [195, 'Cyan'], [225, 'Sky Blue'],
+ [255, 'Blue'], [285, 'Purple'], [315, 'Magenta'], [345, 'Pink'],
+ ];
+ for (const [max, name] of names) {
+ if (hue < max) return name;
+ }
+ return 'Red';
+ }
+
+ function updateDetectionUI(detected) {
+ const el = document.getElementById('detection-info');
+ const indicator = document.getElementById('detection-indicator');
+ if (detected) {
+ const colorName = getColorName(detected.color);
+ const shapeName = detected.shape.charAt(0).toUpperCase() + detected.shape.slice(1);
+ el.innerHTML =
+ '● OBJECT DETECTED
' +
+ 'Shape: ' + shapeName + '
' +
+ 'Color: ' + colorName + '
';
+ indicator.classList.remove('hidden');
+ } else {
+ el.innerHTML = 'No objects detected
';
+ indicator.classList.add('hidden');
+ }
+ }