Initial commit

This commit is contained in:
PROFERIS - Mi³osz Stocki
2026-07-06 11:34:03 +02:00
commit 67b4eda6aa
12 changed files with 2735 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* WijiBoard BLE Singleton
* web/js/ble.js
*
* Provides a singleton BLE object with:
* BLE.connect() → connects to device, gets characteristics
* BLE.disconnect() → disconnects
* BLE.write(cmd) → writes ASCII command (write-without-response)
* BLE.isConnected() → boolean
* BLE.on(event, cb) → subscribe to 'connected' | 'disconnected' | 'status'
* BLE.off(event, cb) → unsubscribe
*
* Command characteristic (WRITE_NR):
* S1+<n> → step motor 1 clockwise n steps
* S1-<n> → step motor 1 counter-clockwise n steps
* S2+<n> → step motor 2 clockwise n steps
* S2-<n> → step motor 2 counter-clockwise n steps
* SPD:<n> → set max speed (steps/sec)
* ACC:<n> → set acceleration (steps/sec²)
* HOME → zero both steppers
* POS → request position report
*
* Status characteristic (NOTIFY):
* P:<s1>,<s2> → current step positions for motor 1 & 2
*/
// ── UUIDs must match src/main.cpp exactly ──────────────────────
const SERVICE_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456700';
const CMD_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456701';
const STATUS_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456702';
const DEVICE_NAME = 'WijiBoard';
const BLE = (() => {
// ── Internal state ──────────────────────────────────────────
let device = null;
let server = null;
let cmdChar = null;
let statusChar = null;
const encoder = new TextEncoder();
// ── Tiny event emitter ──────────────────────────────────────
const listeners = {};
function emit(event, ...args) {
(listeners[event] || []).forEach(cb => {
try { cb(...args); } catch (e) { console.error('[BLE] listener error', e); }
});
}
function on(event, cb) {
if (!listeners[event]) listeners[event] = [];
listeners[event].push(cb);
}
function off(event, cb) {
if (!listeners[event]) return;
listeners[event] = listeners[event].filter(fn => fn !== cb);
}
// ── Disconnect handler ──────────────────────────────────────
function onDisconnected() {
console.log('[BLE] Device disconnected');
device = null;
server = null;
cmdChar = null;
statusChar = null;
emit('disconnected');
}
// ── Status notification handler ─────────────────────────────
function onStatusNotification(event) {
const value = new TextDecoder().decode(event.target.value);
emit('status', value.trim());
}
// ── connect() ───────────────────────────────────────────────
async function connect() {
if (!navigator.bluetooth) {
throw new Error('Web Bluetooth API not available. Use Chrome/Edge over HTTPS or localhost.');
}
emit('connecting');
device = await navigator.bluetooth.requestDevice({
filters: [{ name: DEVICE_NAME }],
optionalServices: [SERVICE_UUID],
});
device.addEventListener('gattserverdisconnected', onDisconnected);
server = await device.gatt.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
cmdChar = await service.getCharacteristic(CMD_UUID);
// Status notifications (optional firmware may not have this char yet)
try {
statusChar = await service.getCharacteristic(STATUS_UUID);
await statusChar.startNotifications();
statusChar.addEventListener('characteristicvaluechanged', onStatusNotification);
} catch {
console.warn('[BLE] Status characteristic not available notifications disabled');
}
emit('connected', device.name);
}
// ── disconnect() ────────────────────────────────────────────
async function disconnect() {
if (device?.gatt?.connected) {
device.gatt.disconnect();
}
}
// ── write() ─────────────────────────────────────────────────
async function write(cmd) {
if (!cmdChar) throw new Error('BLE not connected');
await cmdChar.writeValueWithoutResponse(encoder.encode(cmd));
emit('sent', cmd);
}
// ── isConnected() ───────────────────────────────────────────
function isConnected() {
return !!(device?.gatt?.connected && cmdChar);
}
// ── getDeviceName() ─────────────────────────────────────────
function getDeviceName() {
return device?.name ?? null;
}
return {
on, off, connect, disconnect, write, isConnected, getDeviceName,
SERVICE_UUID, CMD_UUID, STATUS_UUID, DEVICE_NAME,
};
})();
export default BLE;
+261
View File
@@ -0,0 +1,261 @@
/**
* WijiBoard Kinematics (exact port of PositionControl.cpp)
* web/js/kinematics.js
*
* ── Mechanism: symmetric 5-bar parallel linkage ─────────────────
*
* END EFFECTOR (x, y)
* / \
* L2 (110) L2 (110)
* / \
* ELBOW1 ELBOW2
* \ /
* L1 (85) L1 (85)
* \ /
* MOTOR1 (-d2,0) MOTOR2 (+d2,0)
* | |
* [===BASE===] (d=25.8 mm wide)
* d2=12.9 mm
*
* Both motors are mounted in the centre mechanism box.
* Motor 1 is at (-d2, 0), Motor 2 is at (+d2, 0).
* Each motor drives a proximal arm (l1). The distal arms (l2)
* connect the elbows to the shared end-effector.
*
* IK: given target (x, y), solve θ1 and θ2 independently:
* Motor 1 sees the target at (x - d2, y) from its pivot.
* Motor 2 sees the target at (x + d2, y) from its pivot.
* Each uses the standard 2-link IK (law of cosines).
*
* Source: lib/Position/PositionControl.cpp (nerd-sniped/WijiBoard)
*/
// ── Exact constants from PositionControl.cpp ─────────────────────
const ARM = {
d: 25.8, // full motor separation (mm)
d2: 12.9, // half motor separation — motor 1 at (-d2,0), motor 2 at (+d2,0)
l1: 85.0, // proximal link length (mm)
l2: 110.0, // distal link length (mm)
STEPS_PER_REV: 2048,
STEP_ANGLE_DEG: 360 / 2048, // ≈ 0.17578125°
};
// ── Workspace limits ──────────────────────────────────────────────
const LIMITS = {
// Outer bounding box (the board surface)
X_MIN: -150,
X_MAX: 150,
Y_MIN: -30, // numbers sit slightly below Y=0
Y_MAX: 145,
// Centre exclusion zone (the motor/mechanism box)
// Motors are at ±12.9 mm; box is a bit larger to account for the housing
BOX_HALF_W: 22, // ±22 mm in X (tune to real hardware)
BOX_HALF_H: 22, // 0..22 mm in Y (box sits above the base line)
BOX_Y_MIN: -5,
BOX_Y_MAX: 22,
};
// ── Letter / number position lookup table ────────────────────────
// Directly ported from PositionControl.cpp
const LOOKUP_TABLE = {
'Q': { x: -66.5, y: 91.6 },
'W': { x: 70.8, y: 92.0 },
'E': { x: -41.4, y: 125.5 },
'R': { x: -44.0, y: 96.0 },
'T': { x: 1.3, y: 97.8 },
'Y': { x: 117.8, y: 91.7 },
'U': { x: 22.5, y: 98.3 },
'I': { x: 53.5, y: 124.5 },
'O': { x: -112.0, y: 82.0 },
'P': { x: -90.0, y: 89.0 },
'A': { x: -143.0, y: 99.0 },
'S': { x: -19.8, y: 98.0 },
'D': { x: -68.5, y: 121.7 },
'F': { x: -19.5, y: 127.0 },
'G': { x: 4.0, y: 128.4 },
'H': { x: 31.3, y: 127.3 },
'J': { x: 72.5, y: 121.0 },
'K': { x: 93.0, y: 116.8 },
'L': { x: 114.3, y: 110.2 },
'Z': { x: 130.0, y: 75.0 },
'X': { x: 95.9, y: 87.3 },
'C': { x: -94.0, y: 116.0 },
'V': { x: 44.5, y: 97.0 },
'B': { x: -119.0, y: 109.0 },
'N': { x: -135.4, y: 75.1 },
'M': { x: 132.0, y: 100.5 },
'+': { x: -110.0, y: 1.0 },
'-': { x: 110.0, y: 1.0 },
'*': { x: 110.0, y: -24.0 },
',': { x: -110.0, y: -24.0 },
'0': { x: -130.4, y: 44.2 },
'1': { x: -101.1, y: 53.3 },
'2': { x: -71.5, y: 60.4 },
'3': { x: -41.7, y: 65.2 },
'4': { x: -13.0, y: 66.5 },
'5': { x: 16.3, y: 68.0 },
'6': { x: 45.8, y: 64.0 },
'7': { x: 75.7, y: 61.1 },
'8': { x: 103.6, y: 53.8 },
'9': { x: 132.1, y: 45.0 },
};
// ── IK solver — exact port of calculateInverseKinematics() ───────
/**
* Compute motor angles for a given target end-effector position.
* Exactly mirrors the C++ implementation in PositionControl.cpp.
*
* @param {number} x Target X in mm (origin = midpoint between motors)
* @param {number} y Target Y in mm
* @returns {{ theta1: number, theta2: number, reachable: boolean }}
* theta1 / theta2 in RADIANS (motor 1 = left, motor 2 = right)
*/
function solve(x, y) {
const { d2, l1, l2 } = ARM;
// ── Motor 1 (left pivot at -d2, 0) ───────────────────────────
const xmd = x - d2; // target X relative to left motor
const s = Math.sqrt(xmd * xmd + y * y); // distance: left motor → target
const cosW1 = (l2 * l2 - s * s - l1 * l1) / (-2 * l1 * s);
if (cosW1 < -1 || cosW1 > 1) return { theta1: 0, theta2: 0, reachable: false };
const q = Math.atan2(y, xmd);
const w1 = Math.acos(cosW1);
const theta1 = q - w1;
// ── Motor 2 (right pivot at +d2, 0) ──────────────────────────
const xpd = x + d2; // target X relative to right motor
const t = Math.sqrt(xpd * xpd + y * y); // distance: right motor → target
const cosW2 = (l2 * l2 - t * t - l1 * l1) / (-2 * l1 * t);
if (cosW2 < -1 || cosW2 > 1) return { theta1: 0, theta2: 0, reachable: false };
const r = Math.atan2(y, xpd);
const w2 = Math.acos(cosW2);
const theta2 = r + w2;
return { theta1, theta2, reachable: true };
}
// ── Forward kinematics ───────────────────────────────────────────
/**
* Given motor angles, compute elbow and end-effector positions.
* The end-effector is found as the intersection of the two distal
* link circles — this is the FK complement to the 5-bar IK above.
*
* In practice for visualisation we just re-derive the elbow
* positions from each motor.
*
* @param {number} theta1 Motor 1 angle (radians)
* @param {number} theta2 Motor 2 angle (radians)
* @returns {{
* elbow1: {x,y}, elbow2: {x,y},
* endX: number, endY: number
* }}
*/
function forward(theta1, theta2) {
const { d2, l1, l2 } = ARM;
// Elbow 1 (tip of motor 1's proximal link)
const e1x = -d2 + l1 * Math.cos(theta1);
const e1y = l1 * Math.sin(theta1);
// Elbow 2 (tip of motor 2's proximal link)
const e2x = d2 + l1 * Math.cos(theta2);
const e2y = l1 * Math.sin(theta2);
// End-effector: intersection of circle(elbow1, l2) and circle(elbow2, l2)
// Use the same approach as the IK: each distal link points from its elbow to the EE.
// For visualisation accuracy, reconstruct EE by reversing the IK:
// From IK: theta1 = q - w1 → q = atan2(y, x - d2)
// We know theta1 and the elbow position, so EE is at l2 along some direction.
// Simplest: use circle-circle intersection of the two elbow-radius-l2 circles.
const dx = e2x - e1x;
const dy = e2y - e1y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-6 || dist > 2 * l2) {
// Degenerate / unreachable — just average the two elbows
return {
elbow1: { x: e1x, y: e1y },
elbow2: { x: e2x, y: e2y },
endX: (e1x + e2x) / 2,
endY: (e1y + e2y) / 2,
};
}
const a = dist / 2;
const h = Math.sqrt(l2 * l2 - a * a);
const mx = (e1x + e2x) / 2;
const my = (e1y + e2y) / 2;
// Two intersection candidates — pick the one with higher Y (the "up" configuration)
const px1 = mx + h * (dy / dist);
const py1 = my - h * (dx / dist);
const px2 = mx - h * (dy / dist);
const py2 = my + h * (dx / dist);
const { endX, endY } = py1 > py2
? { endX: px1, endY: py1 }
: { endX: px2, endY: py2 };
return {
elbow1: { x: e1x, y: e1y },
elbow2: { x: e2x, y: e2y },
endX, endY,
};
}
// ── Workspace check ───────────────────────────────────────────────
/**
* Check whether a point is inside the valid workspace.
* @param {number} x
* @param {number} y
* @returns {{ ok: boolean, reason: string }}
*/
function checkWorkspace(x, y) {
// Outer bounding box
if (x < LIMITS.X_MIN || x > LIMITS.X_MAX)
return { ok: false, reason: `X=${x.toFixed(1)} outside board limits [${LIMITS.X_MIN}, ${LIMITS.X_MAX}]` };
if (y < LIMITS.Y_MIN || y > LIMITS.Y_MAX)
return { ok: false, reason: `Y=${y.toFixed(1)} outside board limits [${LIMITS.Y_MIN}, ${LIMITS.Y_MAX}]` };
// Centre exclusion zone (mechanism box)
if (
x > -LIMITS.BOX_HALF_W && x < LIMITS.BOX_HALF_W &&
y > LIMITS.BOX_Y_MIN && y < LIMITS.BOX_Y_MAX
) {
return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is inside the mechanism exclusion zone` };
}
// IK reachability
const { reachable } = solve(x, y);
if (!reachable) return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is outside arm reach` };
return { ok: true, reason: '' };
}
// ── Lookup ────────────────────────────────────────────────────────
function lookup(char) {
return LOOKUP_TABLE[char.toUpperCase()] ?? null;
}
// ── Unit converters ───────────────────────────────────────────────
function stepsToRad(steps) {
return (steps / ARM.STEPS_PER_REV) * 2 * Math.PI;
}
function radToSteps(rad) {
return Math.round((rad / (2 * Math.PI)) * ARM.STEPS_PER_REV);
}
function stepsToDeg(steps) {
return steps * ARM.STEP_ANGLE_DEG;
}
function radToDeg(rad) {
return rad * 180 / Math.PI;
}
export default {
ARM, LIMITS, LOOKUP_TABLE,
solve, forward, checkWorkspace, lookup,
stepsToRad, radToSteps, stepsToDeg, radToDeg,
};
+91
View File
@@ -0,0 +1,91 @@
/**
* WijiBoard Hash-based SPA Router
* web/js/router.js
*
* Usage:
* Router.register('home', homeModule);
* Router.register('stepper-test', stepperModule);
* Router.init('#view', '#home'); // outlet element id, default route
*
* Each section module must export:
* { mount(containerEl), unmount() }
*/
const Router = (() => {
const routes = new Map(); // hash → module
let outlet = null; // DOM element to render into
let current = null; // currently active route key
let currentMod = null; // currently mounted module
// ── Register a route ─────────────────────────────────────────
function register(hash, module) {
routes.set(hash, module);
}
// ── Navigate to a route ──────────────────────────────────────
async function navigate(hash) {
const key = hash.replace(/^#/, '');
if (!routes.has(key)) {
console.warn(`[Router] Unknown route: "${key}"`);
return;
}
if (key === current) return; // already there
// Unmount old section
if (currentMod?.unmount) {
try { await currentMod.unmount(); } catch (e) { console.error('[Router] unmount error', e); }
}
// Clear outlet
outlet.innerHTML = '';
// Mount new section
const mod = routes.get(key);
currentMod = mod;
current = key;
try {
await mod.mount(outlet);
} catch (e) {
console.error('[Router] mount error', e);
outlet.innerHTML = `<p style="color:var(--accent-red);padding:24px">
Failed to load section: ${e.message}</p>`;
}
// Update sidebar active state
document.querySelectorAll('.sidebar-nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.route === key);
});
// Update URL hash (without re-triggering hashchange)
if (location.hash.replace('#','') !== key) {
history.replaceState(null, '', `#${key}`);
}
}
// ── Init: wire up outlet + default route ─────────────────────
function init(outletSelector, defaultHash = '#home') {
outlet = document.querySelector(outletSelector);
if (!outlet) throw new Error(`[Router] Outlet "${outletSelector}" not found`);
// Listen for hash changes
window.addEventListener('hashchange', () => {
navigate(location.hash || defaultHash);
});
// Handle initial load
const initial = location.hash && location.hash.length > 1
? location.hash
: defaultHash;
// Small defer to let the page fully render first
requestAnimationFrame(() => navigate(initial));
}
// ── Active route getter ───────────────────────────────────────
function getActive() { return current; }
return { register, navigate, init, getActive };
})();
export default Router;
+107
View File
@@ -0,0 +1,107 @@
/**
* WijiBoard Home Section
* web/js/sections/home.js
*
* A minimal placeholder home section.
*/
import BLE from '../ble.js';
const HomeSection = {
_cleanup: [],
mount(container) {
container.innerHTML = `
<div class="section-page fade-in">
<div class="section-header">
<h2>WijiBoard</h2>
<p>WiFi Spirit Board — SCARA arm controller. Select a section from the sidebar to get started.</p>
</div>
<div class="grid-2" style="margin-bottom:16px">
<!-- Status card -->
<div class="card">
<div class="card-header">
<span class="card-title">Connection</span>
</div>
<div class="status-row" style="margin-bottom:10px">
<span class="status-label">BLE</span>
<span class="status-value" id="home-ble-status">
${BLE.isConnected() ? `${BLE.getDeviceName()}` : 'Not connected'}
</span>
</div>
<p style="font-size:0.8rem;color:var(--text-muted)">
Use the <strong style="color:var(--text-secondary)">Connect</strong> button
in the top bar to pair with the WijiBoard via Bluetooth.
The device name is <code style="color:var(--accent-blue)">WijiBoard</code>.
</p>
</div>
<!-- About card -->
<div class="card">
<div class="card-header">
<span class="card-title">About</span>
</div>
<p style="font-size:0.82rem;line-height:1.8;color:var(--text-secondary)">
This dashboard controls a two-arm SCARA robot that moves a planchette
across a spirit board. It uses <strong style="color:var(--text-primary)">Web Bluetooth</strong>
to communicate with an ESP32-S3 running two 28BYJ-48 stepper motors
with AccelStepper.
</p>
</div>
</div>
<!-- Sections overview -->
<div class="card">
<div class="card-header">
<span class="card-title">Sections</span>
</div>
<div class="grid-2">
<button class="sidebar-nav-item" data-route="stepper-test"
onclick="document.getElementById('nav-stepper-test').click()"
style="padding:14px 16px;background:var(--surface-2)">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
<div>
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Stepper Test</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Manually jog each motor, visualize arm position</div>
</div>
</button>
<div class="sidebar-nav-item" style="padding:14px 16px;background:var(--surface-2);opacity:0.45;cursor:default">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<div>
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Board Control</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Coming soon</div>
</div>
</div>
</div>
</div>
</div>
`;
// Keep home BLE status label in sync
const statusEl = container.querySelector('#home-ble-status');
const update = () => {
if (statusEl) statusEl.textContent = BLE.isConnected()
? `${BLE.getDeviceName()}`
: 'Not connected';
};
BLE.on('connected', update);
BLE.on('disconnected', update);
this._cleanup = [
() => BLE.off('connected', update),
() => BLE.off('disconnected', update),
];
},
unmount() {
this._cleanup.forEach(fn => fn());
this._cleanup = [];
},
};
export default HomeSection;
+766
View File
@@ -0,0 +1,766 @@
/**
* WijiBoard Stepper Test Section
* web/js/sections/stepper-test.js
*
* 5-bar parallel linkage visualiser + IK-based click-to-move.
*
* Mechanism geometry (from PositionControl.cpp):
* Motor 1 at (-12.9, 0) mm Motor 2 at (+12.9, 0) mm
* Proximal links: l1 = 85 mm Distal links: l2 = 110 mm
*
* IK flow: click XY → IK.solve(x,y) → {θ1, θ2} → steps → BLE
* FK flow: steps → radians → IK.forward(θ1, θ2) → draw SVG
*/
import BLE from '../ble.js';
import IK from '../kinematics.js';
import UI from '../ui.js';
// ── SVG viewport ──────────────────────────────────────────────────
// We map the real workspace onto the SVG canvas.
// Real workspace: X ∈ [-155, 155], Y ∈ [-35, 150]
// We add some margin and invert Y (SVG y grows downward).
const SV = {
W: 480, H: 380,
// World→SVG transform: scale + offset so the board fits nicely
SCALE: 1.35, // px per mm
// Origin (world 0,0) maps to this SVG point:
OX: 240, // ≈ centre horizontally
OY: 295, // base line — motors sit here
// Convenience: world mm → SVG px
wx(worldX) { return this.OX + worldX * this.SCALE; },
wy(worldY) { return this.OY - worldY * this.SCALE; },
// SVG px → world mm
svgToWorldX(sx) { return (sx - this.OX) / this.SCALE; },
svgToWorldY(sy) { return -(sy - this.OY) / this.SCALE; },
};
// ── Arm length in px (for readable references) ────────────────────
const PX_L1 = IK.ARM.l1 * SV.SCALE;
const PX_L2 = IK.ARM.l2 * SV.SCALE;
const PX_D2 = IK.ARM.d2 * SV.SCALE;
// ── Section state ─────────────────────────────────────────────────
let steps1 = 0;
let steps2 = 0;
let eventCleanup = [];
// ── SVG element refs ──────────────────────────────────────────────
let svgEl;
// Real arm elements
let arm1Prox, arm1Dist, arm2Prox, arm2Dist;
let elbow1, elbow2, endEff, coordLabel, xhH, xhV;
// Ghost arm
let ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist;
let ghostElbow1, ghostElbow2, ghostEnd, ghostLabel;
// Target marker
let targetMarker;
// ── Readout refs ──────────────────────────────────────────────────
let elCurrX, elCurrY, elTheta1, elTheta2, elSteps1, elSteps2;
let simBadge;
// ─────────────────────────────────────────────────────────────────
// SVG rendering helpers
// ─────────────────────────────────────────────────────────────────
function setLine(el, x1, y1, x2, y2) {
el.setAttribute('x1', x1); el.setAttribute('y1', y1);
el.setAttribute('x2', x2); el.setAttribute('y2', y2);
}
function setCircle(el, cx, cy) {
el.setAttribute('cx', cx); el.setAttribute('cy', cy);
}
/** Render the 5-bar arm from two motor angles. */
function renderArm(theta1, theta2, isGhost = false) {
const { elbow1: e1, elbow2: e2, endX, endY } = IK.forward(theta1, theta2);
const motor1sx = SV.wx(-IK.ARM.d2); const motor1sy = SV.wy(0);
const motor2sx = SV.wx( IK.ARM.d2); const motor2sy = SV.wy(0);
const elbow1sx = SV.wx(e1.x); const elbow1sy = SV.wy(e1.y);
const elbow2sx = SV.wx(e2.x); const elbow2sy = SV.wy(e2.y);
const endsx = SV.wx(endX); const endsy = SV.wy(endY);
// Guard: forward() can return NaN for degenerate angles skip render
if (!isFinite(endX) || !isFinite(endY)) return { endsx: SV.OX, endsy: SV.OY };
if (isGhost) {
setLine(ghost1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy);
setLine(ghost1Dist, elbow1sx, elbow1sy, endsx, endsy);
setLine(ghost2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
setLine(ghost2Dist, elbow2sx, elbow2sy, endsx, endsy);
setCircle(ghostElbow1, elbow1sx, elbow1sy);
setCircle(ghostElbow2, elbow2sx, elbow2sy);
setCircle(ghostEnd, endsx, endsy);
ghostLabel.setAttribute('x', endsx + 10);
ghostLabel.setAttribute('y', endsy);
} else {
setLine(arm1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy);
setLine(arm1Dist, elbow1sx, elbow1sy, endsx, endsy);
setLine(arm2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
setLine(arm2Dist, elbow2sx, elbow2sy, endsx, endsy);
setCircle(elbow1, elbow1sx, elbow1sy);
setCircle(elbow2, elbow2sx, elbow2sy);
setCircle(endEff, endsx, endsy);
// Crosshairs
xhH.setAttribute('x1', endsx - 13); xhH.setAttribute('y1', endsy);
xhH.setAttribute('x2', endsx + 13); xhH.setAttribute('y2', endsy);
xhV.setAttribute('x1', endsx); xhV.setAttribute('y1', endsy - 13);
xhV.setAttribute('x2', endsx); xhV.setAttribute('y2', endsy + 13);
coordLabel.textContent = `(${endX.toFixed(1)}, ${endY.toFixed(1)}) mm`;
updateReadouts(theta1, theta2, endX, endY);
}
return { endsx, endsy };
}
function renderArmFromSteps() {
const t1 = IK.stepsToRad(steps1);
const t2 = IK.stepsToRad(steps2);
renderArm(t1, t2);
}
function updateReadouts(t1, t2, ex, ey) {
if (elCurrX) elCurrX.textContent = isFinite(ex) ? ex.toFixed(1) : '';
if (elCurrY) elCurrY.textContent = isFinite(ey) ? ey.toFixed(1) : '';
if (elTheta1) elTheta1.textContent = IK.radToDeg(t1).toFixed(1) + '°';
if (elTheta2) elTheta2.textContent = IK.radToDeg(t2).toFixed(1) + '°';
if (elSteps1) elSteps1.textContent = steps1;
if (elSteps2) elSteps2.textContent = steps2;
}
// ── Ghost arm show/hide ───────────────────────────────────────────
function showGhost(theta1, theta2, labelText) {
const ghosts = [ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
ghostElbow1, ghostElbow2, ghostEnd, ghostLabel];
ghosts.forEach(el => el.style.display = '');
renderArm(theta1, theta2, true);
ghostLabel.textContent = labelText;
}
function hideGhost() {
[ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
ghostElbow1, ghostElbow2, ghostEnd, ghostLabel]
.forEach(el => el.style.display = 'none');
}
// ── Target marker ─────────────────────────────────────────────────
function showTargetMarker(sx, sy) {
targetMarker.setAttribute('cx', sx);
targetMarker.setAttribute('cy', sy);
targetMarker.style.display = '';
targetMarker.classList.remove('target-pulse');
void targetMarker.offsetWidth; // force reflow
targetMarker.classList.add('target-pulse');
}
// ─────────────────────────────────────────────────────────────────
// IK movement
// ─────────────────────────────────────────────────────────────────
async function moveToXY(targetX, targetY) {
// Check workspace limits first
const wsCheck = IK.checkWorkspace(targetX, targetY);
if (!wsCheck.ok) {
UI.log(`${wsCheck.reason}`, 'warn');
return false;
}
const { theta1, theta2 } = IK.solve(targetX, targetY);
const newSteps1 = IK.radToSteps(theta1);
const newSteps2 = IK.radToSteps(theta2);
const delta1 = newSteps1 - steps1;
const delta2 = newSteps2 - steps2;
steps1 = newSteps1;
steps2 = newSteps2;
renderArm(theta1, theta2);
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
if (BLE.isConnected()) {
try {
await BLE.write(cmd1);
await BLE.write(cmd2);
} catch (e) {
UI.log(`BLE error: ${e.message}`, 'error');
}
} else {
UI.log(`[sim] IK→ θ1=${IK.radToDeg(theta1).toFixed(1)}° θ2=${IK.radToDeg(theta2).toFixed(1)}° | ${cmd1} ${cmd2}`, 'info');
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// Manual jog
// ─────────────────────────────────────────────────────────────────
async function jogMotor(motor, delta) {
if (motor === 1) steps1 += delta;
else steps2 += delta;
renderArmFromSteps();
const cmd = `S${motor}${delta >= 0 ? '+' : ''}${delta}`;
BLE.isConnected()
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
: UI.log(`[sim] ${cmd}`, 'info');
}
async function zeroMotor(motor) {
if (motor === 1) steps1 = 0;
else steps2 = 0;
renderArmFromSteps();
const cmd = `HOME${motor}`;
BLE.isConnected()
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
: UI.log(`[sim] ${cmd}`, 'info');
}
// ─────────────────────────────────────────────────────────────────
// SVG workspace zones (pre-computed)
// ─────────────────────────────────────────────────────────────────
function buildZones() {
const L = IK.LIMITS;
// Outer bounding box rect
const bx = SV.wx(L.X_MIN);
const by = SV.wy(L.Y_MAX);
const bw = (L.X_MAX - L.X_MIN) * SV.SCALE;
const bh = (L.Y_MAX - L.Y_MIN) * SV.SCALE;
// Mechanism exclusion box
const ex = SV.wx(-L.BOX_HALF_W);
const ey = SV.wy(L.BOX_Y_MAX);
const ew = L.BOX_HALF_W * 2 * SV.SCALE;
const eh = (L.BOX_Y_MAX - L.BOX_Y_MIN) * SV.SCALE;
// Motor positions
const m1x = SV.wx(-IK.ARM.d2);
const m2x = SV.wx( IK.ARM.d2);
const my = SV.wy(0);
// Scale ruler
const r0x = SV.wx(0); const r0y = SV.wy(-20);
const r1x = SV.wx(50);
return `
<!-- Board boundary (outer workspace) -->
<rect x="${bx}" y="${by}" width="${bw}" height="${bh}"
fill="rgba(68,138,255,0.04)"
stroke="rgba(68,138,255,0.30)" stroke-width="1.5" stroke-dasharray="6 4"
rx="4"/>
<!-- Mechanism exclusion zone -->
<rect x="${ex}" y="${ey}" width="${ew}" height="${eh}"
fill="rgba(255,82,82,0.12)"
stroke="rgba(255,82,82,0.55)" stroke-width="1.5"
rx="3"/>
<text x="${SV.OX}" y="${ey + eh/2 + 4}" text-anchor="middle"
font-size="8" fill="rgba(255,82,82,0.7)" font-family="monospace">
NO-GO ZONE
</text>
<!-- Axes -->
<line x1="${SV.wx(-160)}" y1="${my}" x2="${SV.wx(160)}" y2="${my}"
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
<line x1="${SV.OX}" y1="${SV.wy(-40)}" x2="${SV.OX}" y2="${SV.wy(155)}"
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
<text x="${SV.wx(163)}" y="${my + 4}" font-size="8"
fill="rgba(255,255,255,0.2)" font-family="monospace">+X</text>
<text x="${SV.OX + 4}" y="${SV.wy(158)}" font-size="8"
fill="rgba(255,255,255,0.2)" font-family="monospace">+Y</text>
<!-- Scale ruler: 50 mm -->
<line x1="${r0x}" y1="${r0y}" x2="${r1x}" y2="${r0y}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<line x1="${r0x}" y1="${r0y - 4}" x2="${r0x}" y2="${r0y + 4}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<line x1="${r1x}" y1="${r0y - 4}" x2="${r1x}" y2="${r0y + 4}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<text x="${(r0x + r1x)/2}" y="${r0y + 12}" text-anchor="middle"
font-size="8" fill="rgba(255,255,255,0.3)" font-family="monospace">50 mm</text>
<!-- Motor markers -->
<circle cx="${m1x}" cy="${my}" r="5"
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
<circle cx="${m2x}" cy="${my}" r="5"
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
<text x="${m1x}" y="${my + 16}" text-anchor="middle"
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M1</text>
<text x="${m2x}" y="${my + 16}" text-anchor="middle"
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M2</text>
`;
}
// ─────────────────────────────────────────────────────────────────
// HTML template
// ─────────────────────────────────────────────────────────────────
function buildHTML() {
return `
<style>
.test-grid {
display: grid;
grid-template-columns: 1fr 260px;
gap: 16px;
margin-bottom: 16px;
align-items: start;
}
@media (max-width: 900px) { .test-grid { grid-template-columns: 1fr; } }
#scara-svg { cursor: crosshair; display: block; width: 100%; }
@keyframes targetPulse {
0% { r: 5px; opacity: 1; }
100% { r: 20px; opacity: 0; }
}
.target-pulse { animation: targetPulse 0.45s ease-out forwards; }
.pos-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;
}
.pos-box {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 7px 8px; text-align: center;
}
.pos-val { font-size:1.05rem; font-weight:700; font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
.pos-val.xy { color: var(--accent-green); }
.pos-val.ang { color: var(--accent-blue); }
.pos-val.stp { color: var(--text-secondary); font-size:0.88rem; }
.pos-sub { font-size:0.62rem; font-weight:600; text-transform:uppercase;
letter-spacing:0.09em; color:var(--text-muted); margin-top:2px; }
.jog-compact { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.jog-motor { background:var(--surface-2); border:1px solid var(--border);
border-radius:var(--radius-md); padding:12px;
display:flex; flex-direction:column; gap:8px; }
.jog-title { font-size:0.72rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.09em; color:var(--text-muted); }
.quick-steps { display:flex; gap:4px; flex-wrap:wrap; }
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
border-radius:var(--radius-xs); }
</style>
<div class="section-page fade-in">
<div class="section-header">
<h2>Stepper Test</h2>
<p>Click anywhere on the SCARA map to move via IK. Red zone = mechanism exclusion. Dashed box = board limits.</p>
</div>
<div class="test-grid">
<!-- ── SCARA map ─────────────────────────────────────── -->
<div class="card" style="padding:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<span class="card-title">SCARA Map — click to move</span>
<span id="sim-badge" style="font-size:0.67rem;padding:3px 8px;border-radius:100px;
background:rgba(255,202,40,0.12);color:var(--accent-amber);
border:1px solid rgba(255,202,40,0.25);font-weight:600">SIMULATION</span>
</div>
<svg id="scara-svg" viewBox="0 0 ${SV.W} ${SV.H}" xmlns="http://www.w3.org/2000/svg">
<!-- Background grid -->
<defs>
<pattern id="grid-pat" width="27" height="27" patternUnits="userSpaceOnUse"
patternTransform="translate(${SV.OX % 27},${SV.OY % 27})">
<path d="M 27 0 L 0 0 0 27" fill="none"
stroke="rgba(255,255,255,0.035)" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="${SV.W}" height="${SV.H}" fill="#070a0f"/>
<rect width="${SV.W}" height="${SV.H}" fill="url(#grid-pat)"/>
<!-- Workspace zones (board boundary, exclusion zone, axes) -->
${buildZones()}
<!-- ── Ghost arm (hover preview) ─────────────────── -->
<line id="ghost-1p" style="display:none"
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
<line id="ghost-1d" style="display:none"
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
<line id="ghost-2p" style="display:none"
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
<line id="ghost-2d" style="display:none"
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
<circle id="ghost-e1" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
<circle id="ghost-e2" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
<circle id="ghost-end" r="5" style="display:none" fill="rgba(0,230,118,0.22)"/>
<text id="ghost-label" font-size="9" fill="rgba(0,230,118,0.6)"
font-family="monospace" style="display:none"/>
<!-- ── Real arm ──────────────────────────────────── -->
<!-- Arm 1: proximal + distal -->
<line id="arm1-prox"
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(0)}"
x2="${SV.wx(-IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
stroke="#448aff" stroke-width="5" stroke-linecap="round"/>
<line id="arm1-dist"
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
stroke="#7eb8f7" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
<!-- Arm 2: proximal + distal -->
<line id="arm2-prox"
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(0)}"
x2="${SV.wx(IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
stroke="#f7a03c" stroke-width="5" stroke-linecap="round"/>
<line id="arm2-dist"
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
stroke="#ffd08a" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
<!-- Elbow joints -->
<circle id="elbow1" r="5" fill="#a0c4ff" stroke="#0a0e18" stroke-width="1.5"
cx="${SV.wx(-IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
<circle id="elbow2" r="5" fill="#ffd08a" stroke="#0a0e18" stroke-width="1.5"
cx="${SV.wx( IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
<!-- End effector -->
<circle id="end-eff" r="7"
cx="${SV.wx(0)}" cy="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
fill="var(--accent-green)" stroke="#0a0e18" stroke-width="1.5"/>
<!-- Crosshair -->
<line id="xh-h" stroke="rgba(0,230,118,0.5)" stroke-width="1"
x1="0" y1="0" x2="0" y2="0"/>
<line id="xh-v" stroke="rgba(0,230,118,0.5)" stroke-width="1"
x1="0" y1="0" x2="0" y2="0"/>
<!-- Target marker -->
<circle id="target-marker" r="5" style="display:none"
fill="none" stroke="var(--accent-green)" stroke-width="1.5"/>
<!-- Coordinate label -->
<text id="coord-label"
x="${SV.OX}" y="${SV.H - 8}"
text-anchor="middle" font-size="10"
fill="rgba(0,230,118,0.65)" font-family="monospace">
(0.0, 0.0) mm
</text>
</svg>
<div style="display:flex;gap:16px;font-size:0.70rem;color:var(--text-muted);margin-top:6px;flex-wrap:wrap">
<span style="color:rgba(68,138,255,0.8)">━ M1 (left)</span>
<span style="color:rgba(247,160,60,0.8)">━ M2 (right)</span>
<span style="color:rgba(255,82,82,0.7)">▪ NO-GO zone</span>
<span style="color:rgba(68,138,255,0.5)">╌ board limits</span>
<span>l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm d=${IK.ARM.d}mm</span>
</div>
</div>
<!-- ── Right: controls ──────────────────────────────── -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- Current position -->
<div class="card">
<div class="card-header"><span class="card-title">Current Position</span></div>
<div class="pos-grid">
<div class="pos-box">
<div class="pos-val xy" id="curr-x">0.0</div>
<div class="pos-sub">X (mm)</div>
</div>
<div class="pos-box">
<div class="pos-val xy" id="curr-y">0.0</div>
<div class="pos-sub">Y (mm)</div>
</div>
<div class="pos-box">
<div class="pos-val ang" id="curr-t1">0.0°</div>
<div class="pos-sub">θ1 (M1)</div>
</div>
<div class="pos-box">
<div class="pos-val ang" id="curr-t2">0.0°</div>
<div class="pos-sub">θ2 (M2)</div>
</div>
<div class="pos-box">
<div class="pos-val stp" id="curr-s1">0</div>
<div class="pos-sub">M1 steps</div>
</div>
<div class="pos-box">
<div class="pos-val stp" id="curr-s2">0</div>
<div class="pos-sub">M2 steps</div>
</div>
</div>
</div>
<!-- Move to XY -->
<div class="card">
<div class="card-header"><span class="card-title">Move to XY</span></div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="display:flex;gap:6px;align-items:center">
<label class="form-label" style="width:18px">X</label>
<input class="form-input" type="number" id="input-x"
value="0" step="5" style="flex:1;text-align:center"/>
<span class="form-label">mm</span>
</div>
<div style="display:flex;gap:6px;align-items:center">
<label class="form-label" style="width:18px">Y</label>
<input class="form-input" type="number" id="input-y"
value="100" step="5" style="flex:1;text-align:center"/>
<span class="form-label">mm</span>
</div>
<button class="btn btn-success btn-full" id="btn-goto">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
<span class="btn-text">Move to XY</span>
</button>
</div>
</div>
<!-- Motion settings -->
<div class="card">
<div class="card-header"><span class="card-title">Motion</span></div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="form-group">
<label class="form-label">Speed (steps/s)</label>
<div class="range-row">
<input class="form-range" type="range" id="speed-slider"
min="100" max="1000" step="50" value="600"/>
<span class="range-value" id="speed-val">600</span>
</div>
</div>
<div class="form-group">
<label class="form-label">Accel (steps/s²)</label>
<div class="range-row">
<input class="form-range" type="range" id="accel-slider"
min="50" max="500" step="25" value="100"/>
<span class="range-value" id="accel-val">100</span>
</div>
</div>
</div>
</div>
</div><!-- /right panel -->
</div><!-- /test-grid -->
<!-- Manual jog -->
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<span class="card-title">Manual Jog</span>
<span style="font-size:0.72rem;color:var(--text-muted)">Incremental step control — bypasses IK</span>
</div>
<div class="jog-compact">
<!-- Motor 1 -->
<div class="jog-motor">
<div class="jog-title" style="color:rgba(68,138,255,0.9)">Motor 1 — Left</div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-ccw">◀ CCW</button>
<input class="form-input" type="number" id="jog-steps-1"
value="50" min="1" max="2048"
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-cw">CW ▶</button>
</div>
<div class="quick-steps">
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="${n}">+${n}</button>`).join('')}
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="-${n}">-${n}</button>`).join('')}
</div>
<button class="btn btn-danger btn-sm btn-full" id="s1-zero">Zero M1</button>
</div>
<!-- Motor 2 -->
<div class="jog-motor">
<div class="jog-title" style="color:rgba(247,160,60,0.9)">Motor 2 — Right</div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-ccw">◀ CCW</button>
<input class="form-input" type="number" id="jog-steps-2"
value="50" min="1" max="2048"
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-cw">CW ▶</button>
</div>
<div class="quick-steps">
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="${n}">+${n}</button>`).join('')}
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="-${n}">-${n}</button>`).join('')}
</div>
<button class="btn btn-danger btn-sm btn-full" id="s2-zero">Zero M2</button>
</div>
</div>
</div>
<!-- Event log -->
<div class="card">
<div class="log-header">
<span class="log-title">Event Log</span>
<button class="log-clear-btn" id="clear-log-btn">Clear</button>
</div>
<div class="log-console" id="log-console" aria-live="polite"></div>
</div>
</div>
`;
}
// ─────────────────────────────────────────────────────────────────
// Section lifecycle
// ─────────────────────────────────────────────────────────────────
const StepperTestSection = {
mount(container) {
container.innerHTML = buildHTML();
// ── Cache refs ──────────────────────────────────────────────
svgEl = document.getElementById('scara-svg');
arm1Prox = document.getElementById('arm1-prox');
arm1Dist = document.getElementById('arm1-dist');
arm2Prox = document.getElementById('arm2-prox');
arm2Dist = document.getElementById('arm2-dist');
elbow1 = document.getElementById('elbow1');
elbow2 = document.getElementById('elbow2');
endEff = document.getElementById('end-eff');
coordLabel= document.getElementById('coord-label');
xhH = document.getElementById('xh-h');
xhV = document.getElementById('xh-v');
ghost1Prox = document.getElementById('ghost-1p');
ghost1Dist = document.getElementById('ghost-1d');
ghost2Prox = document.getElementById('ghost-2p');
ghost2Dist = document.getElementById('ghost-2d');
ghostElbow1 = document.getElementById('ghost-e1');
ghostElbow2 = document.getElementById('ghost-e2');
ghostEnd = document.getElementById('ghost-end');
ghostLabel = document.getElementById('ghost-label');
targetMarker= document.getElementById('target-marker');
elCurrX = document.getElementById('curr-x');
elCurrY = document.getElementById('curr-y');
elTheta1 = document.getElementById('curr-t1');
elTheta2 = document.getElementById('curr-t2');
elSteps1 = document.getElementById('curr-s1');
elSteps2 = document.getElementById('curr-s2');
simBadge = document.getElementById('sim-badge');
// ── SVG coordinate helpers ───────────────────────────────────
function pointerToWorld(e) {
const rect = svgEl.getBoundingClientRect();
const sx = (e.clientX - rect.left) * (SV.W / rect.width);
const sy = (e.clientY - rect.top) * (SV.H / rect.height);
return { wx: SV.svgToWorldX(sx), wy: SV.svgToWorldY(sy), sx, sy };
}
// ── SVG hover ───────────────────────────────────────────────
svgEl.addEventListener('mousemove', (e) => {
const { wx, wy } = pointerToWorld(e);
const ws = IK.checkWorkspace(wx, wy);
if (ws.ok) {
const { theta1, theta2 } = IK.solve(wx, wy);
showGhost(theta1, theta2, `(${wx.toFixed(0)},${wy.toFixed(0)})`);
svgEl.style.cursor = 'crosshair';
} else {
hideGhost();
svgEl.style.cursor = 'not-allowed';
}
});
svgEl.addEventListener('mouseleave', () => {
hideGhost();
svgEl.style.cursor = 'crosshair';
});
// ── SVG click ───────────────────────────────────────────────
svgEl.addEventListener('click', async (e) => {
const { wx, wy, sx, sy } = pointerToWorld(e);
const ws = IK.checkWorkspace(wx, wy);
if (!ws.ok) {
UI.log(`${ws.reason}`, 'warn');
return;
}
showTargetMarker(sx, sy);
const xi = document.getElementById('input-x');
const yi = document.getElementById('input-y');
if (xi) xi.value = wx.toFixed(1);
if (yi) yi.value = wy.toFixed(1);
await moveToXY(wx, wy);
});
// ── Move to XY button ────────────────────────────────────────
document.getElementById('btn-goto').addEventListener('click', async () => {
const x = parseFloat(document.getElementById('input-x').value);
const y = parseFloat(document.getElementById('input-y').value);
if (isNaN(x) || isNaN(y)) { UI.log('Invalid XY.', 'warn'); return; }
await moveToXY(x, y);
});
// ── Jog CW / CCW ────────────────────────────────────────────
const gs = (n) => parseInt(document.getElementById(`jog-steps-${n}`)?.value ?? '50', 10) || 50;
document.getElementById('s1-cw') .addEventListener('click', () => jogMotor(1, +gs(1)));
document.getElementById('s1-ccw').addEventListener('click', () => jogMotor(1, -gs(1)));
document.getElementById('s2-cw') .addEventListener('click', () => jogMotor(2, +gs(2)));
document.getElementById('s2-ccw').addEventListener('click', () => jogMotor(2, -gs(2)));
document.getElementById('s1-zero').addEventListener('click', () => zeroMotor(1));
document.getElementById('s2-zero').addEventListener('click', () => zeroMotor(2));
// ── Quick-step buttons ────────────────────────────────────────
container.querySelectorAll('[data-motor][data-steps]').forEach(btn => {
btn.addEventListener('click', () =>
jogMotor(parseInt(btn.dataset.motor, 10), parseInt(btn.dataset.steps, 10)));
});
// ── Speed / Accel sliders ─────────────────────────────────────
let spdT, accT;
document.getElementById('speed-slider').addEventListener('input', (e) => {
document.getElementById('speed-val').textContent = e.target.value;
clearTimeout(spdT);
spdT = setTimeout(() => {
const cmd = `SPD:${e.target.value}`;
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
}, 400);
});
document.getElementById('accel-slider').addEventListener('input', (e) => {
document.getElementById('accel-val').textContent = e.target.value;
clearTimeout(accT);
accT = setTimeout(() => {
const cmd = `ACC:${e.target.value}`;
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
}, 400);
});
// ── Log clear ─────────────────────────────────────────────────
document.getElementById('clear-log-btn').addEventListener('click', UI.clearLog);
// ── BLE badge ─────────────────────────────────────────────────
function updateBadge() {
if (!simBadge) return;
const live = BLE.isConnected();
simBadge.textContent = live ? 'LIVE' : 'SIMULATION';
simBadge.style.background = live ? 'rgba(0,230,118,0.12)' : 'rgba(255,202,40,0.12)';
simBadge.style.color = live ? 'var(--accent-green)' : 'var(--accent-amber)';
simBadge.style.border = live ? '1px solid rgba(0,230,118,0.3)' : '1px solid rgba(255,202,40,0.25)';
}
BLE.on('connected', updateBadge);
BLE.on('disconnected', updateBadge);
updateBadge();
// ── BLE NOTIFY position feedback ──────────────────────────────
function onBLEStatus(e) {
const msg = e.detail;
if (msg.startsWith('P:')) {
const [s1, s2] = msg.slice(2).split(',').map(Number);
steps1 = s1; steps2 = s2;
renderArmFromSteps();
}
}
document.addEventListener('ble:status', onBLEStatus);
eventCleanup = [
() => BLE.off('connected', updateBadge),
() => BLE.off('disconnected', updateBadge),
() => document.removeEventListener('ble:status', onBLEStatus),
];
// ── Initial render ─────────────────────────────────────────────
renderArmFromSteps();
UI.log('Stepper Test ready — 5-bar parallel IK active.', 'success');
UI.log(`Arm: d=${IK.ARM.d}mm l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm`, 'info');
if (!BLE.isConnected()) {
UI.log('Simulation mode — BLE commands logged but not sent.', 'warn');
}
},
unmount() {
eventCleanup.forEach(fn => fn());
eventCleanup = [];
},
};
export default StepperTestSection;
+111
View File
@@ -0,0 +1,111 @@
/**
* WijiBoard Shared UI Utilities
* web/js/ui.js
*
* Exports a UI object with helpers used across all sections.
* Requires the SPA shell to contain:
* #ble-status, #ble-label (top-bar BLE indicator)
* #log-console (global event log)
* #btn-ble-connect (top-bar connect button)
*/
import BLE from './ble.js';
const UI = (() => {
// ── Log console ─────────────────────────────────────────────
function log(message, type = 'info') {
const console = document.getElementById('log-console');
if (!console) return;
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
const entry = document.createElement('span');
entry.className = `log-entry ${type}`;
entry.textContent = `[${ts}] ${message}`;
const br = document.createElement('br');
console.appendChild(entry);
console.appendChild(br);
console.scrollTop = console.scrollHeight;
}
function clearLog() {
const el = document.getElementById('log-console');
if (el) el.innerHTML = '';
}
// ── BLE status indicator (top bar) ──────────────────────────
function setConnectionStatus(connected, deviceName = null) {
const statusEl = document.getElementById('ble-status');
const labelEl = document.getElementById('ble-label');
const btn = document.getElementById('btn-ble-connect');
if (statusEl) statusEl.classList.toggle('connected', connected);
if (labelEl) labelEl.textContent = connected
? (deviceName ?? 'Connected')
: 'Disconnected';
if (btn) {
btn.disabled = connected;
btn.innerHTML = connected
? '<span class="btn-text">Disconnect</span>'
: '<span class="btn-text">Connect</span>';
btn.className = connected
? 'btn btn-sm btn-danger'
: 'btn btn-sm btn-primary';
// Rebind: when connected, button should disconnect
btn.onclick = connected
? () => BLE.disconnect()
: () => BLE.connect().catch(e => {
if (!e.message?.includes('cancelled')) {
log(`Connect failed: ${e.message}`, 'error');
}
});
}
}
// ── Button loading state ─────────────────────────────────────
function setButtonLoading(btnId, loading) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.classList.toggle('loading', loading);
btn.disabled = loading;
// Toggle spinner visibility
const spinner = btn.querySelector('.spinner');
if (spinner) spinner.style.display = loading ? 'block' : 'none';
}
// ── Wire BLE events to UI ────────────────────────────────────
function initBLEListeners() {
BLE.on('connecting', () => {
log('Scanning for WijiBoard…', 'info');
setButtonLoading('btn-ble-connect', true);
});
BLE.on('connected', (name) => {
setButtonLoading('btn-ble-connect', false);
setConnectionStatus(true, name);
log(`Connected to "${name}" ✓`, 'success');
});
BLE.on('disconnected', () => {
setButtonLoading('btn-ble-connect', false);
setConnectionStatus(false);
log('BLE connection lost.', 'warn');
});
BLE.on('sent', (cmd) => {
log(`${cmd}`, 'sent');
});
BLE.on('status', (msg) => {
// Forward raw status messages to sections that need them
document.dispatchEvent(new CustomEvent('ble:status', { detail: msg }));
});
}
return { log, clearLog, setConnectionStatus, setButtonLoading, initBLEListeners };
})();
export default UI;
+131
View File
@@ -0,0 +1,131 @@
/* ============================================================
WijiBoard — Design Tokens, Reset & Typography
web/styles/base.css
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/* ── Design tokens ─────────────────────────────────────────── */
:root {
/* Background layers */
--bg: #0d0f14;
--surface: #161923;
--surface-2: #1e2330;
--surface-3: #252a3a;
/* Borders */
--border: rgba(255, 255, 255, 0.07);
--border-hover: rgba(255, 255, 255, 0.14);
/* Accent palette */
--accent-blue: #448aff;
--accent-green: #00e676;
--accent-red: #ff5252;
--accent-amber: #ffca28;
--accent-purple: #e040fb;
/* Text */
--text-primary: #e8eaf0;
--text-secondary: #a0a8bc;
--text-muted: #6c7385;
/* Glow effects */
--glow-blue: 0 0 28px rgba(68, 138, 255, 0.45);
--glow-green: 0 0 28px rgba(0, 230, 118, 0.50);
--glow-red: 0 0 28px rgba(255, 82, 82, 0.38);
--glow-amber: 0 0 20px rgba(255, 202, 40, 0.40);
/* Layout */
--sidebar-width: 240px;
--topbar-height: 60px;
--content-padding: 24px;
/* Shape */
--radius-xl: 20px;
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 8px;
--radius-xs: 5px;
/* Motion */
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
--transition-fast: 0.12s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.38s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset ─────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Ambient background glow */
background-image:
radial-gradient(ellipse 70% 40% at 50% 0%, rgba(68, 138, 255, 0.09) 0%, transparent 70%),
radial-gradient(ellipse 40% 30% at 90% 80%, rgba(0, 230, 118, 0.06) 0%, transparent 60%);
}
/* ── Typography scale ───────────────────────────────────────── */
h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.025em; line-height: 1.2; }
h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; line-height: 1.3; }
h3 { font-size: 1rem; font-weight: 600; letter-spacing: -0.01em; }
h4 { font-size: 0.875rem;font-weight: 600; }
p { color: var(--text-secondary); line-height: 1.7; }
a { color: var(--accent-blue); text-decoration: none; }
a:hover { text-decoration: underline; }
small { font-size: 0.75rem; color: var(--text-muted); }
/* ── Utility: visually hidden ───────────────────────────────── */
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
/* ── Utility: scrollbars ────────────────────────────────────── */
* {
scrollbar-width: thin;
scrollbar-color: var(--surface-3) transparent;
}
*::-webkit-scrollbar { width: 5px; height: 5px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
*::-webkit-scrollbar-thumb:hover { background: var(--surface-2); }
/* ── Utility: spinner ───────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.65s linear infinite;
}
/* ── Utility: pulse ─────────────────────────────────────────── */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Utility: fade-in ───────────────────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.3s var(--transition) both; }
+657
View File
@@ -0,0 +1,657 @@
/* ============================================================
WijiBoard — Reusable Component Styles
web/styles/components.css
Requires: base.css
============================================================ */
/* ══════════════════════════════════════════════════════════════
APP SHELL — Top bar + Sidebar + Layout
══════════════════════════════════════════════════════════════ */
.app-shell {
display: flex;
height: 100vh;
overflow: hidden;
position: relative;
}
/* ── Top bar ─────────────────────────────────────────────────── */
.topbar {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--topbar-height);
background: rgba(13, 15, 20, 0.85);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 14px;
z-index: 100;
box-shadow: 0 1px 0 rgba(255,255,255,0.03);
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: -0.01em;
color: var(--text-primary);
flex: 1;
}
.topbar-brand .brand-icon {
width: 30px; height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #1a2a4a, #0f1c38);
border: 1px solid rgba(68,138,255,0.3);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
box-shadow: 0 0 12px rgba(68,138,255,0.25);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* ── Hamburger button ────────────────────────────────────────── */
.hamburger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 36px; height: 36px;
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: var(--radius-sm);
transition: background var(--transition);
flex-shrink: 0;
}
.hamburger:hover { background: var(--surface-2); }
.hamburger-bar {
width: 18px; height: 2px;
background: var(--text-secondary);
border-radius: 2px;
transition: transform var(--transition), opacity var(--transition);
}
.hamburger.open .hamburger-bar:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hamburger.open .hamburger-bar:nth-child(2) { opacity: 0; transform: scaleX(0); }
.hamburger.open .hamburger-bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* ── Sidebar ─────────────────────────────────────────────────── */
.sidebar {
position: fixed;
top: var(--topbar-height);
left: 0;
bottom: 0;
width: var(--sidebar-width);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px 10px;
gap: 4px;
z-index: 90;
transform: translateX(0);
transition: transform var(--transition-slow);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar.collapsed {
transform: translateX(calc(-1 * var(--sidebar-width)));
}
/* Sidebar overlay (mobile / collapsed state backdrop) */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 89;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-slow);
}
.sidebar-overlay.visible {
opacity: 1;
pointer-events: auto;
}
/* Sidebar nav section label */
.sidebar-section-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
padding: 12px 10px 6px;
}
/* Sidebar nav item */
.sidebar-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
border: none;
background: none;
width: 100%;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
transition: background var(--transition), color var(--transition);
text-align: left;
text-decoration: none;
}
.sidebar-nav-item:hover {
background: var(--surface-2);
color: var(--text-primary);
}
.sidebar-nav-item.active {
background: rgba(68,138,255,0.12);
color: var(--accent-blue);
border: 1px solid rgba(68,138,255,0.2);
}
.sidebar-nav-item .nav-icon {
width: 18px; height: 18px;
opacity: 0.7;
flex-shrink: 0;
}
.sidebar-nav-item.active .nav-icon { opacity: 1; }
/* ── Main content area ───────────────────────────────────────── */
.main-content {
margin-left: var(--sidebar-width);
margin-top: var(--topbar-height);
flex: 1;
height: calc(100vh - var(--topbar-height));
overflow-y: auto;
padding: var(--content-padding);
transition: margin-left var(--transition-slow);
}
.main-content.sidebar-collapsed {
margin-left: 0;
}
/* ══════════════════════════════════════════════════════════════
BLE STATUS (top bar)
══════════════════════════════════════════════════════════════ */
.ble-status {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 100px;
padding: 5px 12px 5px 8px;
transition: border-color var(--transition);
}
.ble-status.connected {
border-color: rgba(0, 230, 118, 0.3);
background: rgba(0, 230, 118, 0.07);
}
.ble-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
transition: background var(--transition), box-shadow var(--transition);
}
.ble-status.connected .ble-dot {
background: var(--accent-green);
box-shadow: 0 0 8px rgba(0,230,118,0.7);
animation: pulse 2s ease-in-out infinite;
}
.ble-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
transition: color var(--transition);
white-space: nowrap;
}
.ble-status.connected .ble-label { color: var(--accent-green); }
/* ══════════════════════════════════════════════════════════════
BUTTONS
══════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 16px;
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.01em;
white-space: nowrap;
transition: transform var(--transition), box-shadow var(--transition),
background var(--transition), border-color var(--transition),
opacity var(--transition);
position: relative;
overflow: hidden;
user-select: none;
}
.btn::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255,255,255,0.07);
opacity: 0;
transition: opacity var(--transition);
}
.btn:hover:not(:disabled)::after { opacity: 1; }
.btn:active:not(:disabled) { transform: scale(0.96); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Variants */
.btn-primary {
background: linear-gradient(135deg, #2a3f80, #1a2a5e);
color: #fff;
border-color: rgba(68,138,255,0.4);
}
.btn-primary:hover:not(:disabled) {
box-shadow: var(--glow-blue);
border-color: var(--accent-blue);
}
.btn-success {
background: linear-gradient(135deg, #0a3322, #052212);
color: var(--accent-green);
border-color: rgba(0,230,118,0.3);
}
.btn-success:hover:not(:disabled) {
box-shadow: var(--glow-green);
border-color: var(--accent-green);
}
.btn-danger {
background: linear-gradient(135deg, #3a1a1a, #2a1010);
color: #ffb3b3;
border-color: rgba(255,82,82,0.3);
}
.btn-danger:hover:not(:disabled) {
box-shadow: var(--glow-red);
border-color: var(--accent-red);
}
.btn-ghost {
background: var(--surface-2);
color: var(--text-primary);
border-color: var(--border);
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--border-hover);
background: var(--surface-3);
}
.btn-icon-only {
padding: 9px;
min-width: 38px;
}
/* Size variants */
.btn-sm { padding: 6px 12px; font-size: 0.78rem; border-radius: var(--radius-sm); }
.btn-lg { padding: 13px 22px; font-size: 0.95rem; }
.btn-full { width: 100%; }
/* Loading state */
.btn.loading .btn-text { opacity: 0; }
.btn.loading .spinner { position: absolute; }
/* ══════════════════════════════════════════════════════════════
CARDS
══════════════════════════════════════════════════════════════ */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.35),
inset 0 1px 0 rgba(255,255,255,0.04);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 10px;
}
.card-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-muted);
}
.card-title-accent {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* ══════════════════════════════════════════════════════════════
STATUS ROW / PILL
══════════════════════════════════════════════════════════════ */
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 14px;
gap: 10px;
}
.status-label {
font-size: 0.73rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-muted);
}
.status-value {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-secondary);
}
/* ══════════════════════════════════════════════════════════════
LOG CONSOLE
══════════════════════════════════════════════════════════════ */
.log-console {
background: #07090d;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
height: 140px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
font-size: 0.72rem;
line-height: 1.75;
}
.log-entry { display: block; }
.log-entry.info { color: #6a7a9a; }
.log-entry.success { color: var(--accent-green); }
.log-entry.error { color: var(--accent-red); }
.log-entry.warn { color: var(--accent-amber); }
.log-entry.sent { color: #7eb8f7; }
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.log-title {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-muted);
}
.log-clear-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.70rem;
font-family: inherit;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
.log-clear-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.06); }
/* ══════════════════════════════════════════════════════════════
FORM CONTROLS (input, range, number)
══════════════════════════════════════════════════════════════ */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 0.73rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.form-input {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.875rem;
padding: 8px 12px;
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
}
.form-input:focus {
border-color: rgba(68,138,255,0.5);
box-shadow: 0 0 0 3px rgba(68,138,255,0.12);
}
.form-input[type="number"] { width: 80px; text-align: center; }
/* Range slider */
.form-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
background: var(--surface-3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.form-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--accent-blue);
box-shadow: 0 0 8px rgba(68,138,255,0.5);
transition: box-shadow var(--transition);
cursor: pointer;
}
.form-range::-webkit-slider-thumb:hover {
box-shadow: var(--glow-blue);
}
.form-range::-moz-range-thumb {
width: 16px; height: 16px;
border: none;
border-radius: 50%;
background: var(--accent-blue);
cursor: pointer;
}
.range-row {
display: flex;
align-items: center;
gap: 10px;
}
.range-value {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary);
min-width: 36px;
text-align: right;
}
/* ══════════════════════════════════════════════════════════════
STEPPER JOG PANEL
══════════════════════════════════════════════════════════════ */
.stepper-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.stepper-card:hover {
border-color: rgba(68,138,255,0.18);
}
.stepper-pos-display {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stepper-pos-item {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
text-align: center;
}
.stepper-pos-num {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent-blue);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.stepper-pos-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-top: 2px;
}
.jog-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.jog-row {
display: flex;
align-items: center;
gap: 8px;
}
.jog-row .btn {
flex: 1;
}
.step-input-wrapper {
display: flex;
align-items: center;
gap: 6px;
}
.step-input-wrapper label {
font-size: 0.72rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ══════════════════════════════════════════════════════════════
SCARA VISUALIZER
══════════════════════════════════════════════════════════════ */
.visualizer-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
padding: 18px;
gap: 10px;
}
.scara-svg {
width: 100%;
max-width: 340px;
border-radius: var(--radius-md);
background: #070a0f;
border: 1px solid var(--border);
}
/* SVG internal elements */
.arm-seg-1 { stroke: var(--accent-blue); stroke-width: 5; stroke-linecap: round; }
.arm-seg-2 { stroke: #7eb8f7; stroke-width: 4; stroke-linecap: round; }
.joint-base { fill: var(--accent-blue); }
.joint-elbow{ fill: #a0c4ff; }
.end-eff { fill: var(--accent-green); }
.workspace-arc { stroke: rgba(68,138,255,0.12); stroke-width: 1; fill: none; }
.grid-line { stroke: rgba(255,255,255,0.04); stroke-width: 1; }
.crosshair { stroke: rgba(0,230,118,0.35); stroke-width: 1; }
/* ══════════════════════════════════════════════════════════════
SECTION WRAPPERS
══════════════════════════════════════════════════════════════ */
.section-page {
max-width: 1100px;
margin: 0 auto;
animation: fadeIn 0.25s var(--transition) both;
}
.section-header {
margin-bottom: 24px;
}
.section-header h2 {
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.section-header p {
margin-top: 4px;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Grid layouts */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
/* Responsive */
@media (max-width: 768px) {
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.main-content { padding: 16px; }
.sidebar { width: 220px; }
:root { --sidebar-width: 220px; }
}
/* Divider */
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}