Compare commits
15 Commits
moveRotati
...
mobileFrie
| Author | SHA1 | Date | |
|---|---|---|---|
|
c225ee696a
|
|||
|
fb8cf9bbfc
|
|||
|
402b147654
|
|||
|
e9d3b71f28
|
|||
|
e593efafa4
|
|||
|
7d02789a39
|
|||
|
b1b67fb0bd
|
|||
|
62bb28ca9d
|
|||
|
07204a476e
|
|||
|
5662142274
|
|||
|
83049815db
|
|||
|
ba710fcf5d
|
|||
|
0bc7417f35
|
|||
|
2684ed2c72
|
|||
|
b766527a97
|
28
README.md
Normal file
28
README.md
Normal 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
22
icons/192.svg
Normal 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
41
icons/512.svg
Normal 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 |
299
index.html
299
index.html
@ -4,7 +4,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
|
||||
@ -14,6 +22,179 @@
|
||||
<p>Interactive simulation of a swerve drive robot with configurable size, speeds, and number of wheels</p>
|
||||
</header>
|
||||
<main>
|
||||
<section class="documentation">
|
||||
<h2>About This Project</h2>
|
||||
<details>
|
||||
<summary>How To Use</summary>
|
||||
<div class="documentation-content">
|
||||
<h3>Getting Started</h3>
|
||||
<p>This interactive visualizer demonstrates how a swerve drive robot moves based on commanded
|
||||
velocities. Use the controls to experiment with different configurations and movement patterns.
|
||||
</p>
|
||||
|
||||
<h3>Drive Controls</h3>
|
||||
<ul>
|
||||
<li><strong>Strafe Left/Right:</strong> Controls the robot's velocity in the X direction
|
||||
(field-relative). Positive values move right, negative values move left.</li>
|
||||
<li><strong>Move Forward/Backward:</strong> Controls the robot's velocity in the Y direction
|
||||
(field-relative). Positive values move forward, negative values move backward.</li>
|
||||
<li><strong>Rotation:</strong> Controls the robot's angular velocity (turn rate) in radians per
|
||||
second. Positive values rotate counter-clockwise.</li>
|
||||
<li><strong>Max Module Speed:</strong> Sets the maximum speed limit for any individual swerve
|
||||
module. If calculated speeds exceed this, all modules are scaled proportionally.</li>
|
||||
<li><strong>Reset Controls:</strong> Returns all velocity sliders to zero.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Preset Configurations</h3>
|
||||
<p>Choose from 9 pre-built robot configurations ranging from 2 to 16 wheels. Each preset
|
||||
demonstrates different module arrangements:</p>
|
||||
<ul>
|
||||
<li><strong>2-Wheel:</strong> Differential drive arrangement</li>
|
||||
<li><strong>3-Wheel Triangle:</strong> Three modules in an equilateral triangle</li>
|
||||
<li><strong>4-Wheel Square:</strong> Classic square configuration</li>
|
||||
<li><strong>4-Wheel Rectangle:</strong> Rectangular configuration for longer robots</li>
|
||||
<li><strong>6-Wheel Hexagon:</strong> Hexagonal arrangement</li>
|
||||
<li><strong>8-Wheel Octagon:</strong> Octagonal arrangement</li>
|
||||
<li><strong>8-Wheel Square:</strong> Double-layered square with inner and outer modules</li>
|
||||
<li><strong>12-Wheel Hexagon:</strong> Double-layered hexagonal arrangement</li>
|
||||
<li><strong>16-Wheel Octagon:</strong> Double-layered octagonal arrangement</li>
|
||||
</ul>
|
||||
|
||||
<h3>Custom Configurations</h3>
|
||||
<p>Create your own robot configuration:</p>
|
||||
<ol>
|
||||
<li>Enter the desired number of modules (Minimum of 2)</li>
|
||||
<li>Click <strong>Generate Position Inputs</strong> to create input fields</li>
|
||||
<li>For each module, specify:
|
||||
<ul>
|
||||
<li><strong>Module Name:</strong> A label for the module</li>
|
||||
<li><strong>X Position:</strong> Distance from robot center (pixels, positive = right)
|
||||
</li>
|
||||
<li><strong>Y Position:</strong> Distance from robot center (pixels, positive = up)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click <strong>Apply Custom Configuration</strong> to see your design</li>
|
||||
<li>Use <strong>Remove Position Inputs</strong> to clear the custom fields. This does not reset
|
||||
the robot, only clears the input box</li>
|
||||
</ol>
|
||||
|
||||
<h3>Understanding the Visualization</h3>
|
||||
<ul>
|
||||
<li><strong>Robot Frame:</strong> The filled polygon connecting the outer-most module positions
|
||||
</li>
|
||||
<li><strong>Modules:</strong> Circular markers at each wheel position</li>
|
||||
<li><strong>Velocity Arrows:</strong> Red arrows showing the direction and magnitude of each
|
||||
module's velocity</li>
|
||||
<li><strong>Grid:</strong> Moves relative to the robot to show field-relative motion</li>
|
||||
<li><strong>Gyro Heading:</strong> The current rotation angle of the robot in degrees</li>
|
||||
</ul>
|
||||
|
||||
<h3>Module States Panel</h3>
|
||||
<p>Displays real-time information for each module:</p>
|
||||
<ul>
|
||||
<li><strong>Angle:</strong> The direction the module is pointing (in degrees)</li>
|
||||
<li><strong>Speed:</strong> The velocity of the module (in pixels/second)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Explanation of Swerve Kinematics</summary>
|
||||
<div class="documentation-content">
|
||||
<h3>What is Swerve Drive?</h3>
|
||||
<p>Swerve drive (also called holonomic drive) is a drivetrain design where each wheel module can
|
||||
independently rotate and drive in any direction. This allows the robot to move in any direction
|
||||
while simultaneously rotating, providing exceptional maneuverability.</p>
|
||||
|
||||
<h3>Kinematic Equations</h3>
|
||||
<p>The simulator calculates each module's state using inverse kinematics. Given a desired robot
|
||||
velocity (v<sub>x</sub>, v<sub>y</sub>) and rotation rate (ω), we calculate each module's
|
||||
required velocity.</p>
|
||||
|
||||
<h4>Field-Relative vs Robot-Relative</h4>
|
||||
<p>This simulator uses <strong>field-relative control</strong>, meaning the velocity commands are
|
||||
relative to the field, not the robot's current orientation. The inputs are transformed to
|
||||
robot-relative coordinates using the current gyro heading:</p>
|
||||
<pre>
|
||||
v<sub>robot_x</sub> = v<sub>field_x</sub> × cos(-θ) - v<sub>field_y</sub> × sin(-θ)
|
||||
v<sub>robot_y</sub> = v<sub>field_x</sub> × sin(-θ) + v<sub>field_y</sub> × cos(-θ)
|
||||
</pre>
|
||||
<p>Where θ is the robot's heading angle (gyro reading).</p>
|
||||
|
||||
<h4>Module Velocity Calculation</h4>
|
||||
<p>For each module at position (x<sub>i</sub>, y<sub>i</sub>) relative to the robot's center of
|
||||
rotation:</p>
|
||||
<ol>
|
||||
<li><strong>Translation component:</strong> The robot's linear velocity (v<sub>robot_x</sub>,
|
||||
v<sub>robot_y</sub>)</li>
|
||||
<li><strong>Rotation component:</strong> Perpendicular to the position vector, with magnitude
|
||||
proportional to distance from center:
|
||||
<pre>
|
||||
v<sub>rot_x</sub> = -y<sub>i</sub> × ω
|
||||
v<sub>rot_y</sub> = x<sub>i</sub> × ω
|
||||
</pre>
|
||||
</li>
|
||||
<li><strong>Combined velocity:</strong> Vector sum of translation and rotation:
|
||||
<pre>
|
||||
v<sub>module_x</sub> = v<sub>robot_x</sub> + v<sub>rot_x</sub>
|
||||
v<sub>module_y</sub> = v<sub>robot_y</sub> + v<sub>rot_y</sub>
|
||||
</pre>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h4>Module Angle and Speed</h4>
|
||||
<p>From the module's velocity vector, we calculate:</p>
|
||||
<ul>
|
||||
<li><strong>Speed:</strong> The magnitude of the velocity vector: √(v<sub>x</sub>² +
|
||||
v<sub>y</sub>²)</li>
|
||||
<li><strong>Angle:</strong> The direction of the velocity vector: arctan2(v<sub>y</sub>,
|
||||
v<sub>x</sub>)</li>
|
||||
</ul>
|
||||
|
||||
<h4>Speed Normalization</h4>
|
||||
<p>If any module's calculated speed exceeds the maximum allowed speed, all module velocities are
|
||||
scaled proportionally. This preserves the movement direction while respecting hardware limits:
|
||||
</p>
|
||||
<pre>
|
||||
scale = max_speed / max(calculated_speeds)
|
||||
if scale < 1:
|
||||
all_module_speeds × scale
|
||||
</pre>
|
||||
|
||||
<h3>Gyro Integration</h3>
|
||||
<p>The robot's heading (gyro angle) is continuously updated by integrating the rotation rate:</p>
|
||||
<pre>
|
||||
θ<sub>new</sub> = θ<sub>old</sub> + ω × Δt
|
||||
</pre>
|
||||
<p>Where Δt is the time step. The heading is normalized to stay within the range [-π, π].</p>
|
||||
|
||||
<h3>Real-World Applications</h3>
|
||||
<p>Swerve drive systems are commonly used in:</p>
|
||||
<ul>
|
||||
<li><strong>FRC (FIRST Robotics Competition):</strong> For competitive robots requiring precise
|
||||
positioning</li>
|
||||
<li><strong>Industrial AGVs:</strong> Automated guided vehicles in warehouses</li>
|
||||
<li><strong>Research Platforms:</strong> Mobile robots requiring omnidirectional movement</li>
|
||||
</ul>
|
||||
|
||||
<h3>Key Advantages</h3>
|
||||
<ul>
|
||||
<li>True holonomic motion (can move in any direction without rotating)</li>
|
||||
<li>Can translate and rotate simultaneously</li>
|
||||
<li>Excellent maneuverability in constrained spaces</li>
|
||||
<li>No "drift" or unwanted rotation during translation</li>
|
||||
</ul>
|
||||
|
||||
<h3>Implementation Considerations</h3>
|
||||
<ul>
|
||||
<li><strong>Mechanical Complexity:</strong> Each module requires two motors (drive and steering)
|
||||
</li>
|
||||
<li><strong>Control Complexity:</strong> Requires coordinated control of all modules</li>
|
||||
<li><strong>Sensor Requirements:</strong> Absolute encoders recommended for module angles</li>
|
||||
<li><strong>Cost:</strong> More expensive than traditional drivetrains</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<section class="visualization-canvas">
|
||||
<h2>Robot Visualization</h2>
|
||||
<canvas id="swerve-canvas" width="800" height="800"></canvas>
|
||||
@ -26,24 +207,57 @@
|
||||
<legend>Translation & Rotation</legend>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="vx-slider">Strafe Left/Right (pixels/s)</label>
|
||||
<input type="range" id="vx-slider" min="-300" max="300" step="10" value="0">
|
||||
<output id="vx-value">0</output>
|
||||
<button id="control-mode-toggle" type="button">Switch to Keyboard/Joystick Controls</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="vy-slider">Move Forward/Backward (pixels/s)</label>
|
||||
<input type="range" id="vy-slider" min="-300" max="300" step="10" value="0">
|
||||
<output id="vy-value">0</output>
|
||||
<div id="slider-controls">
|
||||
<div class="control-group">
|
||||
<label for="vy-slider">Move Forward/Backward (pixels/s)</label>
|
||||
<input type="range" id="vy-slider" min="-300" max="300" step="10" value="0">
|
||||
<output id="vy-value">0</output>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="vx-slider">Strafe Left/Right (pixels/s)</label>
|
||||
<input type="range" id="vx-slider" min="-300" max="300" step="10" value="0">
|
||||
<output id="vx-value">0</output>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="omega-slider">Rotation (rad/s)</label>
|
||||
<input type="range" id="omega-slider" min="-3" max="3" step="0.1" value="0">
|
||||
<output id="omega-value">0</output>
|
||||
</div>
|
||||
|
||||
<button id="reset-btn" type="button">Reset Controls</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label for="omega-slider">Rotation (rad/s)</label>
|
||||
<input type="range" id="omega-slider" min="-3" max="3" step="0.1" value="0">
|
||||
<output id="omega-value">0</output>
|
||||
</div>
|
||||
<div id="keyboard-controls" style="display: none;">
|
||||
<div class="control-group">
|
||||
<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">
|
||||
<output id="keyboard-max-speed-value">150</output>
|
||||
</div>
|
||||
|
||||
<button id="reset-btn" type="button">Reset Controls</button>
|
||||
<div class="control-group">
|
||||
<label for="keyboard-max-rotation">Max Rotation (rad/s)</label>
|
||||
<input type="range" id="keyboard-max-rotation" min="0.5" max="3" step="0.1" value="1.5">
|
||||
<output id="keyboard-max-rotation-value">1.5</output>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-instructions">
|
||||
<h4>Keyboard Controls:</h4>
|
||||
<ul>
|
||||
<li><strong>W:</strong> Move Forward</li>
|
||||
<li><strong>A:</strong> Strafe Left</li>
|
||||
<li><strong>S:</strong> Move Backward</li>
|
||||
<li><strong>D:</strong> Strafe Right</li>
|
||||
<li><strong>Q:</strong> Rotate Counter-Clockwise</li>
|
||||
<li><strong>E:</strong> Rotate Clockwise</li>
|
||||
</ul>
|
||||
<p><em>Click on the canvas area to focus for keyboard input</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
@ -51,7 +265,7 @@
|
||||
|
||||
<div class="control-group">
|
||||
<label for="max-speed-slider">Max Module Speed (pixels/s)</label>
|
||||
<input type="range" id="max-speed-slider" min="1" max="300" step="10" value="150">
|
||||
<input type="range" id="max-speed-slider" min="200" max="1000" step="10" value="400">
|
||||
<output id="max-speed-value">0</output>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -67,6 +281,9 @@
|
||||
<button id="preset-4rect" type="button">4-Wheel Rectangle</button>
|
||||
<button id="preset-6wheel" type="button">6-Wheel Hexagon</button>
|
||||
<button id="preset-8wheel" type="button">8-Wheel Octagon</button>
|
||||
<button id="preset-8square" type="button">8-Wheel Square</button>
|
||||
<button id="preset-12hex" type="button">12-Wheel Hexagon</button>
|
||||
<button id="preset-16oct" type="button">16-Wheel Octogon</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
@ -74,7 +291,7 @@
|
||||
|
||||
<div class="control-group">
|
||||
<label for="module-count">Number of Modules</label>
|
||||
<input type="number" id="module-count" min="2" max="12" value="4" step="1">
|
||||
<input type="number" id="module-count" min="2" max="64" value="4" step="1">
|
||||
</div>
|
||||
|
||||
<button id="generate-inputs-btn" type="button">Generate Position Inputs</button>
|
||||
@ -99,18 +316,50 @@
|
||||
<!-- Dynamically generated module data will appear here -->
|
||||
</div>
|
||||
</section>
|
||||
<section class="documentation">
|
||||
<h2>About This Project</h2>
|
||||
<details>
|
||||
<summary>How To Use</summary>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Explaination of Swerve Kinematics</summary>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="script.js"></script>
|
||||
<script type="module" src="vendor/lucio/graham-scan.mjs"></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>
|
||||
|
||||
</html>
|
||||
22
manifest.json
Normal file
22
manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
619
script.js
619
script.js
@ -2,6 +2,84 @@
|
||||
* BEGIN CLASS DECLARATIONS
|
||||
*/
|
||||
|
||||
import GrahamScan from "./vendor/lucio/graham-scan.mjs";
|
||||
|
||||
class Joystick {
|
||||
constructor(ctx, x, y, radius) {
|
||||
this.ctx = ctx;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.radius = radius;
|
||||
this.nubX = x;
|
||||
this.nubY = y;
|
||||
this.active = false;
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
class Vec2D {
|
||||
constructor(x, y) {
|
||||
@ -84,18 +162,19 @@ class SwerveDrive {
|
||||
}
|
||||
|
||||
drive(velocityX, velocityY, turnSpeed, maxModuleSpeed, deltaTime = 0.01) {
|
||||
// Update gyro heading first
|
||||
this.updateHeading(turnSpeed, deltaTime);
|
||||
// Store the requested turn speed for later calculation of actual turn speed
|
||||
this.requestedTurnSpeed = turnSpeed;
|
||||
|
||||
// Take in a requested speeds and update every module
|
||||
// Take in a requested speeds and update every module (but don't update heading yet)
|
||||
this.modules.forEach(module =>
|
||||
module.calculateState(velocityX, velocityY, turnSpeed, this.gyroHeading)
|
||||
);
|
||||
|
||||
// If any speeds exceed the max speed, normalize down so we don't effect movement direction
|
||||
const maxCalculated = Math.max(...this.modules.map(m => m.speed), 0);
|
||||
let scale = 1.0;
|
||||
if (maxCalculated > maxModuleSpeed) {
|
||||
const scale = maxModuleSpeed / maxCalculated;
|
||||
scale = maxModuleSpeed / maxCalculated;
|
||||
this.modules.forEach(module => {
|
||||
module.velocity.x *= scale;
|
||||
module.velocity.y *= scale;
|
||||
@ -103,6 +182,38 @@ class SwerveDrive {
|
||||
module.angle = module.velocity.angle();
|
||||
});
|
||||
}
|
||||
|
||||
// Update heading with the actual turn speed (scaled if modules were limited)
|
||||
const actualTurnSpeed = turnSpeed * scale;
|
||||
this.updateHeading(actualTurnSpeed, deltaTime);
|
||||
this.actualTurnSpeed = actualTurnSpeed;
|
||||
}
|
||||
|
||||
getActualVelocity() {
|
||||
// Calculate the actual robot velocity from the average of module velocities
|
||||
// This returns the velocity in robot-relative coordinates
|
||||
if (this.modules.length === 0) return new Vec2D(0, 0);
|
||||
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
|
||||
// Average the module velocities (they're in robot frame)
|
||||
this.modules.forEach(module => {
|
||||
sumX += module.velocity.x;
|
||||
sumY += module.velocity.y;
|
||||
});
|
||||
|
||||
const avgX = sumX / this.modules.length;
|
||||
const avgY = sumY / this.modules.length;
|
||||
|
||||
// Transform back to field-relative coordinates
|
||||
const cosHeading = Math.cos(this.gyroHeading);
|
||||
const sinHeading = Math.sin(this.gyroHeading);
|
||||
|
||||
const fieldVelX = avgX * cosHeading - avgY * sinHeading;
|
||||
const fieldVelY = avgX * sinHeading + avgY * cosHeading;
|
||||
|
||||
return new Vec2D(fieldVelX, fieldVelY);
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,7 +268,7 @@ const PresetConfigs = {
|
||||
return modules;
|
||||
},
|
||||
|
||||
eightWheel: (size) => {
|
||||
eightWheelOctogon: (size) => {
|
||||
const radius = size / 2;
|
||||
const modules = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
@ -169,7 +280,64 @@ const PresetConfigs = {
|
||||
});
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
},
|
||||
|
||||
eightWheelSquare: (size) => {
|
||||
const full = size;
|
||||
const half = size / 2;
|
||||
return [
|
||||
{ x: full, y: full, name: "Outer FL" },
|
||||
{ x: full, y: -full, name: "Outer FR" },
|
||||
{ x: -full, y: full, name: "Outer BL" },
|
||||
{ x: -full, y: -full, name: "Outer BR" },
|
||||
{ x: half, y: half, name: "Inner FL" },
|
||||
{ x: half, y: -half, name: "Inner FR" },
|
||||
{ x: -half, y: half, name: "Inner BL" },
|
||||
{ x: -half, y: -half, name: "Inner BR" }
|
||||
];
|
||||
},
|
||||
|
||||
twelveWheelHexagon: (size) => {
|
||||
const outerRadius = size;
|
||||
const innerRadius = size / 2;
|
||||
const modules = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 2) + (i * Math.PI / 3);
|
||||
modules.push({
|
||||
x: outerRadius * Math.cos(angle),
|
||||
y: outerRadius * Math.sin(angle),
|
||||
name: `Module ${i + 1}`
|
||||
});
|
||||
|
||||
modules.push({
|
||||
x: innerRadius * Math.cos(angle),
|
||||
y: innerRadius * Math.sin(angle),
|
||||
name: `Module ${i + 7}`
|
||||
});
|
||||
}
|
||||
return modules;
|
||||
},
|
||||
|
||||
sixteenWheelOctogon: (size) => {
|
||||
const outerRadius = size;
|
||||
const innerRadius = size / 2;
|
||||
const modules = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (Math.PI / 2) + (i * Math.PI / 4);
|
||||
modules.push({
|
||||
x: outerRadius * Math.cos(angle),
|
||||
y: outerRadius * Math.sin(angle),
|
||||
name: `Module ${i + 1}`
|
||||
});
|
||||
|
||||
modules.push({
|
||||
x: innerRadius * Math.cos(angle),
|
||||
y: innerRadius * Math.sin(angle),
|
||||
name: `Module ${i + 9}`
|
||||
});
|
||||
}
|
||||
return modules;
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
@ -177,6 +345,15 @@ const PresetConfigs = {
|
||||
* 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
|
||||
const vxSlider = document.getElementById('vx-slider');
|
||||
const vySlider = document.getElementById('vy-slider');
|
||||
@ -196,6 +373,15 @@ const generateInputsBtn = document.getElementById('generate-inputs-btn');
|
||||
const clearInputsBtn = document.getElementById('delete-inputs-btn');
|
||||
const applyCustomBtn = document.getElementById('apply-custom-btn');
|
||||
|
||||
// Get control mode elements
|
||||
const controlModeToggle = document.getElementById('control-mode-toggle');
|
||||
const sliderControls = document.getElementById('slider-controls');
|
||||
const keyboardControls = document.getElementById('keyboard-controls');
|
||||
const keyboardMaxSpeed = document.getElementById('keyboard-max-speed');
|
||||
const keyboardMaxSpeedOutput = document.getElementById('keyboard-max-speed-value');
|
||||
const keyboardMaxRotation = document.getElementById('keyboard-max-rotation');
|
||||
const keyboardMaxRotationOutput = document.getElementById('keyboard-max-rotation-value');
|
||||
|
||||
// Preset buttons
|
||||
const preset2WheelBtn = document.getElementById('preset-2wheel');
|
||||
const preset3WheelBtn = document.getElementById('preset-3wheel');
|
||||
@ -203,32 +389,42 @@ const preset4WheelBtn = document.getElementById('preset-4wheel');
|
||||
const preset4RectBtn = document.getElementById('preset-4rect');
|
||||
const preset6WheelBtn = document.getElementById('preset-6wheel');
|
||||
const preset8WheelBtn = document.getElementById('preset-8wheel');
|
||||
const preset8SquareBtn = document.getElementById('preset-8square');
|
||||
const preset12HexBtn = document.getElementById('preset-12hex');
|
||||
const preset16OctBtn = document.getElementById('preset-16oct');
|
||||
|
||||
/*
|
||||
* END DOM VARIABLES
|
||||
* BEGIN CONTROL MODE AND INPUT DEVICE VARIABLES
|
||||
*/
|
||||
|
||||
// Control mode state
|
||||
let isManualInputMode = false; // true = keyboard/gamepad mode, false = slider mode
|
||||
|
||||
// Keyboard state tracking
|
||||
const keyState = {
|
||||
w: false, // Forward
|
||||
a: false, // Left
|
||||
s: false, // Backward
|
||||
d: false, // Right
|
||||
q: false, // Counter-clockwise
|
||||
e: false // Clockwise
|
||||
};
|
||||
|
||||
// Current manual input velocities (from keyboard or gamepad)
|
||||
let manualInputVelX = 0;
|
||||
let manualInputVelY = 0;
|
||||
let manualInputOmega = 0;
|
||||
|
||||
/*
|
||||
* END CONTROL MODE AND INPUT DEVICE VARIABLES
|
||||
* BEGIN LISTENER CODE
|
||||
*/
|
||||
|
||||
|
||||
vxSlider.addEventListener('input', (e) => {
|
||||
vxOutput.textContent = parseFloat(e.target.value);
|
||||
});
|
||||
vxOutput.textContent = parseFloat(vxSlider.value);
|
||||
|
||||
vySlider.addEventListener('input', (e) => {
|
||||
vyOutput.textContent = parseFloat(e.target.value);
|
||||
});
|
||||
vyOutput.textContent = parseFloat(vySlider.value);
|
||||
|
||||
omegaSlider.addEventListener('input', (e) => {
|
||||
omegaOutput.textContent = parseFloat(e.target.value);
|
||||
});
|
||||
omegaOutput.textContent = parseFloat(omegaSlider.value);
|
||||
|
||||
maxSpeedSlider.addEventListener('input', (e) => {
|
||||
maxSpeedOutput.textContent = parseFloat(e.target.value);
|
||||
maxSpeedOutput.textContent = e.target.value;
|
||||
});
|
||||
maxSpeedOutput.textContent = parseFloat(maxSpeedSlider.value);
|
||||
maxSpeedOutput.textContent = maxSpeedSlider.value;
|
||||
|
||||
resetBtn.addEventListener('click', (e) => {
|
||||
vxSlider.value = 0;
|
||||
@ -240,6 +436,97 @@ resetBtn.addEventListener('click', (e) => {
|
||||
omegaOutput.textContent = parseFloat(omegaSlider.value);
|
||||
});
|
||||
|
||||
// Keyboard control sliders
|
||||
keyboardMaxSpeed.addEventListener('input', (e) => {
|
||||
keyboardMaxSpeedOutput.textContent = parseFloat(e.target.value);
|
||||
});
|
||||
keyboardMaxSpeedOutput.textContent = parseFloat(keyboardMaxSpeed.value);
|
||||
|
||||
keyboardMaxRotation.addEventListener('input', (e) => {
|
||||
keyboardMaxRotationOutput.textContent = parseFloat(e.target.value);
|
||||
});
|
||||
keyboardMaxRotationOutput.textContent = parseFloat(keyboardMaxRotation.value);
|
||||
|
||||
// Control mode toggle
|
||||
controlModeToggle.addEventListener('click', () => {
|
||||
isManualInputMode = !isManualInputMode;
|
||||
|
||||
if (isManualInputMode) {
|
||||
// Switch to manual input mode (keyboard/gamepad)
|
||||
sliderControls.style.display = 'none';
|
||||
keyboardControls.style.display = 'block';
|
||||
controlModeToggle.textContent = 'Switch to Slider Controls';
|
||||
|
||||
// Reset slider values when switching to manual input
|
||||
vxSlider.value = 0;
|
||||
vySlider.value = 0;
|
||||
omegaSlider.value = 0;
|
||||
vxOutput.textContent = '0';
|
||||
vyOutput.textContent = '0';
|
||||
omegaOutput.textContent = '0';
|
||||
leftJoystick.setIsVisible(true);
|
||||
rightJoystick.setIsVisible(true);
|
||||
} else {
|
||||
// Switch to slider mode
|
||||
sliderControls.style.display = 'block';
|
||||
keyboardControls.style.display = 'none';
|
||||
controlModeToggle.textContent = 'Switch to Keyboard/Joystick Controls';
|
||||
|
||||
// Reset manual input state
|
||||
Object.keys(keyState).forEach(key => keyState[key] = false);
|
||||
manualInputVelX = 0;
|
||||
manualInputVelY = 0;
|
||||
manualInputOmega = 0;
|
||||
leftJoystick.setIsVisible(false);
|
||||
rightJoystick.setIsVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard event listeners
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!isManualInputMode) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
if (key in keyState) {
|
||||
keyState[key] = true;
|
||||
e.preventDefault();
|
||||
updateManualInputVelocities();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (!isManualInputMode) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
if (key in keyState) {
|
||||
keyState[key] = false;
|
||||
e.preventDefault();
|
||||
updateManualInputVelocities();
|
||||
}
|
||||
});
|
||||
|
||||
// Function to update velocities based on manual input devices (keyboard/gamepad)
|
||||
function updateManualInputVelocities() {
|
||||
const maxSpeed = parseFloat(keyboardMaxSpeed.value);
|
||||
const maxRotation = parseFloat(keyboardMaxRotation.value);
|
||||
|
||||
// Calculate translation velocities from keyboard input
|
||||
manualInputVelX = 0;
|
||||
manualInputVelY = 0;
|
||||
|
||||
if (keyState.d) manualInputVelX += maxSpeed; // Right
|
||||
if (keyState.a) manualInputVelX -= maxSpeed; // Left
|
||||
if (keyState.w) manualInputVelY += maxSpeed; // Forward
|
||||
if (keyState.s) manualInputVelY -= maxSpeed; // Backward
|
||||
|
||||
// Calculate rotation velocity from keyboard input
|
||||
manualInputOmega = 0;
|
||||
if (keyState.e) manualInputOmega += maxRotation; // Clockwise
|
||||
if (keyState.q) manualInputOmega -= maxRotation; // Counter-clockwise
|
||||
|
||||
// TODO: Add gamepad input processing here
|
||||
}
|
||||
|
||||
// Preset button event listeners
|
||||
preset2WheelBtn.addEventListener('click', () => {
|
||||
const positions = PresetConfigs.twoWheel(robotSize);
|
||||
@ -282,18 +569,44 @@ preset6WheelBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
preset8WheelBtn.addEventListener('click', () => {
|
||||
const positions = PresetConfigs.eightWheel(robotSize);
|
||||
const positions = PresetConfigs.eightWheelOctogon(robotSize);
|
||||
robot.setModules(positions);
|
||||
robot.setName("8-Wheel Octogon");
|
||||
createModuleDisplays(robot);
|
||||
updateModuleDisplays(robot);
|
||||
});
|
||||
|
||||
preset8SquareBtn.addEventListener('click', () => {
|
||||
const positions = PresetConfigs.eightWheelSquare(robotSize);
|
||||
robot.setModules(positions);
|
||||
robot.setName("8-Wheel Square");
|
||||
createModuleDisplays(robot);
|
||||
updateModuleDisplays(robot);
|
||||
});
|
||||
|
||||
preset12HexBtn.addEventListener('click', () => {
|
||||
const positions = PresetConfigs.twelveWheelHexagon(robotSize);
|
||||
robot.setModules(positions);
|
||||
robot.setName("12-Wheel Hexagon");
|
||||
createModuleDisplays(robot);
|
||||
updateModuleDisplays(robot);
|
||||
});
|
||||
|
||||
preset16OctBtn.addEventListener('click', () => {
|
||||
const positions = PresetConfigs.sixteenWheelOctogon(robotSize);
|
||||
robot.setModules(positions);
|
||||
robot.setName("16-Wheel Octogon");
|
||||
createModuleDisplays(robot);
|
||||
updateModuleDisplays(robot);
|
||||
});
|
||||
|
||||
|
||||
|
||||
generateInputsBtn.addEventListener('click', () => {
|
||||
const count = parseInt(moduleCountInput.value);
|
||||
|
||||
if (isNaN(count) || count < 2) {
|
||||
alert('Please enter a valid number of modules above or equal to 2.');
|
||||
if (isNaN(count) || count < 2 || count > 64) {
|
||||
alert('Please enter a valid number of modules between 2 and 64.');
|
||||
return;
|
||||
}
|
||||
generateModuleInputs(count);
|
||||
@ -328,16 +641,171 @@ applyCustomBtn.addEventListener('click', () => {
|
||||
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
|
||||
* BEGIN DYNAMIC DOM FUNCTIONS
|
||||
*/
|
||||
|
||||
// Function to calculate evenly spaced module positions in circular layers
|
||||
function calculateModulePositions(count) {
|
||||
if (count <= 0) return [];
|
||||
|
||||
const baseRadius = 50; // Base radius for the first layer
|
||||
const positions = [];
|
||||
let remainingModules = count;
|
||||
let numberOfLayers = Math.ceil(count / 6);
|
||||
let modulesPerLayer = Math.ceil(count / numberOfLayers);
|
||||
let angleOffset = 0;
|
||||
let currentLayer = 0;
|
||||
let moduleIndex = 0;
|
||||
|
||||
while (remainingModules > 0) {
|
||||
// Determine modules for this layer
|
||||
let modulesInThisLayer;
|
||||
|
||||
if (remainingModules <= modulesPerLayer) {
|
||||
// Last layer gets all remaining modules
|
||||
modulesInThisLayer = remainingModules;
|
||||
} else {
|
||||
// All other layers: try to distribute evenly with max modulesPerLayer
|
||||
const remainingLayers = Math.ceil(remainingModules / modulesPerLayer);
|
||||
modulesInThisLayer = Math.min(modulesPerLayer, Math.ceil(remainingModules / remainingLayers));
|
||||
}
|
||||
|
||||
// Calculate radius for this layer
|
||||
const radius = currentLayer === 0 ? baseRadius : baseRadius * (1 + currentLayer * 0.75);
|
||||
|
||||
// Calculate positions for modules in this layer
|
||||
for (let i = 0; i < modulesInThisLayer; i++) {
|
||||
const angle = (angleOffset + 2 * Math.PI * i) / modulesInThisLayer;
|
||||
const x = Math.round(radius * Math.cos(angle));
|
||||
const y = Math.round(radius * Math.sin(angle));
|
||||
|
||||
positions.push({
|
||||
x: x,
|
||||
y: y,
|
||||
name: `Layer ${currentLayer + 1} Module ${(moduleIndex % modulesPerLayer) + 1}`
|
||||
});
|
||||
|
||||
moduleIndex++;
|
||||
}
|
||||
angleOffset += Math.PI;
|
||||
|
||||
remainingModules -= modulesInThisLayer;
|
||||
currentLayer++;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function generateModuleInputs(count) {
|
||||
const container = document.getElementById('module-position-inputs');
|
||||
container.innerHTML = ''; // Clear existing inputs
|
||||
|
||||
// Calculate evenly spaced positions
|
||||
const positions = calculateModulePositions(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const position = positions[i];
|
||||
const moduleFieldset = document.createElement('fieldset');
|
||||
moduleFieldset.className = 'module-input-group';
|
||||
moduleFieldset.innerHTML = `
|
||||
@ -345,15 +813,15 @@ function generateModuleInputs(count) {
|
||||
|
||||
<div class="control-group">
|
||||
<label for="module-${i}-name">Module Name</label>
|
||||
<input type="text" id="module-${i}-name" value="Module ${i + 1}" required>
|
||||
<input type="text" id="module-${i}-name" value="${position.name}" required>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="module-${i}-x">X Position (pixels)</label>
|
||||
<input type="number" id="module-${i}-x" step="1" value="0" required>
|
||||
<input type="number" id="module-${i}-x" step="1" value="${position.x}" required>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="module-${i}-y">Y Position (pixels)</label>
|
||||
<input type="number" id="module-${i}-y" step="0.1" value="0" required>
|
||||
<input type="number" id="module-${i}-y" step="0.1" value="${position.y}" required>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(moduleFieldset);
|
||||
@ -416,12 +884,8 @@ function updateModuleDisplays(robot) {
|
||||
* BEGIN ANIMATION CODE
|
||||
*/
|
||||
|
||||
// Get the canvas and context as constants
|
||||
const canvas = document.getElementById('swerve-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Get CSS variables for use in canvas
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const leftJoystick = new Joystick(ctx, -250, 250, 100);
|
||||
const rightJoystick = new Joystick(ctx, 250, 250, 100);
|
||||
|
||||
function drawGrid(ctx, sideLength, gridSquareSize, xOffset, yOffset) {
|
||||
ctx.save();
|
||||
@ -489,6 +953,7 @@ function drawModule(ctx, module) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
function drawRobot(ctx, robot, heading) {
|
||||
ctx.save(); // Save current state before rotation
|
||||
|
||||
@ -498,24 +963,39 @@ function drawRobot(ctx, robot, heading) {
|
||||
ctx.fillStyle = rootStyles.getPropertyValue('--robot-fill-color');
|
||||
ctx.lineWidth = 4;
|
||||
|
||||
const modules = robot.modules.sort((a, b) => Math.atan2(a.position.y, a.position.x) - Math.atan2(b.position.y, b.position.x));
|
||||
let hull = [];
|
||||
// Get the convex hull of the robot if there are more than 3 modules
|
||||
if (robot.modules.length > 3) {
|
||||
const grahamScan = new GrahamScan();
|
||||
grahamScan.setPoints(robot.modules.map((module) => [module.position.x, module.position.y]));
|
||||
hull = grahamScan.getHull();
|
||||
} else {
|
||||
hull = robot.modules.map((module) => [module.position.x, module.position.y]);
|
||||
}
|
||||
|
||||
|
||||
// Draw the convex hull as the robot frame
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(modules[0].position.x, modules[0].position.y);
|
||||
for (let i = 1; i < modules.length; i++) {
|
||||
ctx.lineTo(modules[i].position.x, modules[i].position.y);
|
||||
ctx.moveTo(hull[0][0], hull[0][1]);
|
||||
for (let i = 1; i < hull.length; i++) {
|
||||
ctx.lineTo(hull[i][0], hull[i][1]);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
modules.forEach(module => drawModule(ctx, module));
|
||||
// Draw all modules (not just hull modules)
|
||||
robot.modules.forEach(module => drawModule(ctx, module, heading));
|
||||
|
||||
ctx.restore(); // Restore to remove rotation
|
||||
}
|
||||
|
||||
|
||||
// Initialize Variables
|
||||
// Joysticks
|
||||
const supportsTouch = (navigator.maxTouchPoints > 0);
|
||||
|
||||
|
||||
// General robot
|
||||
const robotSize = 200;
|
||||
const defaultModulePositions = PresetConfigs.fourWheelSquare(robotSize);
|
||||
const robot = new SwerveDrive(defaultModulePositions, "4-Wheel Square");
|
||||
@ -535,28 +1015,65 @@ function animate() {
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
|
||||
// Update speeds based on sliders
|
||||
xSpeed = parseFloat(vxSlider.value);
|
||||
ySpeed = -parseFloat(vySlider.value);
|
||||
turnSpeed = parseFloat(omegaSlider.value);
|
||||
// Update speeds based on control mode
|
||||
if (isManualInputMode) {
|
||||
const maxSpeed = parseFloat(keyboardMaxSpeed.value);
|
||||
const maxRotation = parseFloat(keyboardMaxRotation.value);
|
||||
|
||||
// Animate the grid with robot movement
|
||||
let offsetSpeedDivisor = (100 - gridSquareSize <= 0 ? 1 : 100 - gridSquareSize);
|
||||
// Combine keyboard and joystick inputs
|
||||
const keyboardX = manualInputVelX;
|
||||
const keyboardY = -manualInputVelY; // Negative because canvas Y axis is inverted
|
||||
const keyboardOmega = manualInputOmega;
|
||||
|
||||
// Update grid offsets based on robot movement
|
||||
xGridOffset = (xGridOffset + (xSpeed / offsetSpeedDivisor)) % gridSquareSize;
|
||||
yGridOffset = (yGridOffset + (ySpeed / offsetSpeedDivisor)) % gridSquareSize;
|
||||
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 {
|
||||
xSpeed = parseFloat(vxSlider.value);
|
||||
ySpeed = -parseFloat(vySlider.value);
|
||||
turnSpeed = parseFloat(omegaSlider.value);
|
||||
}
|
||||
|
||||
// Update module states before drawing the robot
|
||||
// The drive() method will update the gyroHeading internally
|
||||
robot.drive(xSpeed, ySpeed, turnSpeed, parseFloat(maxSpeedSlider.value));
|
||||
updateModuleDisplays(robot);
|
||||
|
||||
// Get the actual robot velocity (after scaling to max module speed) for grid animation
|
||||
const actualVelocity = robot.getActualVelocity();
|
||||
|
||||
// Update control outputs with actual speeds
|
||||
if (isManualInputMode) {
|
||||
// In manual input mode (keyboard/gamepad), show the current values
|
||||
keyboardMaxSpeedOutput.textContent = `Max: ${keyboardMaxSpeed.value} | Current: ${Math.max(Math.abs(actualVelocity.x), Math.abs(actualVelocity.y)).toFixed(1)}`;
|
||||
keyboardMaxRotationOutput.textContent = `Max: ${keyboardMaxRotation.value} | Current: ${Math.abs(robot.actualTurnSpeed || 0).toFixed(2)}`;
|
||||
} else {
|
||||
// In slider mode, show requested vs actual
|
||||
vxOutput.textContent = `Requested: ${vxSlider.value} | Actual: ${actualVelocity.x.toFixed(2)}`;
|
||||
vyOutput.textContent = `Requested: ${vySlider.value} | Actual: ${-actualVelocity.y.toFixed(2)}`;
|
||||
omegaOutput.textContent = `Requested: ${omegaSlider.value} | Actual: ${(robot.actualTurnSpeed || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Animate the grid
|
||||
let offsetSpeedDivisor = (100 - gridSquareSize <= 0 ? 1 : 100 - gridSquareSize);
|
||||
|
||||
// Update grid offsets based on robot movement
|
||||
xGridOffset = (xGridOffset + (actualVelocity.x / offsetSpeedDivisor)) % gridSquareSize;
|
||||
yGridOffset = (yGridOffset + (actualVelocity.y / offsetSpeedDivisor)) % gridSquareSize;
|
||||
|
||||
// Draw the robot and it's movement. Grid should be oversized so movement
|
||||
// doesn't find the edge of the grid
|
||||
drawGrid(ctx, canvas.width * 2, gridSquareSize, xGridOffset, yGridOffset);
|
||||
drawRobot(ctx, robot, robot.gyroHeading);
|
||||
|
||||
leftJoystick.draw();
|
||||
rightJoystick.draw();
|
||||
|
||||
// Do it all over again
|
||||
ctx.restore();
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
16
styles.css
16
styles.css
@ -238,7 +238,7 @@ tr:hover {
|
||||
|
||||
.visualization-area {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 3;
|
||||
grid-row: 2 / 4;
|
||||
}
|
||||
|
||||
#swerve-canvas {
|
||||
@ -252,22 +252,22 @@ tr:hover {
|
||||
|
||||
.controls-panel {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
grid-row: 3 / 4;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.module-states {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
grid-row: 3 / 4;
|
||||
}
|
||||
|
||||
.documentation {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 4 / 5;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
@ -362,3 +362,9 @@ button:hover {
|
||||
color: var(--text-light);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
33
sw.js
Normal file
33
sw.js
Normal 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))
|
||||
);
|
||||
});
|
||||
148
vendor/lucio/graham-scan.mjs
vendored
Normal file
148
vendor/lucio/graham-scan.mjs
vendored
Normal file
@ -0,0 +1,148 @@
|
||||
/*
|
||||
This module is not by me, it was found at the following github with MIT license:
|
||||
https://github.com/luciopaiva/graham-scan/tree/master
|
||||
=========
|
||||
Copyright 2020 Lucio Paiva
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
=========
|
||||
*/
|
||||
|
||||
|
||||
const X = 0;
|
||||
const Y = 1;
|
||||
const REMOVED = -1;
|
||||
|
||||
export default class GrahamScan {
|
||||
|
||||
constructor() {
|
||||
/** @type {[Number, Number][]} */
|
||||
this.points = [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.points = [];
|
||||
}
|
||||
|
||||
getPoints() {
|
||||
return this.points;
|
||||
}
|
||||
|
||||
setPoints(points) {
|
||||
this.points = points.slice(); // copy
|
||||
}
|
||||
|
||||
addPoint(point) {
|
||||
this.points.push(point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the smallest convex hull of a given set of points. Runs in O(n log n).
|
||||
*
|
||||
* @return {[Number, Number][]}
|
||||
*/
|
||||
getHull() {
|
||||
const pivot = this.preparePivotPoint();
|
||||
|
||||
let indexes = Array.from(this.points, (point, i) => i);
|
||||
const angles = Array.from(this.points, (point) => this.getAngle(pivot, point));
|
||||
const distances = Array.from(this.points, (point) => this.euclideanDistanceSquared(pivot, point));
|
||||
|
||||
// sort by angle and distance
|
||||
indexes.sort((i, j) => {
|
||||
const angleA = angles[i];
|
||||
const angleB = angles[j];
|
||||
if (angleA === angleB) {
|
||||
const distanceA = distances[i];
|
||||
const distanceB = distances[j];
|
||||
return distanceA - distanceB;
|
||||
}
|
||||
return angleA - angleB;
|
||||
});
|
||||
|
||||
// remove points with repeated angle (but never the pivot, so start from i=1)
|
||||
for (let i = 1; i < indexes.length - 1; i++) {
|
||||
if (angles[indexes[i]] === angles[indexes[i + 1]]) { // next one has same angle and is farther
|
||||
indexes[i] = REMOVED; // remove it logically to avoid O(n) operation to physically remove it
|
||||
}
|
||||
}
|
||||
|
||||
const hull = [];
|
||||
for (let i = 0; i < indexes.length; i++) {
|
||||
const index = indexes[i];
|
||||
const point = this.points[index];
|
||||
|
||||
if (index !== REMOVED) {
|
||||
if (hull.length < 3) {
|
||||
hull.push(point);
|
||||
} else {
|
||||
while (this.checkOrientation(hull[hull.length - 2], hull[hull.length - 1], point) > 0) {
|
||||
hull.pop();
|
||||
}
|
||||
hull.push(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hull.length < 3 ? [] : hull;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the orientation of 3 points in the order given.
|
||||
*
|
||||
* It works by comparing the slope of P1->P2 vs P2->P3. If P1->P2 > P2->P3, orientation is clockwise; if
|
||||
* P1->P2 < P2->P3, counter-clockwise. If P1->P2 == P2->P3, points are co-linear.
|
||||
*
|
||||
* @param {[Number, Number]} p1
|
||||
* @param {[Number, Number]} p2
|
||||
* @param {[Number, Number]} p3
|
||||
* @return {Number} positive if orientation is clockwise, negative if counter-clockwise, 0 if co-linear
|
||||
*/
|
||||
checkOrientation(p1, p2, p3) {
|
||||
return (p2[Y] - p1[Y]) * (p3[X] - p2[X]) - (p3[Y] - p2[Y]) * (p2[X] - p1[X]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {[Number, Number]} a
|
||||
* @param {[Number, Number]} b
|
||||
* @return Number
|
||||
*/
|
||||
getAngle(a, b) {
|
||||
return Math.atan2(b[Y] - a[Y], b[X] - a[X]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {[Number, Number]} p1
|
||||
* @param {[Number, Number]} p2
|
||||
* @return {Number}
|
||||
*/
|
||||
euclideanDistanceSquared(p1, p2) {
|
||||
const a = p2[X] - p1[X];
|
||||
const b = p2[Y] - p1[Y];
|
||||
return a * a + b * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {[Number, Number]}
|
||||
*/
|
||||
preparePivotPoint() {
|
||||
let pivot = this.points[0];
|
||||
let pivotIndex = 0;
|
||||
for (let i = 1; i < this.points.length; i++) {
|
||||
const point = this.points[i];
|
||||
if (point[Y] < pivot[Y] || point[Y] === pivot[Y] && point[X] < pivot[X]) {
|
||||
pivot = point;
|
||||
pivotIndex = i;
|
||||
}
|
||||
}
|
||||
return pivot;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user