10 Commits

8 changed files with 423 additions and 15 deletions

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# MuneBase Swerve Sim
A browser-based simulator for Swerve Drive robots.
Live demo: https://swerve-vis.munebase.dev
You can host the project locally with a simple static server and open it in your browser:
- Python 3 (built-in):
python3 -m http.server 8000
Then open: http://localhost:8000
- Node.js (with npx):
npx http-server -c-1 .
Open `index.html` from the project root in your browser if your editor provides a static preview.
Notes
- Third-party scripts are in the `vendor/` directory.
License
This repository is provided as-is.

22
icons/192.svg Normal file
View File

@ -0,0 +1,22 @@
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" fill="#16213e"/>
<circle cx="96" cy="96" r="70" fill="none" stroke="#0f3460" stroke-width="3"/>
<!-- Swerve modules (4 corners) -->
<circle cx="56" cy="56" r="12" fill="#e94560"/>
<circle cx="136" cy="56" r="12" fill="#e94560"/>
<circle cx="56" cy="136" r="12" fill="#e94560"/>
<circle cx="136" cy="136" r="12" fill="#e94560"/>
<!-- Velocity arrows -->
<line x1="56" y1="56" x2="56" y2="40" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
<line x1="136" y1="56" x2="136" y2="40" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
<line x1="56" y1="136" x2="56" y2="152" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
<line x1="136" y1="136" x2="136" y2="152" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
<!-- Center point -->
<circle cx="96" cy="96" r="6" fill="#4ecca3"/>
<!-- Robot frame -->
<polygon points="56,56 136,56 136,136 56,136" fill="none" stroke="#4ecca3" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

41
icons/512.svg Normal file
View File

@ -0,0 +1,41 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#16213e"/>
<circle cx="256" cy="256" r="190" fill="none" stroke="#0f3460" stroke-width="8"/>
<!-- Swerve modules (4 corners) -->
<circle cx="150" cy="150" r="32" fill="#e94560"/>
<circle cx="362" cy="150" r="32" fill="#e94560"/>
<circle cx="150" cy="362" r="32" fill="#e94560"/>
<circle cx="362" cy="362" r="32" fill="#e94560"/>
<!-- Velocity arrows -->
<line x1="150" y1="150" x2="150" y2="100" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
<polygon points="150,90 140,110 160,110" fill="#ff6b6b"/>
<line x1="362" y1="150" x2="362" y2="100" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
<polygon points="362,90 352,110 372,110" fill="#ff6b6b"/>
<line x1="150" y1="362" x2="150" y2="412" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
<polygon points="150,422 140,402 160,402" fill="#ff6b6b"/>
<line x1="362" y1="362" x2="362" y2="412" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
<polygon points="362,422 352,402 372,402" fill="#ff6b6b"/>
<!-- Center point -->
<circle cx="256" cy="256" r="16" fill="#4ecca3"/>
<!-- Robot frame -->
<polygon points="150,150 362,150 362,362 150,362" fill="none" stroke="#4ecca3" stroke-width="6"/>
<!-- Grid lines in background -->
<g opacity="0.2" stroke="#4ecca3" stroke-width="1">
<line x1="100" y1="100" x2="100" y2="412"/>
<line x1="200" y1="100" x2="200" y2="412"/>
<line x1="300" y1="100" x2="300" y2="412"/>
<line x1="400" y1="100" x2="400" y2="412"/>
<line x1="100" y1="100" x2="412" y2="100"/>
<line x1="100" y1="200" x2="412" y2="200"/>
<line x1="100" y1="300" x2="412" y2="300"/>
<line x1="100" y1="400" x2="412" y2="400"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -4,7 +4,15 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#16213e">
<meta name="description"
content="Interactive simulation of a swerve drive robot with configurable size, speeds, and number of wheels">
<link rel="icon" type="image/svg+xml" href="/icons/512.svg">
<title>Swerve Drive Visualizer</title> <title>Swerve Drive Visualizer</title>
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
@ -199,10 +207,10 @@ if scale &lt; 1:
<legend>Translation &amp; Rotation</legend> <legend>Translation &amp; Rotation</legend>
<div class="control-group"> <div class="control-group">
<button id="control-mode-toggle" type="button">Switch to Keyboard Controls (WASD + QE)</button> <button id="control-mode-toggle" type="button">Switch to Slider Controls</button>
</div> </div>
<div id="slider-controls"> <div id="slider-controls" style="display: none;">
<div class="control-group"> <div class="control-group">
<label for="vy-slider">Move Forward/Backward (pixels/s)</label> <label for="vy-slider">Move Forward/Backward (pixels/s)</label>
<input type="range" id="vy-slider" min="-300" max="300" step="10" value="0"> <input type="range" id="vy-slider" min="-300" max="300" step="10" value="0">
@ -224,7 +232,7 @@ if scale &lt; 1:
<button id="reset-btn" type="button">Reset Controls</button> <button id="reset-btn" type="button">Reset Controls</button>
</div> </div>
<div id="keyboard-controls" style="display: none;"> <div id="keyboard-controls">
<div class="control-group"> <div class="control-group">
<label for="keyboard-max-speed">Max Speed (pixels/s)</label> <label for="keyboard-max-speed">Max Speed (pixels/s)</label>
<input type="range" id="keyboard-max-speed" min="50" max="300" step="10" value="150"> <input type="range" id="keyboard-max-speed" min="50" max="300" step="10" value="150">
@ -313,6 +321,45 @@ if scale &lt; 1:
<script type="module" src="vendor/lucio/graham-scan.mjs"></script> <script type="module" src="vendor/lucio/graham-scan.mjs"></script>
<script type="module" src="script.js"></script> <script type="module" src="script.js"></script>
<!-- Register Service Worker for PWA -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered successfully:', registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60000); // Check every minute
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available, prompt user to reload
if (confirm('A new version is available! Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
});
})
.catch((error) => {
console.log('Service Worker registration failed:', error);
});
// Handle controller change (new service worker activated)
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
});
}
</script>
</body> </body>
</html> </html>

22
manifest.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "MuneBase Swerve Drive Sim",
"short_name": "Swerve Sim",
"start_url": "/",
"display": "standalone",
"theme_color": "#16213e",
"background_color": "#16213e",
"icons": [
{
"src": "/icons/192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

233
script.js
View File

@ -4,6 +4,85 @@
import GrahamScan from "./vendor/lucio/graham-scan.mjs"; import GrahamScan from "./vendor/lucio/graham-scan.mjs";
class Joystick {
constructor(ctx, x, y, radius, visibleByDefault) {
this.ctx = ctx;
this.x = x;
this.y = y;
this.radius = radius;
this.nubX = x;
this.nubY = y;
this.active = false;
this.visible = visibleByDefault;
}
draw() {
if (!this.visible)
return;
this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
this.ctx.fillStyle = rootStyles.getPropertyValue('--primary-dark-blue');
this.ctx.fill();
this.ctx.beginPath();
this.ctx.arc(this.nubX, this.nubY, this.radius / 3, 0, Math.PI * 2);
this.ctx.fillStyle = rootStyles.getPropertyValue('--accent-blue');
this.ctx.fill();
this.ctx.restore();
}
checkShouldActivate(x, y) {
if (!this.touchInRange(x, y))
return;
this.active = true;
this.processTouch(x, y);
}
deactivate() {
this.active = false;
this.reset();
}
touchInRange(x, y) {
if (!this.visible)
return false;
const deltaX = x - this.x;
const deltaY = y - this.y;
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
return dist <= this.radius;
}
processTouch(x, y) {
if (this.touchInRange(x, y) && this.active) {
this.nubX = x;
this.nubY = y;
}
}
reset() {
this.nubX = this.x;
this.nubY = this.y;
}
getX() {
return (this.nubX - this.x) / this.radius;
}
getY() {
return (this.y - this.nubY) / this.radius;
}
setIsVisible(visible) {
this.visible = visible;
}
}
// 2D vector class to make some of the math easier // 2D vector class to make some of the math easier
class Vec2D { class Vec2D {
constructor(x, y) { constructor(x, y) {
@ -269,6 +348,15 @@ const PresetConfigs = {
* BEGIN DOM VARIABLES * BEGIN DOM VARIABLES
*/ */
// Get canvas
const canvas = document.getElementById('swerve-canvas');
// Get the canvas context as constant
const ctx = canvas.getContext('2d');
// Get CSS variables for use in canvas
const rootStyles = getComputedStyle(document.documentElement);
// Get all control elements // Get all control elements
const vxSlider = document.getElementById('vx-slider'); const vxSlider = document.getElementById('vx-slider');
const vySlider = document.getElementById('vy-slider'); const vySlider = document.getElementById('vy-slider');
@ -314,7 +402,7 @@ const preset16OctBtn = document.getElementById('preset-16oct');
*/ */
// Control mode state // Control mode state
let isManualInputMode = false; // true = keyboard/gamepad mode, false = slider mode let isManualInputMode = true; // true = keyboard/gamepad mode, false = slider mode
// Keyboard state tracking // Keyboard state tracking
const keyState = { const keyState = {
@ -362,6 +450,9 @@ keyboardMaxRotation.addEventListener('input', (e) => {
}); });
keyboardMaxRotationOutput.textContent = parseFloat(keyboardMaxRotation.value); keyboardMaxRotationOutput.textContent = parseFloat(keyboardMaxRotation.value);
const supportsTouch = (navigator.maxTouchPoints > 0);
// Control mode toggle // Control mode toggle
controlModeToggle.addEventListener('click', () => { controlModeToggle.addEventListener('click', () => {
isManualInputMode = !isManualInputMode; isManualInputMode = !isManualInputMode;
@ -379,17 +470,23 @@ controlModeToggle.addEventListener('click', () => {
vxOutput.textContent = '0'; vxOutput.textContent = '0';
vyOutput.textContent = '0'; vyOutput.textContent = '0';
omegaOutput.textContent = '0'; omegaOutput.textContent = '0';
if (supportsTouch) {
leftJoystick.setIsVisible(true);
rightJoystick.setIsVisible(true);
}
} else { } else {
// Switch to slider mode // Switch to slider mode
sliderControls.style.display = 'block'; sliderControls.style.display = 'block';
keyboardControls.style.display = 'none'; keyboardControls.style.display = 'none';
controlModeToggle.textContent = 'Switch to Keyboard Controls (WASD + QE)'; controlModeToggle.textContent = supportsTouch ? 'Switch to Keyboard/Joystick Controls' : 'Switch to Keyboard';
// Reset manual input state // Reset manual input state
Object.keys(keyState).forEach(key => keyState[key] = false); Object.keys(keyState).forEach(key => keyState[key] = false);
manualInputVelX = 0; manualInputVelX = 0;
manualInputVelY = 0; manualInputVelY = 0;
manualInputOmega = 0; manualInputOmega = 0;
leftJoystick.setIsVisible(false);
rightJoystick.setIsVisible(false);
} }
}); });
@ -552,6 +649,105 @@ applyCustomBtn.addEventListener('click', () => {
updateModuleDisplays(robot); updateModuleDisplays(robot);
}); });
function convertTouchToCanvas(inCoords) {
const rect = canvas.getBoundingClientRect();
// Calculate the scale factor between display size and canvas internal size
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
// Convert client coordinates to canvas coordinates, accounting for scaling
const x = (inCoords.x - rect.left) * scaleX - canvas.width / 2;
const y = (inCoords.y - rect.top) * scaleY - canvas.height / 2;
return { x, y };
}
canvas.addEventListener('mousedown', (event) => {
const canvasCoords = convertTouchToCanvas({ x: event.clientX, y: event.clientY });
leftJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
rightJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
});
canvas.addEventListener('mousemove', (event) => {
const canvasCoords = convertTouchToCanvas({ x: event.clientX, y: event.clientY });
leftJoystick.processTouch(canvasCoords.x, canvasCoords.y);
rightJoystick.processTouch(canvasCoords.x, canvasCoords.y);
});
canvas.addEventListener('mouseup', (event) => {
leftJoystick.deactivate();
rightJoystick.deactivate();
});
// Touch event listeners for mobile/tablet support
canvas.addEventListener('touchstart', (event) => {
event.preventDefault(); // Prevent scrolling and default touch behavior
for (let i = 0; i < event.touches.length; i++) {
const touch = event.touches[i];
const canvasCoords = convertTouchToCanvas({ x: touch.clientX, y: touch.clientY });
// alert(`X: ${canvasCoords.x}, Y: ${canvasCoords.y}`);
leftJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
rightJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
}
});
canvas.addEventListener('touchmove', (event) => {
event.preventDefault(); // Prevent scrolling while using joysticks
for (let i = 0; i < event.touches.length; i++) {
const touch = event.touches[i];
const canvasCoords = convertTouchToCanvas({ x: touch.clientX, y: touch.clientY });
leftJoystick.processTouch(canvasCoords.x, canvasCoords.y);
rightJoystick.processTouch(canvasCoords.x, canvasCoords.y);
}
});
canvas.addEventListener('touchend', (event) => {
event.preventDefault();
// If no touches remain, deactivate both joysticks
if (event.touches.length === 0) {
leftJoystick.deactivate();
rightJoystick.deactivate();
} else {
// Check if the remaining touches are still in range of the joysticks
let leftStillActive = false;
let rightStillActive = false;
for (let i = 0; i < event.touches.length; i++) {
const touch = event.touches[i];
const canvasCoords = convertTouchToCanvas({ x: touch.clientX, y: touch.clientY });
if (leftJoystick.touchInRange(canvasCoords.x, canvasCoords.y)) {
leftStillActive = true;
}
if (rightJoystick.touchInRange(canvasCoords.x, canvasCoords.y)) {
rightStillActive = true;
}
}
if (!leftStillActive) {
leftJoystick.deactivate();
leftJoystick.reset();
}
if (!rightStillActive) {
rightJoystick.deactivate();
rightJoystick.reset();
}
}
});
canvas.addEventListener('touchcancel', (event) => {
// Handle touch cancellation (e.g., when system interrupts)
leftJoystick.deactivate();
leftJoystick.reset();
rightJoystick.deactivate();
rightJoystick.reset();
});
/* /*
* END LISTENER CODE * END LISTENER CODE
* BEGIN DYNAMIC DOM FUNCTIONS * BEGIN DYNAMIC DOM FUNCTIONS
@ -696,12 +892,8 @@ function updateModuleDisplays(robot) {
* BEGIN ANIMATION CODE * BEGIN ANIMATION CODE
*/ */
// Get the canvas and context as constants const leftJoystick = new Joystick(ctx, -250, 250, 100, supportsTouch);
const canvas = document.getElementById('swerve-canvas'); const rightJoystick = new Joystick(ctx, 250, 250, 100, supportsTouch);
const ctx = canvas.getContext('2d');
// Get CSS variables for use in canvas
const rootStyles = getComputedStyle(document.documentElement);
function drawGrid(ctx, sideLength, gridSquareSize, xOffset, yOffset) { function drawGrid(ctx, sideLength, gridSquareSize, xOffset, yOffset) {
ctx.save(); ctx.save();
@ -806,8 +998,9 @@ function drawRobot(ctx, robot, heading) {
ctx.restore(); // Restore to remove rotation ctx.restore(); // Restore to remove rotation
} }
// Initialize Variables // Initialize Variables
// General robot
const robotSize = 200; const robotSize = 200;
const defaultModulePositions = PresetConfigs.fourWheelSquare(robotSize); const defaultModulePositions = PresetConfigs.fourWheelSquare(robotSize);
const robot = new SwerveDrive(defaultModulePositions, "4-Wheel Square"); const robot = new SwerveDrive(defaultModulePositions, "4-Wheel Square");
@ -829,9 +1022,22 @@ function animate() {
// Update speeds based on control mode // Update speeds based on control mode
if (isManualInputMode) { if (isManualInputMode) {
xSpeed = manualInputVelX; const maxSpeed = parseFloat(keyboardMaxSpeed.value);
ySpeed = -manualInputVelY; // Negative because canvas Y axis is inverted const maxRotation = parseFloat(keyboardMaxRotation.value);
turnSpeed = manualInputOmega;
// Combine keyboard and joystick inputs
const keyboardX = manualInputVelX;
const keyboardY = -manualInputVelY; // Negative because canvas Y axis is inverted
const keyboardOmega = manualInputOmega;
const joystickX = leftJoystick.getX() * maxSpeed;
const joystickY = -leftJoystick.getY() * maxSpeed;
const joystickOmega = rightJoystick.getX() * maxRotation;
// Use joystick if active, otherwise use keyboard
xSpeed = leftJoystick.active ? joystickX : keyboardX;
ySpeed = leftJoystick.active ? joystickY : keyboardY;
turnSpeed = rightJoystick.active ? joystickOmega : keyboardOmega;
} else { } else {
xSpeed = parseFloat(vxSlider.value); xSpeed = parseFloat(vxSlider.value);
ySpeed = -parseFloat(vySlider.value); ySpeed = -parseFloat(vySlider.value);
@ -870,6 +1076,9 @@ function animate() {
drawGrid(ctx, canvas.width * 2, gridSquareSize, xGridOffset, yGridOffset); drawGrid(ctx, canvas.width * 2, gridSquareSize, xGridOffset, yGridOffset);
drawRobot(ctx, robot, robot.gyroHeading); drawRobot(ctx, robot, robot.gyroHeading);
leftJoystick.draw();
rightJoystick.draw();
// Do it all over again // Do it all over again
ctx.restore(); ctx.restore();
requestAnimationFrame(animate); requestAnimationFrame(animate);

View File

@ -361,4 +361,10 @@ button:hover {
.readout .value { .readout .value {
color: var(--text-light); color: var(--text-light);
font-weight: bold; font-weight: bold;
}
@media only screen and (max-width: 768px) {
main {
display: block;
}
} }

33
sw.js Normal file
View File

@ -0,0 +1,33 @@
const CACHE_NAME = 'v1';
const ASSETS = [
'/',
'/index.html',
'/script.js',
'/styles.css',
'/manifest.json',
'/icons/192.svg',
'/icons/512.svg',
'/vendor/lucio/graham-scan.mjs'
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then(response => response || fetch(e.request))
);
});