From 67b4eda6aa796ab39fac8d02e1fda15b8f4ab922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PROFERIS=20-=20Mi=C2=B3osz=20Stocki?= Date: Mon, 6 Jul 2026 11:34:03 +0200 Subject: [PATCH] Initial commit --- fivebarIKGame.js | 12 + index.html | 204 +++++++++ platformio.ini | 21 + src/main.cpp | 235 ++++++++++ web/js/ble.js | 139 ++++++ web/js/kinematics.js | 261 +++++++++++ web/js/router.js | 91 ++++ web/js/sections/home.js | 107 +++++ web/js/sections/stepper-test.js | 766 ++++++++++++++++++++++++++++++++ web/js/ui.js | 111 +++++ web/styles/base.css | 131 ++++++ web/styles/components.css | 657 +++++++++++++++++++++++++++ 12 files changed, 2735 insertions(+) create mode 100644 fivebarIKGame.js create mode 100644 index.html create mode 100644 platformio.ini create mode 100644 src/main.cpp create mode 100644 web/js/ble.js create mode 100644 web/js/kinematics.js create mode 100644 web/js/router.js create mode 100644 web/js/sections/home.js create mode 100644 web/js/sections/stepper-test.js create mode 100644 web/js/ui.js create mode 100644 web/styles/base.css create mode 100644 web/styles/components.css diff --git a/fivebarIKGame.js b/fivebarIKGame.js new file mode 100644 index 0000000..e4db596 --- /dev/null +++ b/fivebarIKGame.js @@ -0,0 +1,12 @@ +const x = { robot: { groundWidth: 25.8, linkLength1: 85, linkLength2: 110 }, motor1: { min: -330, max: -90 }, motor2: { min: -90, max: 150 }, control: { fk: { normal: 80, fast: 210 }, ik: { normal: 71.5, fast: 169 } }, game: { duration: 3e4, countdownDuration: 3e3, warningTime: 5e3, hitRadius: 3, noGoRadius: 50, targetRegion: { xMin: -136, xMax: 136, yMin: 1, yMax: 65 } }, render: { linkThickness: 3, jointRadius: 1.5, colors: { bg: "#0a0e12", ground: "#3b82f6", link1: "#10b981", link2: "#fbbf24", joint: "#e5e7eb", endEffector: "#fbbf24", target: "#22c55e", targetUrgent: "#ef4444", trail: "rgba(59, 130, 246, 0.3)" } }, minElbowAngleDeg: .5 }, c = { degToRad: r => r * Math.PI / 180, radToDeg: r => r * 180 / Math.PI, clamp: (r, t, e) => Math.max(t, Math.min(e, r)), lerp: (r, t, e) => r + (t - r) * e, dist: (r, t) => Math.hypot(t.x - r.x, t.y - r.y), cross: (r, t) => r.x * t.y - r.y * t.x, magnitude: r => Math.hypot(r.x, r.y), normalize: r => { const t = c.magnitude(r); return t > 0 ? { x: r.x / t, y: r.y / t } : { x: 0, y: 0 } }, unwrapAngle: (r, t) => { let e = r; for (; e - t > 180;)e -= 360; for (; e - t < -180;)e += 360; return e }, intersectCircles: (r, t, e) => { const i = t.x - r.x, s = t.y - r.y, a = Math.hypot(i, s); if (a > 2 * e || a < 1e-9) return null; const o = (r.x + t.x) / 2, n = (r.y + t.y) / 2, h = Math.sqrt(Math.max(0, e * e - a * a / 4)), m = -s / a, l = i / a; return [{ x: o + m * h, y: n + l * h }, { x: o - m * h, y: n - l * h }] } }; class S { constructor(t) { this.config = t, this.minElbowSin = Math.sin(c.degToRad(t.minElbowAngleDeg)) } config; minElbowSin; fkBranchIndex = null; ikElbowSigns = { left: null, right: null }; eeHistory = []; isValidElbow(t, e) { const i = Math.abs(c.cross(t, e)), s = c.magnitude(t) * c.magnitude(e); return i / s >= this.minElbowSin } solveFK(t, e) { const { groundWidth: i, linkLength1: s, linkLength2: a } = this.config.robot, o = { x: -i / 2, y: 0 }, n = { x: i / 2, y: 0 }, h = c.degToRad(t), m = c.degToRad(e), l = { x: o.x + s * Math.cos(h), y: o.y + s * Math.sin(h) }, d = { x: n.x + s * Math.cos(m), y: n.y + s * Math.sin(m) }, y = c.intersectCircles(l, d, a); if (!y) return { valid: !1, baseLeft: o, baseRight: n, joint1: l, joint2: d, endEffector: { x: (l.x + d.x) / 2, y: (l.y + d.y) / 2 } }; const [g, f] = y, p = this.isValidElbow({ x: l.x - o.x, y: l.y - o.y }, { x: g.x - l.x, y: g.y - l.y }) && this.isValidElbow({ x: d.x - n.x, y: d.y - n.y }, { x: g.x - d.x, y: g.y - d.y }), v = this.isValidElbow({ x: l.x - o.x, y: l.y - o.y }, { x: f.x - l.x, y: f.y - l.y }) && this.isValidElbow({ x: d.x - n.x, y: d.y - n.y }, { x: f.x - d.x, y: f.y - d.y }); if (!p && !v) return { valid: !1, baseLeft: o, baseRight: n, joint1: l, joint2: d, endEffector: g }; let u; if (this.fkBranchIndex === 0 && p) u = g; else if (this.fkBranchIndex === 1 && v) u = f; else if (p && !v) this.fkBranchIndex = 0, u = g; else if (v && !p) this.fkBranchIndex = 1, u = f; else if (this.eeHistory.length === 0) this.fkBranchIndex = g.y >= f.y ? 0 : 1, u = this.fkBranchIndex === 0 ? g : f; else { const b = this.eeHistory[this.eeHistory.length - 1], w = c.dist(g, b), k = c.dist(f, b); this.fkBranchIndex = w <= k ? 0 : 1, u = this.fkBranchIndex === 0 ? g : f } return { valid: !0, baseLeft: o, baseRight: n, joint1: l, joint2: d, endEffector: u } } solve2Link(t, e, i, s) { const a = e.x - t.x, o = e.y - t.y, n = Math.hypot(a, o); if (n > i + s || n < Math.abs(i - s) || n < 1e-9) return null; const h = c.clamp((n * n - i * i - s * s) / (2 * i * s), -1, 1), m = Math.acos(h), l = -m, d = []; for (const y of [m, l]) { const g = Math.sin(y); if (Math.abs(g) < this.minElbowSin) continue; const f = i + s * Math.cos(y), p = s * Math.sin(y), v = Math.atan2(o, a) - Math.atan2(p, f); d.push({ angle: v, elbowSign: g > 0 ? 1 : -1 }) } return d.length > 0 ? d : null } solveIK(t, e) { const { groundWidth: i, linkLength1: s, linkLength2: a } = this.config.robot, o = { x: -i / 2, y: 0 }, n = { x: i / 2, y: 0 }, h = this.solve2Link(o, t, s, a), m = this.solve2Link(n, t, s, a); if (!h || !m) return null; let l = null, d = 1 / 0; const y = this.ikElbowSigns.left, g = this.ikElbowSigns.right; for (const f of h) { if (y !== null && f.elbowSign !== y) continue; let p = c.radToDeg(f.angle); if (p = c.unwrapAngle(p, e.a1), !(p < this.config.motor1.min || p > this.config.motor1.max)) for (const v of m) { if (g !== null && v.elbowSign !== g) continue; let u = c.radToDeg(v.angle); if (u = c.unwrapAngle(u, e.a2), u < this.config.motor2.min || u > this.config.motor2.max || !this.solveFK(p, u).valid) continue; const w = Math.abs(p - e.a1) + Math.abs(u - e.a2); w < d && (d = w, l = { angle1: p, angle2: u, leftElbow: f.elbowSign, rightElbow: v.elbowSign }) } } return l && (this.ikElbowSigns.left = l.leftElbow, this.ikElbowSigns.right = l.rightElbow), l } updateHistory(t) { this.eeHistory.push({ ...t }), this.eeHistory.length > 5 && this.eeHistory.shift() } resetBranches() { this.fkBranchIndex = null, this.ikElbowSigns = { left: null, right: null }, this.eeHistory = [] } } class T { constructor(t, e) { this.canvas = t, this.config = e; const i = t.getContext("2d", { alpha: !1 }); if (!i) throw new Error("Could not get canvas context"); this.ctx = i } canvas; config; ctx; scale = 1; offsetX = 0; offsetY = 0; trail = []; targetPulse = 0; resizeObserver = null; onResize; setupResizeObserver(t) { if (this.onResize = t, typeof window < "u" && "ResizeObserver" in window) { this.resizeObserver = new ResizeObserver(() => { this.resize(), this.onResize && this.onResize() }); const e = this.canvas.parentElement; e && this.resizeObserver.observe(e) } else if (typeof window < "u") { const e = () => { this.resize(), this.onResize && this.onResize() }; window.addEventListener("resize", e) } } cleanup() { this.resizeObserver && (this.resizeObserver.disconnect(), this.resizeObserver = null) } resize() { const t = window.devicePixelRatio || 1, e = this.canvas.getBoundingClientRect(); this.canvas.width = e.width * t, this.canvas.height = e.height * t, this.ctx.scale(t, t); const { groundWidth: i, linkLength1: s, linkLength2: a } = this.config.robot, o = i / 2 + s + a, n = s + a, h = e.width < 500 ? .85 : .8, m = e.width * h / (2 * o), l = e.height * h / n; this.scale = Math.min(m, l), this.offsetX = e.width / 2, this.offsetY = e.height * (e.height < 500 ? .65 : .75) } worldToScreen(t) { return { x: this.offsetX + t.x * this.scale, y: this.offsetY - t.y * this.scale } } clear() { const { width: t, height: e } = this.canvas.getBoundingClientRect(), i = this.canvas.closest(".fivebar-ik-container"), s = i ? getComputedStyle(i).getPropertyValue("--fivebar-bg-tertiary").trim() : this.config.render.colors.bg; this.ctx.fillStyle = s || this.config.render.colors.bg, this.ctx.fillRect(0, 0, t, e) } drawLine(t, e, i, s = 2) { const a = this.worldToScreen(t), o = this.worldToScreen(e); this.ctx.strokeStyle = i, this.ctx.lineWidth = s, this.ctx.lineCap = "round", this.ctx.beginPath(), this.ctx.moveTo(a.x, a.y), this.ctx.lineTo(o.x, o.y), this.ctx.stroke() } drawCircle(t, e, i, s) { const a = this.worldToScreen(t), o = e * this.scale; this.ctx.beginPath(), this.ctx.arc(a.x, a.y, o, 0, Math.PI * 2), i && (this.ctx.fillStyle = i, this.ctx.fill()), s && (this.ctx.strokeStyle = s, this.ctx.lineWidth = 2, this.ctx.stroke()) } drawTrail() { if (this.trail.length < 2) return; this.ctx.strokeStyle = this.config.render.colors.trail, this.ctx.lineWidth = 2, this.ctx.lineCap = "round", this.ctx.lineJoin = "round", this.ctx.beginPath(); const t = this.worldToScreen(this.trail[0]); this.ctx.moveTo(t.x, t.y); for (let e = 1; e < this.trail.length; e++) { const i = this.worldToScreen(this.trail[e]); this.ctx.lineTo(i.x, i.y) } this.ctx.stroke() } drawMechanism(t, e) { const { baseLeft: i, baseRight: s, joint1: a, joint2: o, endEffector: n } = t, h = this.config.render.colors; this.drawTrail(), this.drawLine(i, s, h.ground, this.config.render.linkThickness * this.scale), this.drawLine(i, a, h.link1, this.config.render.linkThickness * this.scale), this.drawLine(s, o, h.link1, this.config.render.linkThickness * this.scale), this.drawLine(a, n, h.link2, this.config.render.linkThickness * this.scale), this.drawLine(o, n, h.link2, this.config.render.linkThickness * this.scale); const m = this.config.render.jointRadius; this.drawCircle(i, m, h.ground), this.drawCircle(s, m, h.ground), this.drawCircle(a, m, h.joint), this.drawCircle(o, m, h.joint); const l = e === "IK" ? "#10b981" : "#fbbf24"; this.drawCircle(n, m * 1.2, l), this.drawCoordinates(n, e) } drawCoordinates(t, e) { const i = this.worldToScreen(t), s = `(${t.x.toFixed(1)}, ${t.y.toFixed(1)})`; this.ctx.font = "11px monospace", this.ctx.fillStyle = e === "IK" ? "#10b981" : "#3b82f6", this.ctx.textAlign = "left", this.ctx.fillText(s, i.x + 15, i.y - 10) } drawTarget(t, e = !1) { this.targetPulse += .05; const i = e ? 1 + Math.sin(this.targetPulse * 3) * .3 : 1 + Math.sin(this.targetPulse) * .1, s = e ? this.config.render.colors.targetUrgent : this.config.render.colors.target; this.drawCircle(t, 2 * i, s), this.drawCircle(t, this.config.game.hitRadius * i, void 0, s + "60"), e && this.drawCircle(t, this.config.game.hitRadius * i * 1.5, void 0, s + "30") } addToTrail(t) { this.trail.push({ ...t }), this.trail.length > 50 && this.trail.shift() } clearTrail() { this.trail = [] } worldToScreenPublic(t) { return this.worldToScreen(t) } } class M { + constructor(t, e, i, s) { this.container = t, this.config = e, this.kinematics = i, this.renderer = s, this.loadHighScores(), this.loadGamesPlayed(), this.cacheElements(), this.setupEventListeners(), this.updateUI() } container; config; kinematics; renderer; mode = "freeplay"; controlMode = "FK"; angles = { a1: -180, a2: 0 }; ikTarget = { x: 0, y: 40 }; gameState = { score: 0, startTime: 0, countdownStartTime: 0, timeLeft: x.game.duration, target: null, hitLatch: !1 }; highScores = { FK: 0, IK: 0 }; gamesPlayed = { FK: 0, IK: 0 }; keys = new Set; lastUpdate = performance.now(); animationFrameId = null; elements = {}; tutorialActive = !1; tutorialStep = "motor1"; tutorialProgress = { motor1Moved: !1, motor2Moved: !1, initialM1: -180, initialM2: 0 }; cacheElements() { const t = e => this.container.querySelector(e); this.elements = { header: t("[data-header]"), modeBadge: t("[data-mode-badge]"), timerBadge: t("[data-timer-badge]"), timeLeft: t("[data-time-left]"), scoreBadge: t("[data-score-badge]"), score: t("[data-score]"), hiBadge: t("[data-hi-badge]"), hiFk: t("[data-hi-fk]"), hiIk: t("[data-hi-ik]"), unreachable: t("[data-unreachable]"), helpBtn: t("[data-help-btn]"), playBtn: t("[data-play-btn]"), fkBtn: t("[data-fk-btn]"), ikBtn: t("[data-ik-btn]"), m1Slider: t("[data-m1-slider]"), m2Slider: t("[data-m2-slider]"), m1Value: t("[data-m1-value]"), m2Value: t("[data-m2-value]"), helpOverlay: t("[data-help-overlay]"), closeHelp: t("[data-close-help]"), welcomeOverlay: t("[data-welcome-overlay]"), closeWelcome: t("[data-close-welcome]"), showHelpFromWelcome: t("[data-show-help-from-welcome]"), gameoverOverlay: t("[data-gameover-overlay]"), finalScore: t("[data-final-score]"), gameoverMessage: t("[data-gameover-message]"), modeSuggestion: t("[data-mode-suggestion]"), suggestionText: t("[data-suggestion-text]"), gameoverContinue: t("[data-gameover-continue]"), gameoverSwitch: t("[data-gameover-switch]"), switchModeText: t("[data-switch-mode-text]"), countdownOverlay: t("[data-countdown-overlay]"), countdownNumber: t("[data-countdown-number]"), canvasContainer: t(".fivebar-canvas-container"), loading: t("[data-loading]"), tutorialBanner: t("[data-tutorial-banner]"), replayTutorial: t("[data-replay-tutorial]") } } setupEventListeners() { this.handleKeyDown = this.handleKeyDown.bind(this), this.handleKeyUp = this.handleKeyUp.bind(this), window.addEventListener("keydown", this.handleKeyDown), window.addEventListener("keyup", this.handleKeyUp), this.elements.helpBtn?.addEventListener("click", () => this.showHelp()), this.elements.playBtn?.addEventListener("click", () => this.startGame()), this.elements.fkBtn?.addEventListener("click", () => this.setControlMode("FK")), this.elements.ikBtn?.addEventListener("click", () => this.setControlMode("IK")), this.elements.m1Slider?.addEventListener("input", t => this.handleSlider(1, t.target.value)), this.elements.m2Slider?.addEventListener("input", t => this.handleSlider(2, t.target.value)), this.elements.closeHelp?.addEventListener("click", () => this.hideHelp()), this.elements.replayTutorial?.addEventListener("click", () => { this.hideHelp(), this.replayTutorial() }), this.elements.closeWelcome?.addEventListener("click", () => this.hideWelcome()), this.elements.showHelpFromWelcome?.addEventListener("click", () => { this.hideWelcome(), this.showHelp() }), this.elements.gameoverContinue?.addEventListener("click", () => this.hideGameOver()), this.elements.gameoverSwitch?.addEventListener("click", () => { this.toggleControlMode(), this.hideGameOver(), setTimeout(() => this.startGame(), 300) }), this.elements.helpOverlay?.addEventListener("click", t => { t.target.hasAttribute("data-help-overlay") && this.hideHelp() }) } handleKeyDown(t) { if (t.target.tagName === "INPUT" || t.target.tagName === "TEXTAREA") return; const e = t.key.toLowerCase(); if (this.tutorialActive && this.tutorialStep === "choice") { if (e === "p") { t.preventDefault(), this.advanceTutorialFromChoice("game"), this.startGame(); return } if (e === "tab") { t.preventDefault(), this.advanceTutorialFromChoice("ik"), this.toggleControlMode(); return } } if (this.tutorialActive && this.tutorialStep === "ik-move" && this.controlMode === "IK" && ["a", "d", "w", "s"].includes(e) && setTimeout(() => { this.tutorialActive && this.tutorialStep === "ik-move" && this.completeTutorial() }, 3e3), e === "escape") { t.preventDefault(), this.elements.helpOverlay?.classList.contains("fivebar-visible") ? this.hideHelp() : this.elements.gameoverOverlay?.classList.contains("fivebar-visible") ? this.hideGameOver() : (this.mode === "timed" || this.mode === "countdown") && this.endGame(!0); return } if (e === "h") { t.preventDefault(), this.toggleHelp(); return } if (e === "p") { t.preventDefault(), this.startGame(); return } if (e === "r" && (this.mode === "timed" || this.mode === "countdown")) { t.preventDefault(), this.startGame(); return } if (e === "tab") { t.preventDefault(), this.toggleControlMode(); return } this.keys.add(e), ["a", "d", "w", "s", "j", "l", "shift"].includes(e) && t.preventDefault() } handleKeyUp(t) { this.keys.delete(t.key.toLowerCase()) } handleSlider(t, e) { if (this.controlMode !== "FK") return; const i = parseInt(e) / 1e3; t === 1 ? this.angles.a1 = c.lerp(this.config.motor1.min, this.config.motor1.max, i) : this.angles.a2 = c.lerp(this.config.motor2.min, this.config.motor2.max, i), this.updateSliders(), this.render() } setControlMode(t) { if (this.controlMode !== t) { if (this.controlMode = t, this.kinematics.resetBranches(), t === "IK") { const e = this.kinematics.solveFK(this.angles.a1, this.angles.a2); e.valid && (this.ikTarget = { ...e.endEffector }) } this.renderer.clearTrail(), this.updateUI(), this.render() } } toggleControlMode() { this.setControlMode(this.controlMode === "FK" ? "IK" : "FK") } startGame() { this.mode = "countdown", this.gameState.score = 0, this.gameState.countdownStartTime = performance.now(), this.gameState.hitLatch = !1, this.gameState.target = null, this.showCountdown(), this.updateUI() } showCountdown() { const t = this.elements.countdownOverlay, e = this.elements.countdownNumber; if (!t || !e) return; t.classList.add("fivebar-visible"); const i = () => { const s = performance.now() - this.gameState.countdownStartTime, a = Math.ceil((this.config.game.countdownDuration - s) / 1e3); a > 0 ? (e.textContent = a.toString(), e.className = "fivebar-countdown-number", setTimeout(i, 100)) : (e.textContent = "GO!", e.className = "fivebar-countdown-number fivebar-go", setTimeout(() => { t.classList.remove("fivebar-visible"), this.startTimedMode() }, 800)) }; i() } startTimedMode() { this.mode = "timed", this.gameState.startTime = performance.now(), this.gameState.timeLeft = this.config.game.duration, this.spawnTarget(), this.updateUI() } endGame(t = !1) { if (this.mode !== "timed" && this.mode !== "countdown") return; const e = this.gameState.score; this.mode === "timed" && !t && (this.highScores[this.controlMode] = Math.max(this.highScores[this.controlMode], e), this.saveHighScores(), this.gamesPlayed[this.controlMode]++, this.saveGamesPlayed()), this.mode = "freeplay", this.gameState.target = null, this.elements.header?.classList.remove("fivebar-warning"), this.updateUI(), t || this.showGameOver(e) } showGameOver(t) { this.elements.finalScore && (this.elements.finalScore.textContent = t.toString()); let e = ""; t === 0 ? e = "Don't worry, it takes practice! Try again!" : t < 3 ? e = "Good start! You're getting the hang of it." : t < 5 ? e = "Nice work! You're improving!" : t < 8 ? e = "Great job! You're really getting good at this!" : t < 12 ? e = "Excellent! You're a natural!" : e = "Outstanding! You're a kinematics master!", this.elements.gameoverMessage && (this.elements.gameoverMessage.textContent = e); const i = this.gamesPlayed[this.controlMode], s = this.controlMode === "FK" ? "IK" : "FK", a = this.gamesPlayed[s], o = this.elements.modeSuggestion, n = this.elements.switchModeText; i === 1 && a === 0 ? (o && (o.style.display = "block"), this.controlMode === "FK" ? (this.elements.suggestionText && (this.elements.suggestionText.innerHTML = 'You just played in FK mode (controlling motor angles). Try IK mode by pressing Tab to see how inverse kinematics makes position control much more intuitive!'), n && (n.textContent = "Try IK Mode")) : (this.elements.suggestionText && (this.elements.suggestionText.innerHTML = 'You just played in IK mode (controlling position directly). Try FK mode by pressing Tab to see how much harder it is to control individual motor angles!'), n && (n.textContent = "Try FK Mode"))) : o && (o.style.display = "none"), this.elements.gameoverOverlay?.classList.add("fivebar-visible") } hideGameOver() { this.elements.gameoverOverlay?.classList.remove("fivebar-visible") } spawnTarget() { const t = this.config.game.targetRegion, e = this.config.game.noGoRadius, i = 1e3; for (let o = 0; o < i; o++) { const n = c.lerp(t.xMin, t.xMax, Math.random()), h = c.lerp(t.yMin, t.yMax, Math.random()); if (!(Math.hypot(n, h) < e || Math.hypot(n, h) < 60 && Math.random() > .4)) { this.gameState.target = { x: n, y: h }, this.gameState.hitLatch = !1; return } } const s = Math.random() * Math.PI * 2, a = e + 20 + Math.random() * 30; this.gameState.target = { x: Math.cos(s) * a, y: Math.abs(Math.sin(s) * a) + t.yMin }, this.gameState.hitLatch = !1 } checkHit(t) { if (this.mode !== "timed" || !this.gameState.target) return; const i = c.dist(t, this.gameState.target) <= this.config.game.hitRadius; i && !this.gameState.hitLatch ? (this.gameState.score++, this.gameState.hitLatch = !0, this.spawnTarget(), this.updateUI(), this.createHitEffect(t), this.createScorePopup(t)) : i || (this.gameState.hitLatch = !1) } createHitEffect(t) { const e = this.renderer.worldToScreenPublic(t), i = this.elements.canvasContainer; if (i) for (let s = 0; s < 12; s++) { const a = document.createElement("div"); a.className = "fivebar-particle", a.style.left = e.x + "px", a.style.top = e.y + "px", a.style.width = "8px", a.style.height = "8px", a.style.background = "#22c55e"; const o = s / 12 * Math.PI * 2, n = 30 + Math.random() * 25; a.style.setProperty("--tx", Math.cos(o) * n + "px"), a.style.setProperty("--ty", Math.sin(o) * n + "px"), i.appendChild(a), setTimeout(() => a.remove(), 600) } } createScorePopup(t) { const e = this.renderer.worldToScreenPublic(t), i = this.elements.canvasContainer; if (!i) return; const s = document.createElement("div"); s.className = "fivebar-score-popup", s.textContent = "+1", s.style.left = e.x + "px", s.style.top = e.y + "px", i.appendChild(s), setTimeout(() => s.remove(), 800) } update(t) { if (this.tutorialActive && this.updateTutorial(t), this.mode !== "countdown" && (this.controlMode === "FK" ? this.updateFK(t) : this.updateIK(t), this.mode === "timed")) { const e = performance.now() - this.gameState.startTime; this.gameState.timeLeft = Math.max(0, this.config.game.duration - e); const i = this.gameState.timeLeft <= this.config.game.warningTime; this.elements.header?.classList.toggle("fivebar-warning", i), this.gameState.timeLeft <= 0 && this.endGame(), this.updateUI() } } updateFK(t) { const i = (this.keys.has("shift") ? this.config.control.fk.fast : this.config.control.fk.normal) * t; this.keys.has("a") && (this.angles.a1 += i), this.keys.has("d") && (this.angles.a1 -= i), this.keys.has("j") && (this.angles.a2 += i), this.keys.has("l") && (this.angles.a2 -= i), this.angles.a1 = c.clamp(this.angles.a1, this.config.motor1.min, this.config.motor1.max), this.angles.a2 = c.clamp(this.angles.a2, this.config.motor2.min, this.config.motor2.max), this.updateSliders() } updateIK(t) { const i = (this.keys.has("shift") ? this.config.control.ik.fast : this.config.control.ik.normal) * t; let s = 0, a = 0; if (this.keys.has("a") && (s -= i), this.keys.has("d") && (s += i), this.keys.has("w") && (a += i), this.keys.has("s") && (a -= i), s !== 0 || a !== 0) { this.ikTarget.x += s, this.ikTarget.y += a, this.ikTarget.y = Math.max(1, this.ikTarget.y); const o = this.kinematics.solveIK(this.ikTarget, this.angles); o && (this.angles.a1 = o.angle1, this.angles.a2 = o.angle2, this.updateSliders()) } } updateSliders() { const t = this.elements.m1Slider, e = this.elements.m2Slider, i = this.elements.m1Value, s = this.elements.m2Value; if (!t || !e || !i || !s) return; const a = (this.angles.a1 - this.config.motor1.min) / (this.config.motor1.max - this.config.motor1.min), o = (this.angles.a2 - this.config.motor2.min) / (this.config.motor2.max - this.config.motor2.min); t.value = Math.round(a * 1e3).toString(), e.value = Math.round(o * 1e3).toString(), i.textContent = Math.round(this.angles.a1) + "°", s.textContent = Math.round(this.angles.a2) + "°", t.disabled = this.controlMode === "IK", e.disabled = this.controlMode === "IK" } updateUI() { const t = this.mode === "countdown" ? "Get Ready!" : this.mode === "timed" ? "Game On!" : "Freeplay"; this.elements.modeBadge && (this.elements.modeBadge.textContent = t); const e = this.elements.timerBadge; if (this.mode === "timed" && e) { e.classList.remove("fivebar-hidden"); const a = this.gameState.timeLeft / 1e3; this.elements.timeLeft && (this.elements.timeLeft.textContent = a.toFixed(1)), a <= 5 ? e.classList.add("fivebar-warning") : e.classList.remove("fivebar-warning") } else e && (e.classList.add("fivebar-hidden"), e.classList.remove("fivebar-warning")); const i = this.elements.scoreBadge; this.mode === "timed" && i ? (i.classList.remove("fivebar-hidden"), this.elements.score && (this.elements.score.textContent = this.gameState.score.toString())) : i && i.classList.add("fivebar-hidden"), this.elements.hiFk && (this.elements.hiFk.textContent = this.highScores.FK.toString()), this.elements.hiIk && (this.elements.hiIk.textContent = this.highScores.IK.toString()), this.elements.fkBtn?.classList.toggle("fivebar-active", this.controlMode === "FK"), this.elements.ikBtn?.classList.toggle("fivebar-active", this.controlMode === "IK"), this.elements.ikBtn?.classList.toggle("fivebar-ik", this.controlMode === "IK"); const s = this.elements.playBtn; s && (this.mode === "timed" || this.mode === "countdown" ? s.textContent = "Restart (R)" : s.textContent = "Play (P)") } render() { this.renderer.clear(); const t = this.kinematics.solveFK(this.angles.a1, this.angles.a2); if (this.elements.unreachable?.classList.toggle("fivebar-hidden", t.valid), this.renderer.drawMechanism(t, this.controlMode), this.mode === "timed" && this.gameState.target) { const e = this.gameState.timeLeft <= this.config.game.warningTime; this.renderer.drawTarget(this.gameState.target, e) } t.valid && (this.renderer.addToTrail(t.endEffector), this.kinematics.updateHistory(t.endEffector), this.checkHit(t.endEffector)) } showHelp() { this.elements.helpOverlay?.classList.add("fivebar-visible") } hideHelp() { this.elements.helpOverlay?.classList.remove("fivebar-visible") } toggleHelp() { this.elements.helpOverlay?.classList.toggle("fivebar-visible") } hideWelcome() { this.elements.welcomeOverlay?.classList.remove("fivebar-visible") } loadHighScores() { try { const t = localStorage.getItem("fiveBarHighScores"); if (t) { const e = JSON.parse(t); this.highScores = { ...this.highScores, ...e } } } catch (t) { console.warn("Could not load high scores:", t) } } saveHighScores() { try { localStorage.setItem("fiveBarHighScores", JSON.stringify(this.highScores)) } catch (t) { console.warn("Could not save high scores:", t) } } loadGamesPlayed() { try { const t = localStorage.getItem("fiveBarGamesPlayed"); if (t) { const e = JSON.parse(t); this.gamesPlayed = { ...this.gamesPlayed, ...e } } } catch (t) { console.warn("Could not load games played:", t) } } saveGamesPlayed() { try { localStorage.setItem("fiveBarGamesPlayed", JSON.stringify(this.gamesPlayed)) } catch (t) { console.warn("Could not save games played:", t) } } checkFirstTimeUser() { try { return localStorage.getItem("fiveBarTutorialCompleted") !== "true" } catch { return !0 } } markTutorialComplete() { try { localStorage.setItem("fiveBarTutorialCompleted", "true") } catch (t) { console.warn("Could not save tutorial completion:", t) } } replayTutorial() { try { localStorage.removeItem("fiveBarTutorialCompleted") } catch (t) { console.warn("Could not clear tutorial completion:", t) } this.mode !== "freeplay" && this.endGame(!0), this.startTutorial() } startTutorial() { this.tutorialActive = !0, this.tutorialStep = "motor1", this.tutorialProgress.initialM1 = this.angles.a1, this.tutorialProgress.initialM2 = this.angles.a2, this.tutorialProgress.motor1Moved = !1, this.tutorialProgress.motor2Moved = !1, this.showTutorialOverlay() } showTutorialOverlay() { + const e = { motor1: { title: "Control Motor 1", text: 'Move the left slider or press A / D keys to control Motor 1.', hint: "Try moving it now!" }, motor2: { title: "Great! Now Motor 2", text: 'Move the right slider or press J / L keys to control Motor 2.', hint: "See how both motors work together!" }, explore: { title: "Excellent! Explore a bit", text: "This is Forward Kinematics (FK) mode. You control the motor angles directly and see where the end effector (yellow dot) moves.", hint: "Play around for a few seconds..." }, choice: { title: "Ready for more?", text: 'You can either:
• Press P to play a 30-second challenge game
• Press Tab to try Inverse Kinematics (IK) mode', hint: "IK mode lets you control position directly instead of angles!" }, "ik-move": { title: "Welcome to IK Mode!", text: 'Now you control the end effector position directly:
A / D = Left / Right
W / S = Up / Down', hint: "Try moving around - notice how much easier it is!" }, complete: { title: "Tutorial Complete!", text: `You've learned the basics! Press H anytime for help, or P to start a game.`, hint: "Have fun exploring kinematics!" } }[this.tutorialStep], i = this.elements.tutorialBanner; if (!i) return; i.classList.remove("fivebar-hidden"); const s = this.getTutorialStepNumber(), a = this.tutorialStep === "choice" || this.tutorialStep === "complete"; i.innerHTML = ` +
+

${e.title}

+

${e.text}

+

${e.hint}

+ ${a ? '' : `

Tutorial step ${s} of 6

`} +
+ `, i.querySelector("[data-skip-tutorial]")?.addEventListener("click", () => this.completeTutorial()) + } hideTutorialOverlay() { const t = this.elements.tutorialBanner; t && t.classList.add("fivebar-hidden") } getTutorialStepNumber() { return ["motor1", "motor2", "explore", "choice", "ik-move", "complete"].indexOf(this.tutorialStep) + 1 } updateTutorial(t) { if (this.tutorialActive) switch (this.tutorialStep) { case "motor1": Math.abs(this.angles.a1 - this.tutorialProgress.initialM1) > 10 && (this.tutorialProgress.motor1Moved = !0, this.tutorialStep = "motor2", this.tutorialProgress.initialM2 = this.angles.a2, this.showTutorialOverlay()); break; case "motor2": Math.abs(this.angles.a2 - this.tutorialProgress.initialM2) > 10 && (this.tutorialProgress.motor2Moved = !0, this.tutorialStep = "explore", this.hideTutorialOverlay(), setTimeout(() => { this.tutorialStep === "explore" && (this.tutorialStep = "choice", this.showTutorialOverlay()) }, 5e3)); break } } advanceTutorialFromChoice(t) { !this.tutorialActive || this.tutorialStep !== "choice" || (this.hideTutorialOverlay(), t === "ik" ? (this.tutorialStep = "ik-move", setTimeout(() => { this.tutorialActive && this.tutorialStep === "ik-move" && (this.showTutorialOverlay(), setTimeout(() => { this.tutorialActive && this.tutorialStep === "ik-move" && this.completeTutorial() }, 8e3)) }, 500)) : this.completeTutorial()) } completeTutorial() { this.tutorialActive = !1, this.hideTutorialOverlay(), this.markTutorialComplete() } start() { this.renderer.setupResizeObserver(() => this.render()), this.renderer.resize(), this.updateSliders(); const t = e => { const i = Math.min(.05, (e - this.lastUpdate) / 1e3); this.lastUpdate = e, this.update(i), this.render(), this.animationFrameId = requestAnimationFrame(t) }; this.animationFrameId = requestAnimationFrame(t), this.checkFirstTimeUser() ? setTimeout(() => { this.elements.loading?.classList.add("fivebar-hidden"), this.startTutorial() }, 300) : setTimeout(() => { this.elements.loading?.classList.add("fivebar-hidden") }, 300) } cleanup() { this.animationFrameId !== null && (cancelAnimationFrame(this.animationFrameId), this.animationFrameId = null), this.renderer.cleanup(), window.removeEventListener("keydown", this.handleKeyDown), window.removeEventListener("keyup", this.handleKeyUp) } +} function L(r) { const t = r.querySelector("[data-canvas]"); if (!t) throw new Error("FiveBarIKGame: Canvas element not found"); const e = new S(x), i = new T(t, x), s = new M(r, x, e, i); return console.log("FiveBarIKGame: Starting game..."), s.start(), console.log("FiveBarIKGame: Game started, returning cleanup function"), () => s.cleanup() } export { L as createFiveBarIKGame }; diff --git a/index.html b/index.html new file mode 100644 index 0000000..bbb7ffa --- /dev/null +++ b/index.html @@ -0,0 +1,204 @@ + + + + + + WijiBoard – Control Dashboard + + + + + + + + +
+ + + + + + + + +
+ + + + + +
+ +
+
+ +
+ + + + + diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..1f03dc7 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,21 @@ +; PlatformIO Project Configuration +; Board: Waveshare ESP32-S3-Zero (or compatible ESP32-S3) +; Project: WijiBoard – BLE SCARA Stepper Controller + +[env:esp32-s3-devkitc-1] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino + +; USB CDC on boot – Serial Monitor works over native USB +build_flags = + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + +; Adjust to your COM port +monitor_speed = 115200 +upload_protocol = esptool + +lib_deps = + ; AccelStepper for smooth, non-blocking stepper control + waspinator/AccelStepper @ ^1.64 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..5a691da --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,235 @@ +/** + * WijiBoard – BLE Stepper Controller Firmware + * src/main.cpp + * + * Board : Waveshare ESP32-S3-Zero (or compatible ESP32-S3) + * Steppers: Two 28BYJ-48 via ULN2003 (AccelStepper, FULL4WIRE mode) + * + * BLE Command Characteristic (WRITE_NR): + * S1+ → Step motor 1 CW n steps + * S1- → Step motor 1 CCW n steps + * S2+ → Step motor 2 CW n steps + * S2- → Step motor 2 CCW n steps + * SPD: → Set max speed for both motors (steps/sec) + * ACC: → Set acceleration for both motors (steps/sec²) + * HOME1 → Zero motor 1 position + * HOME2 → Zero motor 2 position + * POS → Request current positions (triggers NOTIFY) + * + * BLE Status Characteristic (NOTIFY): + * P:, → Current step positions for motor 1 & 2 + * + * UUIDs – must match web/js/ble.js exactly: + * Service : a0b1c2d3-e4f5-6789-abcd-ef0123456700 + * Command : a0b1c2d3-e4f5-6789-abcd-ef0123456701 + * Status : a0b1c2d3-e4f5-6789-abcd-ef0123456702 + */ + +#include +#include +#include +#include +#include +#include + +// ─── Motor pin mapping (28BYJ-48 / ULN2003) ────────────────────── +// Motor 1 – Shoulder +#define M1_IN1 4 +#define M1_IN2 5 +#define M1_IN3 6 +#define M1_IN4 7 + +// Motor 2 – Elbow +#define M2_IN1 8 +#define M2_IN2 9 +#define M2_IN3 10 +#define M2_IN4 11 + +// ─── Stepper constants ──────────────────────────────────────────── +#define STEPS_PER_REV 2048 +#define DEFAULT_SPEED 600.0f // steps/sec +#define DEFAULT_ACCEL 100.0f // steps/sec² + +// ─── BLE UUIDs ──────────────────────────────────────────────────── +#define SERVICE_UUID "a0b1c2d3-e4f5-6789-abcd-ef0123456700" +#define CMD_UUID "a0b1c2d3-e4f5-6789-abcd-ef0123456701" +#define STATUS_UUID "a0b1c2d3-e4f5-6789-abcd-ef0123456702" +#define DEVICE_NAME "WijiBoard" + +// ─── Stepper objects ────────────────────────────────────────────── +AccelStepper stepper1(AccelStepper::FULL4WIRE, M1_IN1, M1_IN2, M1_IN3, M1_IN4); +AccelStepper stepper2(AccelStepper::FULL4WIRE, M2_IN1, M2_IN2, M2_IN3, M2_IN4); + +// ─── BLE globals ───────────────────────────────────────────────── +BLEServer* pServer = nullptr; +BLECharacteristic* pCmdChar = nullptr; +BLECharacteristic* pStatusChar = nullptr; +bool bleConnected = false; + +// ─── Status notify throttle ─────────────────────────────────────── +unsigned long lastNotify = 0; +const unsigned long NOTIFY_INTERVAL_MS = 200; + +// ─── Forward declarations ───────────────────────────────────────── +void parseCommand(const String& cmd); +void sendPosition(); + +// ─── BLE Server Callbacks ───────────────────────────────────────── +class ServerCallbacks : public BLEServerCallbacks { + void onConnect(BLEServer*) override { + bleConnected = true; + Serial.println("[BLE] Client connected"); + } + void onDisconnect(BLEServer*) override { + bleConnected = false; + Serial.println("[BLE] Client disconnected – restarting advertising"); + BLEDevice::startAdvertising(); + } +}; + +// ─── Command Characteristic Callbacks ──────────────────────────── +class CmdCallbacks : public BLECharacteristicCallbacks { + void onWrite(BLECharacteristic* pChar) override { + String val = pChar->getValue().c_str(); + val.trim(); + if (val.length() == 0) return; + Serial.printf("[CMD] Received: '%s'\n", val.c_str()); + parseCommand(val); + } +}; + +// ─── Command parser ─────────────────────────────────────────────── +void parseCommand(const String& cmd) { + // S1+ or S1- + if (cmd.startsWith("S1") && cmd.length() > 2) { + long steps = cmd.substring(2).toInt(); // '+' or '-' prefix handled by toInt() + stepper1.move(steps); + Serial.printf("[S1] Move %+ld steps\n", steps); + return; + } + // S2+ or S2- + if (cmd.startsWith("S2") && cmd.length() > 2) { + long steps = cmd.substring(2).toInt(); + stepper2.move(steps); + Serial.printf("[S2] Move %+ld steps\n", steps); + return; + } + // SPD: + if (cmd.startsWith("SPD:")) { + float spd = cmd.substring(4).toFloat(); + stepper1.setMaxSpeed(spd); + stepper2.setMaxSpeed(spd); + Serial.printf("[CFG] Max speed → %.0f steps/sec\n", spd); + return; + } + // ACC: + if (cmd.startsWith("ACC:")) { + float acc = cmd.substring(4).toFloat(); + stepper1.setAcceleration(acc); + stepper2.setAcceleration(acc); + Serial.printf("[CFG] Acceleration → %.0f steps/sec²\n", acc); + return; + } + // HOME1 + if (cmd == "HOME1") { + stepper1.setCurrentPosition(0); + Serial.println("[S1] Zeroed"); + sendPosition(); + return; + } + // HOME2 + if (cmd == "HOME2") { + stepper2.setCurrentPosition(0); + Serial.println("[S2] Zeroed"); + sendPosition(); + return; + } + // POS – explicit position request + if (cmd == "POS") { + sendPosition(); + return; + } + Serial.printf("[CMD] Unknown command: '%s'\n", cmd.c_str()); +} + +// ─── Send current positions via BLE NOTIFY ──────────────────────── +void sendPosition() { + if (!bleConnected || !pStatusChar) return; + String pos = "P:" + String(stepper1.currentPosition()) + + "," + String(stepper2.currentPosition()); + pStatusChar->setValue(pos.c_str()); + pStatusChar->notify(); + Serial.printf("[POS] %s\n", pos.c_str()); +} + +// ─── Setup ──────────────────────────────────────────────────────── +void setup() { + // Fix: zero TX buffer to eliminate CDC post-write stall + Serial.setTxBufferSize(0); + Serial.begin(115200); + delay(500); + Serial.println("\n[BOOT] WijiBoard Stepper Controller"); + + // ── Init steppers ────────────────────────────────────────── + stepper1.setMaxSpeed(DEFAULT_SPEED); + stepper1.setAcceleration(DEFAULT_ACCEL); + stepper1.setCurrentPosition(0); + + stepper2.setMaxSpeed(DEFAULT_SPEED); + stepper2.setAcceleration(DEFAULT_ACCEL); + stepper2.setCurrentPosition(0); + + Serial.println("[STEP] Steppers initialized"); + + // ── Init BLE ─────────────────────────────────────────────── + BLEDevice::init(DEVICE_NAME); + + pServer = BLEDevice::createServer(); + pServer->setCallbacks(new ServerCallbacks()); + + // Service + BLEService* pService = pServer->createService(SERVICE_UUID); + + // Command characteristic (Write without response) + pCmdChar = pService->createCharacteristic( + CMD_UUID, + BLECharacteristic::PROPERTY_WRITE | + BLECharacteristic::PROPERTY_WRITE_NR + ); + pCmdChar->setCallbacks(new CmdCallbacks()); + + // Status characteristic (Notify) + pStatusChar = pService->createCharacteristic( + STATUS_UUID, + BLECharacteristic::PROPERTY_NOTIFY + ); + pStatusChar->addDescriptor(new BLE2902()); + + pService->start(); + + // Advertising + BLEAdvertising* pAdv = BLEDevice::getAdvertising(); + pAdv->addServiceUUID(SERVICE_UUID); + pAdv->setScanResponse(true); + pAdv->setMinPreferred(0x06); + BLEDevice::startAdvertising(); + + Serial.printf("[BLE] Advertising as '%s' – ready!\n", DEVICE_NAME); +} + +// ─── Loop ───────────────────────────────────────────────────────── +void loop() { + // Run steppers (non-blocking AccelStepper) + stepper1.run(); + stepper2.run(); + + // Periodic position notify while motors are moving + if (bleConnected) { + unsigned long now = millis(); + bool moving = stepper1.isRunning() || stepper2.isRunning(); + if (moving && (now - lastNotify >= NOTIFY_INTERVAL_MS)) { + lastNotify = now; + sendPosition(); + } + } +} diff --git a/web/js/ble.js b/web/js/ble.js new file mode 100644 index 0000000..74b3415 --- /dev/null +++ b/web/js/ble.js @@ -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+ → step motor 1 clockwise n steps + * S1- → step motor 1 counter-clockwise n steps + * S2+ → step motor 2 clockwise n steps + * S2- → step motor 2 counter-clockwise n steps + * SPD: → set max speed (steps/sec) + * ACC: → set acceleration (steps/sec²) + * HOME → zero both steppers + * POS → request position report + * + * Status characteristic (NOTIFY): + * P:, → 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; diff --git a/web/js/kinematics.js b/web/js/kinematics.js new file mode 100644 index 0000000..f05da40 --- /dev/null +++ b/web/js/kinematics.js @@ -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, +}; diff --git a/web/js/router.js b/web/js/router.js new file mode 100644 index 0000000..2a81975 --- /dev/null +++ b/web/js/router.js @@ -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 = `

+ Failed to load section: ${e.message}

`; + } + + // 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; diff --git a/web/js/sections/home.js b/web/js/sections/home.js new file mode 100644 index 0000000..22e3d5a --- /dev/null +++ b/web/js/sections/home.js @@ -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 = ` +
+
+

WijiBoard

+

WiFi Spirit Board — SCARA arm controller. Select a section from the sidebar to get started.

+
+ +
+ +
+
+ Connection +
+
+ BLE + + ${BLE.isConnected() ? `✓ ${BLE.getDeviceName()}` : 'Not connected'} + +
+

+ Use the Connect button + in the top bar to pair with the WijiBoard via Bluetooth. + The device name is WijiBoard. +

+
+ + +
+
+ About +
+

+ This dashboard controls a two-arm SCARA robot that moves a planchette + across a spirit board. It uses Web Bluetooth + to communicate with an ESP32-S3 running two 28BYJ-48 stepper motors + with AccelStepper. +

+
+
+ + +
+
+ Sections +
+
+ + + +
+
+
+ `; + + // 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; diff --git a/web/js/sections/stepper-test.js b/web/js/sections/stepper-test.js new file mode 100644 index 0000000..019e24c --- /dev/null +++ b/web/js/sections/stepper-test.js @@ -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 ` + + + + + + + NO-GO ZONE + + + + + + +X + +Y + + + + + + 50 mm + + + + + M1 + M2 + `; +} + +// ───────────────────────────────────────────────────────────────── +// HTML template +// ───────────────────────────────────────────────────────────────── + +function buildHTML() { + return ` + + +
+
+

Stepper Test

+

Click anywhere on the SCARA map to move via IK. Red zone = mechanism exclusion. Dashed box = board limits.

+
+ +
+ +
+
+ SCARA Map — click to move + SIMULATION +
+ + + + + + + + + + + + + ${buildZones()} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (0.0, 0.0) mm + + + +
+ ━ M1 (left) + ━ M2 (right) + ▪ NO-GO zone + ╌ board limits + l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm d=${IK.ARM.d}mm +
+
+ + +
+ + +
+
Current Position
+
+
+
0.0
+
X (mm)
+
+
+
0.0
+
Y (mm)
+
+
+
0.0°
+
θ1 (M1)
+
+
+
0.0°
+
θ2 (M2)
+
+
+
0
+
M1 steps
+
+
+
0
+
M2 steps
+
+
+
+ + +
+
Move to XY
+
+
+ + + mm +
+
+ + + mm +
+ +
+
+ + +
+
Motion
+
+
+ +
+ + 600 +
+
+
+ +
+ + 100 +
+
+
+
+
+
+ + +
+
+ Manual Jog + Incremental step control — bypasses IK +
+
+ +
+
Motor 1 — Left
+
+ + + +
+
+ ${[10,50,100,512].map(n=>``).join('')} + ${[10,50,100,512].map(n=>``).join('')} +
+ +
+ +
+
Motor 2 — Right
+
+ + + +
+
+ ${[10,50,100,512].map(n=>``).join('')} + ${[10,50,100,512].map(n=>``).join('')} +
+ +
+
+
+ + +
+
+ Event Log + +
+
+
+
+ `; +} + +// ───────────────────────────────────────────────────────────────── +// 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; diff --git a/web/js/ui.js b/web/js/ui.js new file mode 100644 index 0000000..d5bc7f3 --- /dev/null +++ b/web/js/ui.js @@ -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 + ? 'Disconnect' + : 'Connect'; + 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; diff --git a/web/styles/base.css b/web/styles/base.css new file mode 100644 index 0000000..46c0ed9 --- /dev/null +++ b/web/styles/base.css @@ -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; } diff --git a/web/styles/components.css b/web/styles/components.css new file mode 100644 index 0000000..bb877ed --- /dev/null +++ b/web/styles/components.css @@ -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; +}