arm-playground / index.html
bacool's picture
Publish Arm Playground (static)
ce32766 verified
Raw History Blame Contribute Delete
129 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arm Playground</title>
<meta name="description" content="Interactive two-joint robot arm for Unit 2 of the Hugging Face Robotics Course: forward and inverse kinematics, configuration space with obstacles, the Jacobian, and feedback control, with guided lessons. A fifth tab unlocks the real five-joint arm in 3D.">
<meta property="og:title" content="Arm Playground: learn robot kinematics and control by touching it">
<meta property="og:description" content="FK, IK, configuration space, the Jacobian and feedback control on a two-joint arm, with guided lessons. A companion to Unit 2 of the Hugging Face Robotics Course.">
<meta property="og:image" content="https://bakulbadwal.github.io/arm-playground/docs/hero.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%2315171b'/%3E%3Cpath d='M14 50 L30 34 L50 16' stroke='%236d9df0' stroke-width='7' stroke-linecap='round' fill='none'/%3E%3Cpath d='M30 34 L50 16' stroke='%23f0994f' stroke-width='7' stroke-linecap='round'/%3E%3Ccircle cx='14' cy='50' r='5' fill='%23fff'/%3E%3Ccircle cx='30' cy='34' r='5' fill='%23fff'/%3E%3C/svg%3E">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<style>
:root {
--bg: #f5f4ef; --panel: #ffffff; --panel-2: #fafaf7; --ink: #1b1d22; --muted: #5f636c; --line: #dddbd2;
--grid: #efeee8; --link1: #2f6fdb; --link2: #e0751f; --joint: #1b1d22;
--target: #16925b; --ghost: #9aa0aa; --bad: #d23c3c; --ok: #16925b; --warn: #b87d00;
--floor: #8a7a66; --floor-c: #eadcc6; --shelf: #6d5bd0; --shelf-c: #dcd5f8; --limit-c: #e2e2e2;
--reach: rgba(47,111,219,0.10); --free-c: #ffffff; --accent: #2f6fdb; --chip: #f0f3fa;
--basinA: rgba(22,146,91,0.30); --basinB: rgba(109,91,208,0.30); --basinX: rgba(120,120,120,0.18);
--shadow: 0 1px 2px rgba(20,20,30,0.05), 0 6px 24px rgba(20,20,30,0.05);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121418; --panel: #1b1e24; --panel-2: #171a1f; --ink: #e9eaed; --muted: #a2a7b0; --line: #30343d;
--grid: #23272e; --link1: #6d9df0; --link2: #f0994f; --joint: #e9eaed;
--target: #3ccb8d; --ghost: #6c727c; --bad: #f06a6a; --ok: #3ccb8d; --warn: #e3b341;
--floor: #b7a58d; --floor-c: #463c30; --shelf: #a597f5; --shelf-c: #37315c; --limit-c: #2c2f36;
--reach: rgba(109,157,240,0.13); --free-c: #1b1e24; --accent: #7aa7f5; --chip: #20252f;
--basinA: rgba(60,203,141,0.28); --basinB: rgba(165,151,245,0.30); --basinX: rgba(160,160,160,0.14);
--shadow: none;
}
}
:root[data-theme="dark"] {
--bg: #121418; --panel: #1b1e24; --panel-2: #171a1f; --ink: #e9eaed; --muted: #a2a7b0; --line: #30343d;
--grid: #23272e; --link1: #6d9df0; --link2: #f0994f; --joint: #e9eaed;
--target: #3ccb8d; --ghost: #6c727c; --bad: #f06a6a; --ok: #3ccb8d; --warn: #e3b341;
--floor: #b7a58d; --floor-c: #463c30; --shelf: #a597f5; --shelf-c: #37315c; --limit-c: #2c2f36;
--reach: rgba(109,157,240,0.13); --free-c: #1b1e24; --accent: #7aa7f5; --chip: #20252f;
--basinA: rgba(60,203,141,0.28); --basinB: rgba(165,151,245,0.30); --basinX: rgba(160,160,160,0.14);
--shadow: none;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--ink);
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
a { color: var(--accent); }
.wrap { max-width: 1240px; margin: 0 auto; padding: 22px 16px 40px; }
.top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.brand h1 { font-size: 26px; margin: 0; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
.brand h1 svg { width: 34px; height: 34px; flex: none; }
.brand .kicker { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); margin-bottom: 4px; }
.brand p { margin: 4px 0 0; color: var(--muted); max-width: 780px; }
.actions { display: flex; gap: 8px; }
button, select { font: inherit; font-size: 14px; color: var(--ink); background: var(--panel); border: 1px solid var(--line);
border-radius: 9px; padding: 6px 12px; cursor: pointer; transition: border-color .15s, background .15s; }
button:hover, select:hover { border-color: var(--accent); }
button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.ghost { background: transparent; }
nav.tabs { display: flex; gap: 6px; margin: 18px 0 10px; flex-wrap: wrap; }
nav.tabs button { border-radius: 999px; padding: 7px 14px 9px; position: relative; overflow: hidden; }
nav.tabs .prog { position: absolute; left: 12px; right: 12px; bottom: 4px; height: 2px; border-radius: 2px; background: color-mix(in srgb, var(--muted) 25%, transparent); }
nav.tabs .prog i { display: block; height: 100%; width: 100%; border-radius: 2px; background: var(--ok); transform: scaleX(0); transform-origin: left; transition: transform .3s; }
nav.tabs button[aria-selected="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.eq { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.eqc { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 8px 14px; box-shadow: var(--shadow); min-width: 0; }
.eqc .tex { font-size: 16px; overflow-x: auto; overflow-y: hidden; }
.eqc .gloss { font-size: 12px; color: var(--muted); margin-top: 2px; }
.syms { margin: -6px 0 14px; font-size: 14px; }
.syms summary { cursor: pointer; color: var(--accent); font-weight: 500; }
.syms ul { list-style: none; padding: 0; margin: 8px 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 6px 16px; }
.syms li { display: flex; gap: 10px; align-items: baseline; color: var(--muted); }
.syms button.sym { font: 600 13px ui-monospace, "SF Mono", Menlo, monospace; padding: 2px 8px; border-radius: 7px; min-width: 60px; text-align: center; color: var(--ink); flex: none; }
.syms button.sym:hover { background: var(--chip); }
.hl { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 65%, transparent); border-radius: 8px; transition: box-shadow .2s; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start;
grid-template-areas: "task cs" "lesson controls" "math controls"; }
.area-task { grid-area: task; } .area-cs { grid-area: cs; } .area-lesson { grid-area: lesson; } .area-math { grid-area: math; } .area-controls { grid-area: controls; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; min-width: 0; box-shadow: var(--shadow); position: relative; }
.card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin: 0 0 8px; font-weight: 650; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
canvas { display: block; width: 100%; aspect-ratio: 1 / 1; touch-action: none; border-radius: 10px; }
.legend { display: flex; gap: 6px 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-top: 8px; }
.sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; border: 1px solid var(--line); }
.tip { position: absolute; pointer-events: none; background: var(--ink); color: var(--bg); font: 12px ui-monospace, Menlo, monospace;
padding: 4px 8px; border-radius: 6px; white-space: nowrap; z-index: 2; opacity: 0.92; }
.ctl { display: grid; grid-template-columns: 150px 1fr 64px; gap: 6px 10px; align-items: center; margin-bottom: 4px; }
.ctl label { font-size: 13px; color: var(--muted); }
.ctl output { font-variant-numeric: tabular-nums; font-size: 13px; text-align: right; }
input[type=range] { width: 100%; accent-color: var(--accent); }
.checks { display: flex; gap: 8px 16px; flex-wrap: wrap; margin: 6px 0 10px; font-size: 14px; }
.checks label { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
.sub { font-size: 12.5px; color: var(--muted); margin: 4px 0; }
.sep { border: 0; border-top: 1px solid var(--line); margin: 12px 0; }
.math { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.7;
background: var(--chip); border-radius: 10px; padding: 10px 12px; overflow-x: auto; white-space: pre; }
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 650; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
.pill.ok { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.pill.warn { background: color-mix(in srgb, var(--warn) 20%, transparent); color: var(--warn); }
.pill.bad { background: color-mix(in srgb, var(--bad) 18%, transparent); color: var(--bad); }
.hidden { display: none !important; }
#plot { aspect-ratio: 4 / 1; margin-top: 8px; }
#bars { aspect-ratio: 5 / 1; margin-top: 6px; }
.pad { display: grid; grid-template-columns: repeat(3, 38px); grid-template-rows: repeat(2, 34px); gap: 4px; }
.pad button { padding: 0; }
.pad .up { grid-column: 2; } .pad .left { grid-column: 1; grid-row: 2; } .pad .down { grid-column: 2; grid-row: 2; } .pad .right { grid-column: 3; grid-row: 2; }
/* tab 5: the real arm in 3D (arm3d.js) */
#a3 { aspect-ratio: 7 / 8; touch-action: pan-y; cursor: grab; }
#a3:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
#a3flat { aspect-ratio: 4 / 5; touch-action: pan-y; }
#a3Status { text-align: right; flex: none; max-width: 60%; }
#a3Status .pill { white-space: nowrap; }
.a3views { gap: 6px; margin: 8px 0 0; }
.a3views button { padding: 4px 10px; font-size: 13px; }
.a3views input[type=range] { flex: 1; min-width: 80px; max-width: 170px; }
#a3Joints .ctl label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
#a3Joints .sw { margin-right: 2px; flex: none; }
details.more ul { padding-left: 18px; margin: 8px 0 0; }
details.more ul li { margin-bottom: 6px; }
/* lesson */
.lesson { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); }
.lesson h3 { font-size: 18px; margin: 2px 0 6px; letter-spacing: -0.01em; }
.dots { display: flex; gap: 5px; }
.dots button { width: 22px; height: 6px; padding: 0; border-radius: 3px; background: var(--line); border: 0; }
.dots button.done { background: color-mix(in srgb, var(--ok) 70%, transparent); }
.dots button.cur { background: var(--accent); }
.lbody { font-size: 15px; }
.lbody p { margin: 0 0 8px; }
.ask { background: var(--panel-2); border: 1px dashed var(--line); border-radius: 10px; padding: 10px 12px; margin: 10px 0; }
.ask .q { font-weight: 600; margin-bottom: 8px; }
.ask input[type=number] { font: inherit; width: 110px; padding: 5px 8px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); }
.ask .opts { display: grid; gap: 6px; }
.ask .opts button { text-align: left; }
.ask .opts button.right { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, var(--panel)); }
.ask .opts button.wrong { border-color: var(--bad); background: color-mix(in srgb, var(--bad) 10%, var(--panel)); }
.fb { margin-top: 8px; font-size: 14px; }
.fb.good { color: var(--ok); } .fb.no { color: var(--bad); }
.fb .why { color: var(--ink); display: block; margin-top: 4px; }
details.more { margin-top: 10px; font-size: 14px; }
details.more summary { cursor: pointer; color: var(--muted); }
details.more ol { padding-left: 20px; margin: 8px 0 0; }
details.more li { margin-bottom: 6px; }
kbd { font: 12px ui-monospace, Menlo, monospace; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 0 5px; background: var(--panel-2); }
footer { margin-top: 26px; color: var(--muted); font-size: 13px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 14px; }
/* export mode (?export=task|cs|both): just the canvases, for still images */
body.export { background: #ffffff; }
body.export .wrap { max-width: none; padding: 0; }
body.export .wrap > *:not(.grid), body.export .card h2, body.export .legend, body.export .tip, body.export footer { display: none !important; }
body.export .grid { gap: 0; grid-template-columns: 1fr; grid-template-areas: "task"; }
body.export.export-cs .grid { grid-template-areas: "cs"; }
body.export.export-both .grid { grid-template-columns: 1fr 1fr; grid-template-areas: "task cs"; }
body.export .grid > section { display: none; border: 0; box-shadow: none; padding: 0; background: transparent; border-radius: 0; }
body.export.export-task .area-task, body.export.export-cs .area-cs, body.export.export-both .area-task, body.export.export-both .area-cs { display: block; }
body.export canvas { border-radius: 0; }
.toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg); padding: 8px 14px; border-radius: 9px; font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; }
.toast.show { opacity: 0.95; }
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; grid-template-areas: "task" "lesson" "cs" "controls" "math"; }
.ctl { grid-template-columns: 118px 1fr 56px; }
.eqc .tex { font-size: 14px; }
#a3flat { aspect-ratio: 2 / 3; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div class="brand">
<div class="kicker">Companion to the <a href="https://huggingface.co/learn/robotics-course" target="_blank" rel="noopener">Hugging Face Robotics Course</a> · Unit 2 &nbsp;·&nbsp; <a href="datasets.html" title="The Unit 1 page: what LeRobotDataset fetches for a temporal window">Unit 1 · Datasets →</a> &nbsp;·&nbsp; <a href="workbench.html" title="Drive the SO-101 in 3D: record, replay, teach">Workbench →</a></div>
<h1><svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="14" fill="var(--ink)"/><path d="M14 50 L30 34" stroke="var(--link1)" stroke-width="7" stroke-linecap="round"/><path d="M30 34 L50 16" stroke="var(--link2)" stroke-width="7" stroke-linecap="round"/><circle cx="14" cy="50" r="5" fill="var(--bg)"/><circle cx="30" cy="34" r="5" fill="var(--bg)"/></svg>Arm Playground</h1>
<p>Robot kinematics and control on the course's two-joint arm. <b>Task space</b> shows where the hand is. <b>Configuration space</b> maps every pair of joint angles (θ₁, θ₂) as one pixel. Follow the lesson on each tab, or just play. Tab 5 gives the real arm its other joints back, in 3D.</p>
</div>
<div class="actions">
<button id="copy" title="Copy a link to exactly this setup">🔗 Copy link</button>
<button id="theme" title="Toggle light/dark" aria-label="Toggle light or dark theme">◐</button>
</div>
</div>
<nav class="tabs" role="tablist" aria-label="Topics">
<button role="tab" data-tab="fk">1 · Forward kinematics<span class="prog"><i></i></span></button>
<button role="tab" data-tab="ik">2 · Inverse kinematics<span class="prog"><i></i></span></button>
<button role="tab" data-tab="jac">3 · Jacobian &amp; diff-IK<span class="prog"><i></i></span></button>
<button role="tab" data-tab="fb">4 · Feedback control<span class="prog"><i></i></span></button>
<button role="tab" data-tab="r3">5 · The real arm (3D)<span class="prog"><i></i></span></button>
</nav>
<div class="eq" id="eq"></div>
<details class="syms" id="syms"><summary>What do the symbols mean? Tap one to see it on screen.</summary><ul id="symList"></ul></details>
<div class="grid">
<section class="card area-task" data-panel="fk ik jac fb">
<h2><span>Task space · where the hand is</span><span id="taskStatus"></span></h2>
<canvas id="task" aria-label="Task space view of the arm"></canvas>
<div class="legend" id="taskLegend"></div>
</section>
<section class="card area-cs" id="csCard" data-panel="fk ik jac fb">
<h2><span>Configuration space · (θ₁, θ₂)</span>
<label class="sub" id="basinWrap" style="margin:0;text-transform:none;letter-spacing:0;font-weight:500"><input type="checkbox" id="basins"> Basins of iterative IK</label></h2>
<canvas id="cspace" aria-label="Configuration space map: click to set the pose, hover to preview"></canvas>
<div class="tip hidden" id="tip"></div>
<div class="legend">
<span><span class="sw" style="background:var(--free-c)"></span>free</span>
<span><span class="sw" style="background:var(--floor-c)"></span>hits floor</span>
<span><span class="sw" style="background:var(--shelf-c)"></span>hits shelf</span>
<span><span class="sw" style="background:var(--limit-c)"></span>past elbow limit</span>
<span id="basinLegend" class="hidden"><span class="sw" style="background:var(--basinA)"></span>→ A <span class="sw" style="background:var(--basinB);margin-left:6px"></span>→ B</span>
<span id="csNote"></span>
<span>hover = preview · click = go there</span>
</div>
</section>
<section class="card area-task" id="a3Card" data-panel="r3">
<h2><span>The real arm · SO‑101, drawn from its URDF</span><span id="a3Status"></span></h2>
<canvas id="a3" tabindex="0" aria-label="3D view of the five-joint arm. Drag to orbit, or use the arrow keys; plus and minus zoom."></canvas>
<div class="row a3views">
<button data-a3view="q" title="Three-quarter view">¾ view</button><button data-a3view="side" title="Look straight at the arm's plane">Side</button><button data-a3view="top" title="Look straight down">Top</button><button data-a3view="front" title="Look from the front">Front</button>
<button id="a3zout" aria-label="Zoom out" title="Zoom out">−</button><button id="a3zin" aria-label="Zoom in" title="Zoom in">+</button>
<label class="sub" for="a3tilt" style="margin:0">Tilt</label><input type="range" id="a3tilt" min="2" max="87" step="1" value="22">
</div>
<div class="legend" id="a3Legend"></div>
</section>
<section class="card area-cs" id="a3ViewsCard" data-panel="r3">
<h2><span>Two flat views · side and top</span></h2>
<canvas id="a3flat" aria-label="Side view (the plane of tabs 1 to 4) above a top view. Drag the green target here when there is one."></canvas>
<div class="legend">
<span><span class="sw" style="background:color-mix(in srgb, var(--accent) 17%, var(--panel))"></span>where the wrist can go with the lift and elbow inside their limits</span>
<span>dashed: tab 1's ring (l₁ + l₂ outside, the elbow's stop inside)</span>
<span><span class="sw" style="background:color-mix(in srgb, var(--warn) 14%, var(--panel))"></span>the pan's range</span>
</div>
<details class="more"><summary>What's exact here, and what's a model</summary>
<ul>
<li><b>Exact to the robot's description file:</b> joint order, axes, offsets and limits are the SO‑101 URDF's (<a href="https://github.com/TheRobotStudio/SO-ARM100/blob/385e8d7/Simulation/SO101/so101_new_calib.urdf" target="_blank" rel="noopener">TheRobotStudio/SO‑ARM100, <code>so101_new_calib.urdf</code> at commit 385e8d7</a>, Apache‑2.0). A copy sits in this repo, and <code>npm test</code> checks this tab's joint table against it number for number.</li>
<li><b>Exact math:</b> forward kinematics, the 116 mm and 135 mm links, the Jacobian (checked against finite differences), and the count 5 &lt; 6.</li>
<li><b>A model:</b> the arm is drawn as plain rods through the joint centres, not the printed parts, and the base and shadows are just for depth. The grid is the plane z = 0 of the URDF's base frame; how that sits against a real tabletop depends on the printed base, which isn't drawn. The dots are random joint settings, not the workspace's true edge, and they ignore the arm hitting itself.</li>
<li><b>The solver is numerical.</b> When it converges it is within 0.1 mm of the target. It only searches downhill from where it starts, so it can also stop against a joint limit; that is why Solve tries several starting poses before it says a target is out of reach, and why Follow can stall on a target behind the base.</li>
<li><b>Also a model:</b> a URDF is an idealised arm. A built one has play in its gears and bends a little, and its calibrated limits differ slightly. The gripper's jaw is left out.</li>
<li><b>Units:</b> tabs 1–4 use two links of length 1. This tab is in millimetres, with the real 116 : 135. With the wrist roll at 0 the fingertip sits 18.3 mm to one side of the blue sheet (10 to 26 mm as the roll turns), because the gripper is offset back toward the arm's centreline. The side view looks straight along the joint axes, so that offset doesn't show there.</li>
</ul>
</details>
</section>
<section class="card lesson area-lesson" id="lesson" aria-live="polite">
<h2><span>Lesson · <span id="lsTab"></span></span><span id="lsCount"></span></h2>
<div class="dots" id="lsDots"></div>
<h3 id="lsTitle"></h3>
<div class="lbody" id="lsBody"></div>
<div id="lsAsk"></div>
<div class="row">
<button id="lsPrev">← Back</button>
<button id="lsSetup" class="ghost" title="Put the arm back to this step's starting setup">↺ Reset this step</button>
<button id="lsNext" class="primary">Next →</button>
</div>
<details class="more"><summary>More experiments for free play</summary><ol id="more"></ol></details>
</section>
<section class="card area-math">
<h2>The math, live</h2>
<div class="math" id="math"></div>
<div data-panel="fb">
<div class="sub" style="margin-top:10px" id="barsCap"></div>
<canvas id="bars" aria-label="Discrete error sequence"></canvas>
</div>
</section>
<section class="card area-controls">
<h2>Controls</h2>
<div data-panel="fk ik jac fb">
<div class="checks">
<label><input type="checkbox" id="floor"> Floor</label>
<label><input type="checkbox" id="shelf"> Shelf</label>
<label><input type="checkbox" id="reach" checked> Reachable area</label>
</div>
<div class="ctl"><label for="l1">Link 1 length l₁</label><input type="range" id="l1" min="0.4" max="1.6" step="0.05" value="1"><output id="l1o"></output></div>
<div class="ctl"><label for="l2">Link 2 length l₂</label><input type="range" id="l2" min="0.4" max="1.6" step="0.05" value="1"><output id="l2o"></output></div>
<div class="ctl"><label for="lim">Elbow limit |θ₂| ≤</label><input type="range" id="lim" min="30" max="180" step="5" value="180"><output id="limo"></output></div>
<hr class="sep">
</div>
<div data-panel="r3">
<p class="sub" style="margin-top:0">Tick a joint to unlock it. A locked joint is what tabs 1–4 assumed. Slider ranges are the URDF's joint limits in whole degrees (the elbow's ±96.8° shows as ±96°).</p>
<div id="a3Joints"></div>
<p class="sub hidden" id="a3selfNote"><b>Self-motion is on:</b> turn the <b>Wrist flex</b> slider. Lift and elbow are re-solved each time so the fingertip stays put.</p>
<hr class="sep">
<div class="checks">
<label><input type="checkbox" id="a3plane"> The plane of tabs 1–4</label>
<label><input type="checkbox" id="a3cloud"> Reachable points</label>
<label><input type="checkbox" id="a3axes"> Joint axes</label>
<label><input type="checkbox" id="a3jac"> Jacobian columns</label>
</div>
<div class="row">
<button id="a3solve" class="primary">▶ Solve to the target</button>
<label class="sub" style="margin:0;display:inline-flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" id="a3follow"> Follow the target as I drag it</label>
</div>
<p class="sub">The green target appears when you solve or follow. Drag it in the 3D view (it slides in the screen's plane) or, more precisely, in either flat view. On a phone, use the flat views: put your finger on the target.</p>
</div>
<div data-panel="fk jac ik">
<div class="ctl"><label for="t1">θ₁ shoulder</label><input type="range" id="t1" min="-180" max="180" step="1" value="30"><output id="t1o"></output></div>
<div class="ctl"><label for="t2">θ₂ elbow</label><input type="range" id="t2" min="-180" max="180" step="1" value="60"><output id="t2o"></output></div>
</div>
<div data-panel="ik">
<p class="sub">Drag in the task pane to move the green target.</p>
<div class="row">
<button id="useA">Jump to A (θ₂ +)</button>
<button id="useB">Jump to B (θ₂ −)</button>
<button id="solve" class="primary">▶ Solve iteratively</button>
</div>
<p class="sub">Iterative IK starts from the <i>current</i> pose and steps downhill on ‖p(q) − p*‖² using the damped Jacobian. It ignores obstacles, just like a bare solver.</p>
</div>
<div data-panel="jac">
<div class="row">
<button id="wig1">〰 Wiggle shoulder</button>
<button id="wig2">〰 Wiggle elbow</button>
</div>
<p class="sub">Drag in the task pane to set the desired hand velocity ṗ* (green arrow).</p>
<div class="checks">
<label><input type="radio" name="inv" value="plain"> Plain inverse J⁻¹</label>
<label><input type="radio" name="inv" value="damped" checked> Damped J⁺</label>
</div>
<div class="ctl"><label for="lam">Damping λ</label><input type="range" id="lam" min="0.01" max="0.5" step="0.01" value="0.1"><output id="lamo"></output></div>
<div class="row"><button id="runJ" class="primary">▶ Run diff-IK</button><button id="resetJ">Reset pose</button></div>
<div class="row" style="align-items:flex-start;gap:14px">
<div class="pad" aria-label="Teleop pad">
<button class="up" data-k="up" aria-label="Hand up">↑</button><button class="left" data-k="left" aria-label="Hand left">←</button>
<button class="down" data-k="down" aria-label="Hand down">↓</button><button class="right" data-k="right" aria-label="Hand right">→</button>
</div>
<p class="sub" style="max-width:260px"><b>Teleop:</b> hold the pad or <kbd>←</kbd><kbd>↑</kbd><kbd>→</kbd><kbd>↓</kbd> / <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to drive the hand. Diff-IK converts each command into joint speeds.</p>
</div>
</div>
<div data-panel="fb">
<div class="row">
<label class="sub" for="path">Target</label>
<select id="path">
<option value="step">Step (jumps every 3 s)</option>
<option value="circle">Circle</option>
<option value="sweep">Side-to-side sweep</option>
<option value="drag">Drag it yourself</option>
</select>
<button id="runF" class="primary">⏸ Pause</button>
<button id="resetF">Reset</button>
</div>
<div class="ctl"><label for="kp">Gain k_p (1/s)</label><input type="range" id="kp" min="0" max="120" step="0.5" value="4"><output id="kpo"></output></div>
<div class="ctl hidden" id="kdRow"><label for="kd">Damping k_d</label><input type="range" id="kd" min="0" max="16" step="0.25" value="0"><output id="kdo"></output></div>
<div class="ctl"><label for="rate">Control rate (Hz)</label><input type="range" id="rate" min="5" max="200" step="1" value="50"><output id="rateo"></output></div>
<div class="ctl"><label for="lat">Latency (ms)</label><input type="range" id="lat" min="0" max="200" step="5" value="0"><output id="lato"></output></div>
<div class="ctl"><label for="merr">Model error</label><input type="range" id="merr" min="-40" max="40" step="5" value="0"><output id="merro"></output></div>
<div class="ctl"><label for="qmax">Joint speed limit</label><input type="range" id="qmax" min="1" max="20" step="0.5" value="8"><output id="qmaxo"></output></div>
<div class="checks"><label><input type="checkbox" id="ff" checked> Feed-forward ṗ* (the plan's velocity)</label><label><input type="checkbox" id="inertia"> Inertia (the arm has mass)</label></div>
<canvas id="plot" aria-label="Tracking error over time"></canvas>
<div class="sub">Tracking error |Δp| over the last 10 s. Grey dashed curve = the ideal continuous response D·e<sup>−k<sub>p</sub>t</sup> after each step; the gap between it and the red line is what discrete updates, latency and the speed limit cost you.</div>
</div>
</section>
</div>
<footer>
<span>Unofficial companion to <a href="https://huggingface.co/learn/robotics-course/unit2/1" target="_blank" rel="noopener">Unit 2 · Classical Robotics</a> of the Hugging Face Robotics Course. Not affiliated with Hugging Face.</span>
<span>MIT License · <a href="https://github.com/bakulbadwal/arm-playground" target="_blank" rel="noopener">Source on GitHub</a></span>
</footer>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
// ================= helpers =================
const $ = (id) => document.getElementById(id);
const rad = (d) => d * Math.PI / 180, deg = (r) => r * 180 / Math.PI;
const wrap = (a) => { a = (a + Math.PI) % (2 * Math.PI); if (a < 0) a += 2 * Math.PI; return a - Math.PI; };
const f2 = (x) => (Math.abs(x) < 5e-4 ? 0 : x).toFixed(2);
const f3 = (x) => (Math.abs(x) < 5e-5 ? 0 : x).toFixed(3);
const fd = (r) => deg(r).toFixed(0) + "°";
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
let cssCache = {};
const css = (name) => (cssCache[name] ??= getComputedStyle(document.documentElement).getPropertyValue(name).trim());
const store = { get(k, d) { try { const v = localStorage.getItem("armpg:" + k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem("armpg:" + k, JSON.stringify(v)); } catch (e) {} } };
// ================= state =================
const S = {
tab: "fk", l1: 1, l2: 1, lim: 180, floor: false, shelf: false, showReach: true, basins: false,
q: [rad(30), rad(60)], target: [0.866, 1.5], target2: null, export: null,
pdes: [0.1, 0], inv: "damped", lam: 0.1, runJ: false, keys: {},
anim: null, wiggle: null, iter: null, hoverQ: null, fkTrail: [],
hl: null,
fb: { kp: 4, kd: 0, rate: 50, lat: 0, merr: 0, qmax: 8, ff: true, inertia: false, path: "step", running: true, prevPm: null, qdd: [0, 0],
t: 0, q: null, qdot: [0, 0], nextCtl: 0, hist: [], qbuf: [], trail: [], qtrail: [], drag: [0.5, 1.2], lastHist: 0,
stepInfo: null, lastStep: null, switches: [] },
};
const R = () => S.l1 + S.l2;
// ================= kinematics =================
function fk(q, l1 = S.l1, l2 = S.l2) {
const e = [l1 * Math.cos(q[0]), l1 * Math.sin(q[0])], a = q[0] + q[1];
return { e, h: [e[0] + l2 * Math.cos(a), e[1] + l2 * Math.sin(a)] };
}
function jac(q, l1 = S.l1, l2 = S.l2) {
const s1 = Math.sin(q[0]), c1 = Math.cos(q[0]), s12 = Math.sin(q[0] + q[1]), c12 = Math.cos(q[0] + q[1]);
return [[-l1 * s1 - l2 * s12, -l2 * s12], [l1 * c1 + l2 * c12, l2 * c12]];
}
const det2 = (M) => M[0][0] * M[1][1] - M[0][1] * M[1][0];
const mv = (M, v) => [M[0][0] * v[0] + M[0][1] * v[1], M[1][0] * v[0] + M[1][1] * v[1]];
function inv2(M) { const d = det2(M); if (Math.abs(d) < 1e-9) return null; return [[M[1][1] / d, -M[0][1] / d], [-M[1][0] / d, M[0][0] / d]]; }
function dampedPinv(M, lam) { // Jᵀ (J Jᵀ + λ² I)⁻¹
const a = M[0][0], b = M[0][1], c = M[1][0], d = M[1][1];
const I = inv2([[a * a + b * b + lam * lam, a * c + b * d], [a * c + b * d, c * c + d * d + lam * lam]]); if (!I) return null;
return [[a * I[0][0] + c * I[1][0], a * I[0][1] + c * I[1][1]], [b * I[0][0] + d * I[1][0], b * I[0][1] + d * I[1][1]]];
}
function ik(p, l1 = S.l1, l2 = S.l2) {
const r2 = p[0] * p[0] + p[1] * p[1], c2 = (r2 - l1 * l1 - l2 * l2) / (2 * l1 * l2);
if (c2 > 1 + 1e-9 || c2 < -1 - 1e-9) return { r2, c2, sols: [] };
const t2 = Math.acos(clamp(c2, -1, 1));
return { r2, c2, sols: [t2, -t2].map((b) => [wrap(Math.atan2(p[1], p[0]) - Math.atan2(l2 * Math.sin(b), l1 + l2 * Math.cos(b))), b]) };
}
const sameQ = (a, b, tol = 1.5) => Math.abs(wrap(a[0] - b[0])) < rad(tol) && Math.abs(wrap(a[1] - b[1])) < rad(tol);
// ================= world / collisions =================
const shelfBox = () => ({ x0: 0.33 * R(), x1: 1.05 * R(), y0: 0.52 * R(), y1: 0.6 * R() });
function segHitsBox(a, b, B) { // Liang–Barsky
let t0 = 0, t1 = 1; const dx = b[0] - a[0], dy = b[1] - a[1];
const P = [-dx, dx, -dy, dy], Q = [a[0] - B.x0, B.x1 - a[0], a[1] - B.y0, B.y1 - a[1]];
for (let i = 0; i < 4; i++) {
if (Math.abs(P[i]) < 1e-12) { if (Q[i] < 0) return false; }
else { const r = Q[i] / P[i]; if (P[i] < 0) { if (r > t1) return false; if (r > t0) t0 = r; } else { if (r < t0) return false; if (r < t1) t1 = r; } }
}
return true;
}
function status(q) { // 0 free · 1 floor · 2 shelf · 3 joint limit
if (Math.abs(deg(q[1])) > S.lim + 1e-6) return 3;
const { e, h } = fk(q);
if (S.floor && (e[1] < -1e-6 || h[1] < -1e-6)) return 1;
if (S.shelf) { const B = shelfBox(); if (segHitsBox([0, 0], e, B) || segHitsBox(e, h, B)) return 2; }
return 0;
}
const STATUS_TXT = ["free", "hits the floor", "hits the shelf", "past the elbow limit"];
// ================= precomputed layers =================
const N = 180;
let csImg = null, reachImg = null, freeGrid = null, basinImg = null, basinKey = "";
function hexToRgb(h) {
if (h.startsWith("rgb")) return h.match(/[\d.]+/g).slice(0, 3).map(Number);
h = h.replace("#", ""); if (h.length === 3) h = h.split("").map((c) => c + c).join("");
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
}
function rebuildWorld() {
cssCache = {}; if (typeof A3 !== "undefined" && A3.ready) Arm3D.invalidate();
const off = document.createElement("canvas"); off.width = N; off.height = N;
const g = off.getContext("2d"), im = g.createImageData(N, N);
const cols = [css("--free-c"), css("--floor-c"), css("--shelf-c"), css("--limit-c")].map(hexToRgb);
freeGrid = new Uint8Array(N * N);
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const q = [rad(-180 + (i + 0.5) * 360 / N), rad(180 - (j + 0.5) * 360 / N)];
const st = status(q); freeGrid[j * N + i] = st === 0 ? 1 : 0;
const c = cols[st], k = 4 * (j * N + i); im.data[k] = c[0]; im.data[k + 1] = c[1]; im.data[k + 2] = c[2]; im.data[k + 3] = 255;
}
g.putImageData(im, 0, 0); csImg = off; reachImg = null; basinKey = "";
}
function buildReach(w) {
const B = 140, M = 440, bins = new Uint8Array(B * B), span = 2.3 * R();
let seed = 12345; const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
for (let j = 0; j < M; j++) for (let i = 0; i < M; i++) {
const q = [rad(-180 + (i + rnd()) * 360 / M), rad(-180 + (j + rnd()) * 360 / M)];
if (status(q) !== 0) continue; const h = fk(q).h;
const bx = Math.floor((h[0] / span + 0.5) * B), by = Math.floor((0.5 - h[1] / span) * B);
if (bx >= 0 && by >= 0 && bx < B && by < B) bins[by * B + bx] = 1;
}
const off = document.createElement("canvas"); off.width = B; off.height = B;
const g = off.getContext("2d"); g.fillStyle = css("--reach");
for (let k = 0; k < B * B; k++) if (bins[k]) g.fillRect(k % B, Math.floor(k / B), 1, 1);
reachImg = { img: off, w };
}
// one iterative-IK step: damped least squares, step length capped
function ikStep(q, p) {
const h = fk(q).h, err = [p[0] - h[0], p[1] - h[1]], P = dampedPinv(jac(q), 0.15);
let dq = mv(P, err).map((x) => 0.6 * x); const n = Math.hypot(dq[0], dq[1]); if (n > 0.2) dq = dq.map((x) => x * 0.2 / n);
return { q: [wrap(q[0] + dq[0]), wrap(q[1] + dq[1])], err: Math.hypot(err[0], err[1]) };
}
function buildBasins() {
const key = [S.target[0].toFixed(3), S.target[1].toFixed(3), S.l1, S.l2].join(",");
if (key === basinKey && basinImg) return; if (dragging && basinImg) return; basinKey = key;
const M = 120, off = document.createElement("canvas"); off.width = M; off.height = M;
const g = off.getContext("2d"), sols = ik(S.target).sols;
const cA = css("--basinA"), cB = css("--basinB"), cX = css("--basinX");
for (let j = 0; j < M; j++) for (let i = 0; i < M; i++) {
let q = [rad(-180 + (i + 0.5) * 360 / M), rad(180 - (j + 0.5) * 360 / M)];
for (let k = 0; k < 70; k++) q = ikStep(q, S.target).q;
let c = cX; if (sols.length) { if (sameQ(q, sols[0], 4)) c = cA; else if (sameQ(q, sols[1], 4)) c = cB; }
g.fillStyle = c; g.fillRect(i, j, 1, 1);
}
basinImg = off;
}
function closestFeasible(p) {
let best = null, bd = Infinity;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
if (!freeGrid[j * N + i]) continue;
const q = [rad(-180 + (i + 0.5) * 360 / N), rad(180 - (j + 0.5) * 360 / N)], h = fk(q).h;
const d = (h[0] - p[0]) ** 2 + (h[1] - p[1]) ** 2; if (d < bd) { bd = d; best = q; }
}
if (!best) return null;
let step = rad(1);
for (let it = 0; it < 40; it++) {
let improved = false;
for (const [a, b] of [[step, 0], [-step, 0], [0, step], [0, -step]]) {
const q = [wrap(best[0] + a), wrap(best[1] + b)]; if (status(q) !== 0) continue;
const h = fk(q).h, d = (h[0] - p[0]) ** 2 + (h[1] - p[1]) ** 2; if (d < bd - 1e-12) { bd = d; best = q; improved = true; }
}
if (!improved) step /= 2;
}
return { q: best, d: Math.sqrt(bd) };
}
let cfCache = { key: "", val: null };
function closestCached(p) {
const key = [p[0].toFixed(3), p[1].toFixed(3), S.l1, S.l2, S.floor, S.shelf, S.lim].join(",");
if (cfCache.key !== key) cfCache = { key, val: closestFeasible(p) };
return cfCache.val;
}
// ================= canvases =================
const task = $("task"), cs = $("cspace"), plot = $("plot"), bars = $("bars");
function fit(c) {
const dpr = window.devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight;
if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
const ctx = c.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { ctx, w, h };
}
function T(w) { const s = w / (2.3 * R()); return { s, x: (x) => w / 2 + x * s, y: (y) => w / 2 - y * s, inv: (px, py) => [(px - w / 2) / s, (w / 2 - py) / s] }; }
// displayed pose = state pose + wiggle
function shownQ() {
const base = S.tab === "fb" && S.fb.q ? S.fb.q : S.q;
if (!S.wiggle) return base;
const t = (performance.now() - S.wiggle.t0) / 1000; if (t > 1.8) { S.wiggle = null; return base; }
const off = 0.28 * Math.sin(2 * Math.PI * 1.1 * t) * Math.sin(Math.PI * t / 1.8);
const q = base.slice(); q[S.wiggle.j] = wrap(q[S.wiggle.j] + off); return q;
}
// ================= drawing =================
function arrow(ctx, x0, y0, x1, y1, color, width = 2.5, head = 10) {
const a = Math.atan2(y1 - y0, x1 - x0), L = Math.hypot(x1 - x0, y1 - y0); if (L < 2) return;
ctx.save(); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = width; ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1 - Math.cos(a) * head * 0.7, y1 - Math.sin(a) * head * 0.7); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x1 - head * Math.cos(a - 0.42), y1 - head * Math.sin(a - 0.42));
ctx.lineTo(x1 - head * Math.cos(a + 0.42), y1 - head * Math.sin(a + 0.42)); ctx.closePath(); ctx.fill(); ctx.restore();
}
function label(ctx, x, y, txt, color, bg, align) {
ctx.save(); ctx.font = "12px ui-monospace, Menlo, monospace";
const W = ctx.canvas.clientWidth, Hh = ctx.canvas.clientHeight, tw = ctx.measureText(txt).width, X = clamp(align === "right" ? x - tw : x, 4, W - tw - 4), Y = clamp(y, 14, Hh - 4);
if (bg) { ctx.fillStyle = css("--panel"); ctx.globalAlpha = 0.85; ctx.fillRect(X - 3, Y - 12, tw + 6, 16); ctx.globalAlpha = 1; }
ctx.fillStyle = color || css("--ink"); ctx.fillText(txt, X, Y); ctx.restore();
}
function crosshair(ctx, x, y, c) { ctx.save(); ctx.strokeStyle = c; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, 10, 0, 7); ctx.moveTo(x - 16, y); ctx.lineTo(x - 5, y); ctx.moveTo(x + 5, y); ctx.lineTo(x + 16, y); ctx.moveTo(x, y - 16); ctx.lineTo(x, y - 5); ctx.moveTo(x, y + 5); ctx.lineTo(x, y + 16); ctx.stroke(); ctx.restore(); }
function capsule(ctx, a, b, color, width, alpha = 1) {
ctx.save(); ctx.globalAlpha = alpha; ctx.lineCap = "round";
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
if (width >= 8) { ctx.globalAlpha = alpha * 0.35; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = width * 0.28;
const dx = b[0] - a[0], dy = b[1] - a[1], L = Math.hypot(dx, dy) || 1, ox = -dy / L * width * 0.18, oy = dx / L * width * 0.18;
ctx.beginPath(); ctx.moveTo(a[0] + ox, a[1] + oy); ctx.lineTo(b[0] + ox, b[1] + oy); ctx.stroke(); }
ctx.restore();
}
function drawArm(ctx, t, q, o = {}) {
const { e, h } = fk(q), B = [t.x(0), t.y(0)], E = [t.x(e[0]), t.y(e[1])], H = [t.x(h[0]), t.y(h[1])];
const ghost = !!o.ghost, c1 = o.color || css("--link1"), c2 = o.color2 || o.color || css("--link2"), w = o.width ?? 13;
ctx.save();
if (o.dash) {
ctx.setLineDash(o.dash); ctx.lineCap = "round"; ctx.globalAlpha = o.alpha ?? 0.9; ctx.lineWidth = o.width ?? 3.5;
ctx.strokeStyle = c1; ctx.beginPath(); ctx.moveTo(B[0], B[1]); ctx.lineTo(E[0], E[1]); ctx.stroke();
ctx.strokeStyle = c2; ctx.beginPath(); ctx.moveTo(E[0], E[1]); ctx.lineTo(H[0], H[1]); ctx.stroke();
ctx.setLineDash([]); ctx.fillStyle = c2; ctx.beginPath(); ctx.arc(E[0], E[1], 3.5, 0, 7); ctx.fill(); ctx.restore(); return;
}
if (!ghost) { ctx.shadowColor = "rgba(0,0,0,0.22)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3; }
capsule(ctx, B, E, c1, w, o.alpha ?? 1); capsule(ctx, E, H, c2, w * 0.85, o.alpha ?? 1);
ctx.shadowColor = "transparent";
// gripper
const a = q[0] + q[1], ux = Math.cos(a), uy = -Math.sin(a), px = -uy, py = ux, gs = w * 0.75;
ctx.strokeStyle = ghost ? c2 : css("--joint"); ctx.lineWidth = ghost ? 2 : 3; ctx.lineCap = "round"; ctx.globalAlpha = o.alpha ?? 1;
ctx.beginPath();
ctx.moveTo(H[0] + px * gs, H[1] + py * gs); ctx.lineTo(H[0] - px * gs, H[1] - py * gs);
ctx.moveTo(H[0] + px * gs, H[1] + py * gs); ctx.lineTo(H[0] + px * gs + ux * gs * 1.1, H[1] + py * gs + uy * gs * 1.1);
ctx.moveTo(H[0] - px * gs, H[1] - py * gs); ctx.lineTo(H[0] - px * gs + ux * gs * 1.1, H[1] - py * gs + uy * gs * 1.1);
ctx.stroke();
// joints: motor housings
for (const [P, r] of [[B, w * 0.78], [E, w * 0.66]]) {
ctx.fillStyle = css("--panel"); ctx.strokeStyle = ghost ? c1 : css("--joint"); ctx.lineWidth = ghost ? 1.5 : 2.5;
ctx.beginPath(); ctx.arc(P[0], P[1], r, 0, 7); ctx.fill(); ctx.stroke();
ctx.fillStyle = ghost ? c1 : css("--joint"); ctx.beginPath(); ctx.arc(P[0], P[1], r * 0.32, 0, 7); ctx.fill();
}
ctx.fillStyle = ghost ? c2 : css("--target"); ctx.beginPath(); ctx.arc(H[0] + ux * gs * 0.6, H[1] + uy * gs * 0.6, 2.5, 0, 7); ctx.fill();
ctx.restore();
}
function drawBase(ctx, t) {
const x = t.x(0), y = t.y(0);
ctx.save(); ctx.fillStyle = css("--joint"); ctx.globalAlpha = 0.85;
ctx.beginPath(); ctx.moveTo(x - 20, y + 16); ctx.lineTo(x + 20, y + 16); ctx.lineTo(x + 12, y + 3); ctx.lineTo(x - 12, y + 3); ctx.closePath(); ctx.fill(); ctx.restore();
}
function drawAngles(ctx, t, q) {
const { e } = fk(q), r1 = 30, r2 = 26;
ctx.save(); ctx.lineWidth = 1.8; ctx.font = "13px ui-monospace, Menlo, monospace";
ctx.strokeStyle = css("--ghost"); ctx.setLineDash([3, 4]); ctx.beginPath(); ctx.moveTo(t.x(0), t.y(0)); ctx.lineTo(t.x(0) + 44, t.y(0)); ctx.stroke(); ctx.setLineDash([]);
ctx.strokeStyle = css("--link1"); ctx.beginPath(); ctx.arc(t.x(0), t.y(0), r1, 0, -q[0], q[0] > 0); ctx.stroke();
ctx.fillStyle = css("--link1"); ctx.fillText("θ₁", t.x(0) + (r1 + 8) * Math.cos(-q[0] / 2) - 4, t.y(0) + (r1 + 8) * Math.sin(-q[0] / 2) + 5);
const ext = [e[0] + 0.5 * S.l2 * Math.cos(q[0]), e[1] + 0.5 * S.l2 * Math.sin(q[0])];
ctx.setLineDash([4, 4]); ctx.strokeStyle = css("--ghost"); ctx.beginPath(); ctx.moveTo(t.x(e[0]), t.y(e[1])); ctx.lineTo(t.x(ext[0]), t.y(ext[1])); ctx.stroke(); ctx.setLineDash([]);
ctx.strokeStyle = css("--link2"); ctx.beginPath(); ctx.arc(t.x(e[0]), t.y(e[1]), r2, -q[0], -(q[0] + q[1]), q[1] > 0); ctx.stroke();
const mid = -(q[0] + q[1] / 2); ctx.fillStyle = css("--link2");
ctx.fillText("θ₂", t.x(e[0]) + (r2 + 8) * Math.cos(mid) - 4, t.y(e[1]) + (r2 + 8) * Math.sin(mid) + 5);
ctx.restore();
}
function drawWorld(ctx, t, w) {
ctx.fillStyle = css("--panel"); ctx.fillRect(0, 0, w, w);
ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1;
for (let v = -3; v <= 3; v += 0.5) { ctx.beginPath(); ctx.moveTo(t.x(v), 0); ctx.lineTo(t.x(v), w); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, t.y(v)); ctx.lineTo(w, t.y(v)); ctx.stroke(); }
if (S.showReach) { if (!reachImg || reachImg.w !== w) buildReach(w); ctx.drawImage(reachImg.img, 0, 0, w, w); }
ctx.strokeStyle = css("--ghost"); ctx.setLineDash([3, 5]); ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(t.x(0), t.y(0), R() * t.s, 0, 7); ctx.stroke();
const rin = Math.abs(S.l1 - S.l2); if (rin > 0.01) { ctx.beginPath(); ctx.arc(t.x(0), t.y(0), rin * t.s, 0, 7); ctx.stroke(); }
ctx.setLineDash([]);
if (S.floor) {
ctx.fillStyle = css("--floor-c"); ctx.fillRect(0, t.y(0), w, w - t.y(0));
ctx.save(); ctx.beginPath(); ctx.rect(0, t.y(0), w, w - t.y(0)); ctx.clip(); ctx.strokeStyle = css("--floor"); ctx.globalAlpha = 0.25; ctx.lineWidth = 1;
for (let x = -w; x < w * 2; x += 14) { ctx.beginPath(); ctx.moveTo(x, t.y(0)); ctx.lineTo(x - w, t.y(0) + w); ctx.stroke(); } ctx.restore();
ctx.strokeStyle = css("--floor"); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, t.y(0)); ctx.lineTo(w, t.y(0)); ctx.stroke();
}
if (S.shelf) { const B = shelfBox(), x = t.x(B.x0), y = t.y(B.y1), ww = (B.x1 - B.x0) * t.s, hh = (B.y1 - B.y0) * t.s;
ctx.fillStyle = css("--shelf-c"); ctx.strokeStyle = css("--shelf"); ctx.lineWidth = 2; ctx.fillRect(x, y, ww, hh); ctx.strokeRect(x, y, ww, hh);
label(ctx, x + 6, y - 5, "shelf", css("--shelf")); }
drawBase(ctx, t);
}
const sw = (v, txt) => `<span class="sw" style="background:var(${v})"></span>${txt}`;
function drawTask() {
const { ctx, w } = fit(task), t = T(w), q = shownQ(), st = status(q);
drawWorld(ctx, t, w);
const legend = [], badArm = st !== 0 ? { color: css("--bad"), color2: css("--bad") } : {};
if (S.hoverQ && S.tab !== "fb") drawArm(ctx, t, S.hoverQ, { ghost: true, color: css("--ghost"), color2: css("--ghost"), width: 7, alpha: 0.55 });
if (S.tab === "fk") {
const { e, h } = fk(q);
// fading trail of where the hand has been: moving one slider traces a circle around that joint
const tr = S.fkTrail, lastP = tr[tr.length - 1];
if (!lastP || Math.hypot(h[0] - lastP[0], h[1] - lastP[1]) > 0.004) { tr.push(h); if (tr.length > 160) tr.shift(); }
if (tr.length > 1) { ctx.save(); ctx.lineCap = "round"; ctx.lineWidth = 2.5;
for (let i = 1; i < tr.length; i++) { ctx.globalAlpha = 0.5 * i / tr.length; ctx.strokeStyle = css("--link2");
ctx.beginPath(); ctx.moveTo(t.x(tr[i - 1][0]), t.y(tr[i - 1][1])); ctx.lineTo(t.x(tr[i][0]), t.y(tr[i][1])); ctx.stroke(); }
ctx.restore(); }
ctx.save(); ctx.strokeStyle = css("--ghost"); ctx.setLineDash([2, 4]); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(t.x(h[0]), t.y(h[1])); ctx.lineTo(t.x(h[0]), t.y(0)); ctx.moveTo(t.x(h[0]), t.y(h[1])); ctx.lineTo(t.x(0), t.y(h[1])); ctx.stroke(); ctx.restore();
drawArm(ctx, t, q, badArm);
// head-to-tail vectors
arrow(ctx, t.x(0), t.y(0), t.x(e[0]), t.y(e[1]), css("--link1"), 1.6, 8);
arrow(ctx, t.x(e[0]), t.y(e[1]), t.x(h[0]), t.y(h[1]), css("--link2"), 1.6, 8);
drawAngles(ctx, t, q);
if (S.export) { // vector view for the still image: each link as an arrow offset from the link, and their sum
const off = (a, b, d) => { const dx = t.x(b[0]) - t.x(a[0]), dy = t.y(b[1]) - t.y(a[1]), L = Math.hypot(dx, dy) || 1; return [-dy / L * d, dx / L * d]; };
const o1 = off([0, 0], e, -22), o2 = off(e, h, -22);
arrow(ctx, t.x(0) + o1[0], t.y(0) + o1[1], t.x(e[0]) + o1[0], t.y(e[1]) + o1[1], css("--link1"), 3, 12);
arrow(ctx, t.x(e[0]) + o2[0], t.y(e[1]) + o2[1], t.x(h[0]) + o2[0], t.y(h[1]) + o2[1], css("--link2"), 3, 12);
label(ctx, (t.x(0) + t.x(e[0])) / 2 + o1[0] * 2.6 - 6, (t.y(0) + t.y(e[1])) / 2 + o1[1] * 2.6, "l₁ (cos θ₁, sin θ₁)", css("--link1"), true, "right");
label(ctx, (t.x(e[0]) + t.x(h[0])) / 2 + o2[0] * 1.6 - 8, (t.y(e[1]) + t.y(h[1])) / 2 + o2[1] * 1.6 + 4, "l₂ (cos(θ₁+θ₂), sin(θ₁+θ₂))", css("--link2"), true, "right");
ctx.save(); ctx.setLineDash([6, 5]); arrow(ctx, t.x(0), t.y(0), t.x(h[0]), t.y(h[1]), css("--ink"), 2, 11); ctx.restore();
label(ctx, (t.x(0) + t.x(h[0])) / 2 - 14, (t.y(0) + t.y(h[1])) / 2 + 4, "p(q) = l₁(…) + l₂(…)", css("--ink"), true, "right");
}
label(ctx, t.x(h[0]) + 12, t.y(h[1]) - 12, `p = (${f2(h[0])}, ${f2(h[1])})`, null, true);
if (hlIs("p")) pulse(ctx, t.x(h[0]), t.y(h[1]), css("--accent"));
if (hlIs("q")) { pulse(ctx, t.x(0), t.y(0), css("--link1")); pulse(ctx, t.x(e[0]), t.y(e[1]), css("--link2")); }
legend.push(sw("--link1", "link 1 at θ₁"), sw("--link2", "link 2 at θ₁+θ₂ · fading trail = where the hand has been"), "grey dashes: θ₂ is measured from link 1's direction");
} else if (S.tab === "ik") {
const res = ik(S.target), valid = res.sols.map((s) => status(s) === 0);
res.sols.forEach((s, k) => { if (!sameQ(s, q)) drawArm(ctx, t, s, { dash: [8, 6], color: valid[k] ? css("--ghost") : css("--bad") });
const hE = fk(s).e; label(ctx, t.x(hE[0]) + 8, t.y(hE[1]) - 8, k ? "B" : "A", valid[k] ? css("--muted") : css("--bad")); });
if (!valid.some(Boolean)) { const c = closestCached(S.target); if (c) drawArm(ctx, t, c.q, { dash: [2, 6], color: css("--target"), width: 4 }); }
if (S.iter && S.iter.hand.length > 1) { ctx.save(); ctx.strokeStyle = css("--accent"); ctx.lineWidth = 2; ctx.setLineDash([1, 4]); ctx.beginPath();
S.iter.hand.forEach((p, i) => (i ? ctx.lineTo(t.x(p[0]), t.y(p[1])) : ctx.moveTo(t.x(p[0]), t.y(p[1])))); ctx.stroke(); ctx.restore(); }
drawArm(ctx, t, q, badArm);
crosshair(ctx, t.x(S.target[0]), t.y(S.target[1]), css("--target"));
if (S.target2) { // a second, labelled target (used for the workspace diagram: one reachable, one not)
const r2 = Math.hypot(S.target2[0], S.target2[1]), ok2 = r2 <= R() + 1e-9 && r2 >= Math.abs(S.l1 - S.l2) - 1e-9;
crosshair(ctx, t.x(S.target2[0]), t.y(S.target2[1]), ok2 ? css("--target") : css("--bad"));
label(ctx, t.x(S.target2[0]) - 16, t.y(S.target2[1]) + 4, ok2 ? "p*₂ (reachable)" : "p*₂ (out of reach)", ok2 ? css("--target") : css("--bad"), true, "right");
label(ctx, t.x(S.target[0]) + 14, t.y(S.target[1]) + 4, "p*₁ (reachable)", css("--target"), true);
}
if (hlIs("target")) pulse(ctx, t.x(S.target[0]), t.y(S.target[1]), css("--target"));
if (hlIs("q")) { const eq = fk(q).e; pulse(ctx, t.x(0), t.y(0), css("--link1")); pulse(ctx, t.x(eq[0]), t.y(eq[1]), css("--link2")); }
legend.push(sw("--target", "target p* (drag it)"), sw("--ghost", "other solution"), sw("--bad", "blocked solution"), "green dots: closest feasible pose");
} else if (S.tab === "jac") {
drawArm(ctx, t, q, badArm);
const { h } = fk(q), J = jac(q), k = 0.38 * t.s, hx = t.x(h[0]), hy = t.y(h[1]);
ctx.save(); ctx.strokeStyle = css("--muted"); ctx.fillStyle = css("--accent"); ctx.globalAlpha = 0.9; ctx.lineWidth = 1.3; ctx.beginPath();
for (let i = 0; i <= 72; i++) { const a = i / 72 * 2 * Math.PI, v = mv(J, [Math.cos(a), Math.sin(a)]); const X = hx + v[0] * k, Y = hy - v[1] * k; i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); }
ctx.closePath(); ctx.globalAlpha = 0.08; ctx.fill(); ctx.globalAlpha = 0.9; ctx.stroke(); ctx.restore();
const hi = S.wiggle ? S.wiggle.j : -1, hj = hlIs("J");
arrow(ctx, hx, hy, hx + J[0][0] * k, hy - J[1][0] * k, css("--link1"), hi === 0 || hj ? 4.5 : 2.6);
arrow(ctx, hx, hy, hx + J[0][1] * k, hy - J[1][1] * k, css("--link2"), hi === 1 || hj ? 4.5 : 2.6);
if (hj) pulse(ctx, hx, hy, css("--accent"));
if (hlIs("q")) { const eq = fk(q).e; pulse(ctx, t.x(0), t.y(0), css("--link1")); pulse(ctx, t.x(eq[0]), t.y(eq[1]), css("--link2")); }
label(ctx, hx + J[0][0] * k + 6, hy - J[1][0] * k - 4, "col 1", css("--link1"), true);
label(ctx, hx + J[0][1] * k + 6, hy - J[1][1] * k + 14, "col 2", css("--link2"), true);
const vs = 1.2 * t.s, hp = hlIs("pdes"); arrow(ctx, hx, hy, hx + S.pdes[0] * vs, hy - S.pdes[1] * vs, css("--target"), hp ? 5 : 3.2, 11);
if (hp) pulse(ctx, hx + S.pdes[0] * vs, hy - S.pdes[1] * vs, css("--target"));
label(ctx, hx + S.pdes[0] * vs + 8, hy - S.pdes[1] * vs + 4, "ṗ*", css("--target"), true);
legend.push(sw("--link1", "column 1: shoulder nudge"), sw("--link2", "column 2: elbow nudge"), "ellipse: hand velocities from unit joint speed", sw("--target", "desired ṗ*"));
} else if (S.tab === "fb") {
const F = S.fb;
if (F.trail.length > 1) { ctx.save(); ctx.strokeStyle = css("--link2"); ctx.globalAlpha = 0.45; ctx.lineWidth = 1.6; ctx.beginPath();
F.trail.forEach((p, i) => (i ? ctx.lineTo(t.x(p[0]), t.y(p[1])) : ctx.moveTo(t.x(p[0]), t.y(p[1])))); ctx.stroke(); ctx.restore(); }
const tp = targetAt(F.t).p, { h } = fk(q), b = 10;
ctx.save(); ctx.fillStyle = css("--target"); ctx.globalAlpha = 0.22; ctx.fillRect(t.x(tp[0]) - b, t.y(tp[1]) - b, 2 * b, 2 * b); ctx.globalAlpha = 1;
ctx.strokeStyle = css("--target"); ctx.lineWidth = 2; ctx.strokeRect(t.x(tp[0]) - b, t.y(tp[1]) - b, 2 * b, 2 * b);
const hd = hlIs("dp"); ctx.strokeStyle = css("--bad"); ctx.setLineDash([3, 3]); ctx.lineWidth = hd ? 4 : 1.5; ctx.beginPath(); ctx.moveTo(t.x(h[0]), t.y(h[1])); ctx.lineTo(t.x(tp[0]), t.y(tp[1])); ctx.stroke(); ctx.restore();
drawArm(ctx, t, q, badArm);
if (hd) pulse(ctx, (t.x(h[0]) + t.x(tp[0])) / 2, (t.y(h[1]) + t.y(tp[1])) / 2, css("--bad"));
legend.push(sw("--target", "moving target"), sw("--bad", "Δp, or arm in collision"), sw("--link2", "hand trail"));
}
if (S.export) { label(ctx, 10, 18, "task space (x, y)", css("--muted")); if (S.showReach) label(ctx, 10, w - 8, `reachable: ${f2(Math.abs(S.l1 - S.l2))} ≤ |p| ≤ ${f2(R())}`, css("--muted")); }
$("taskLegend").innerHTML = legend.map((x) => `<span>${x}</span>`).join("");
$("taskStatus").innerHTML = st === 0 ? `<span class="pill ok">pose free</span>` : `<span class="pill bad">${STATUS_TXT[st]}</span>`;
}
function drawCS() {
const { ctx, w } = fit(cs);
ctx.imageSmoothingEnabled = false; ctx.drawImage(csImg, 0, 0, w, w);
if (S.basins && S.tab === "ik") { buildBasins(); ctx.imageSmoothingEnabled = true; ctx.drawImage(basinImg, 0, 0, w, w); }
const X = (th) => (deg(th) + 180) / 360 * w, Y = (th) => (180 - deg(th)) / 360 * w;
ctx.strokeStyle = css("--line"); ctx.lineWidth = 1;
for (const d of [-90, 0, 90]) { ctx.beginPath(); ctx.moveTo(X(rad(d)), 0); ctx.lineTo(X(rad(d)), w); ctx.moveTo(0, Y(rad(d))); ctx.lineTo(w, Y(rad(d))); ctx.stroke(); }
ctx.font = "11px ui-monospace, Menlo, monospace"; ctx.fillStyle = css("--muted");
ctx.fillText("θ₁ →", w - 40, Y(0) - 6); ctx.fillText("θ₂ ↑", X(0) + 6, 13);
for (const d of [-90, 90]) { ctx.fillText(d + "°", X(rad(d)) + 3, w - 5); ctx.fillText(d + "°", 3, Y(rad(d)) - 3); }
if (S.tab === "jac") { // |det J| = l1 l2 |sin θ2| depends only on θ2: paint it as a vertical field, strongest where the arm is straight or folded
const [r, g, b] = hexToRgb(css("--bad")), grad = ctx.createLinearGradient(0, 0, 0, w);
for (let d = -180; d <= 180; d += 4) { const a = 0.42 * Math.pow(1 - Math.abs(Math.sin(rad(d))), 3); grad.addColorStop((180 - d) / 360, `rgba(${r},${g},${b},${a.toFixed(3)})`); }
ctx.fillStyle = grad; ctx.fillRect(0, 0, w, w);
ctx.save(); ctx.strokeStyle = css("--bad"); ctx.setLineDash([6, 4]); ctx.lineWidth = 1.6;
for (const d of [0, -179.5, 179.5]) { ctx.beginPath(); ctx.moveTo(0, Y(rad(d))); ctx.lineTo(w, Y(rad(d))); ctx.stroke(); }
ctx.restore(); ctx.fillStyle = css("--bad"); ctx.fillText("det J = 0 (arm straight)", 6, Y(0) + 14); ctx.fillText("det J = 0 (folded)", 6, 26);
// q̇ = J⁺ ṗ*: the joint-speed vector at q that produces the requested hand velocity, drawn as ~0.6 s of motion
const Jq = jac(S.q), Pq = S.inv === "plain" ? inv2(Jq) : dampedPinv(Jq, S.lam), qdv = Pq ? mv(Pq, S.pdes) : null;
if (qdv && Math.hypot(qdv[0], qdv[1]) > 1e-3) { const q0 = S.q, q1 = [q0[0] + qdv[0] * 0.6, q0[1] + qdv[1] * 0.6];
arrow(ctx, X(q0[0]), Y(q0[1]), X(q1[0]), Y(q1[1]), css("--target"), 3, 10); label(ctx, X(q1[0]) + 8, Y(q1[1]) + 4, "q̇ = J⁺ṗ*", css("--target"), true); }
}
if (S.export) label(ctx, 10, w - 24, "configuration space (θ₁, θ₂)", css("--muted"));
if (S.tab === "ik") {
ik(S.target).sols.forEach((s, k) => { const ok = status(s) === 0; ctx.strokeStyle = ok ? css("--target") : css("--bad"); ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(X(s[0]), Y(s[1]), 9, 0, 7); ctx.stroke(); ctx.fillStyle = ctx.strokeStyle; ctx.font = "bold 12px ui-monospace, Menlo, monospace"; ctx.fillText(k ? "B" : "A", X(s[0]) + 11, Y(s[1]) - 9); });
if (S.iter && S.iter.path.length > 1) polyQ(ctx, S.iter.path, X, Y, w, css("--accent"), 2.2);
}
if (S.tab === "fb") { // where the controller "should" end up: the two IK solutions of the current target
if (S.fb.qtrail.length) polyQ(ctx, S.fb.qtrail, X, Y, w, css("--link2"), 1.6, 0.7);
ctx.save(); ctx.strokeStyle = css("--target"); ctx.lineWidth = 2; ctx.setLineDash([3, 3]);
for (const s of ik(targetAt(S.fb.t).p).sols) { ctx.beginPath(); ctx.arc(X(s[0]), Y(s[1]), 9, 0, 7); ctx.stroke(); } ctx.restore();
}
if (S.hoverQ) { ctx.save(); ctx.strokeStyle = css("--ink"); ctx.globalAlpha = 0.35; ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.moveTo(X(S.hoverQ[0]), 0); ctx.lineTo(X(S.hoverQ[0]), w); ctx.moveTo(0, Y(S.hoverQ[1])); ctx.lineTo(w, Y(S.hoverQ[1])); ctx.stroke(); ctx.restore(); }
const q = shownQ();
ctx.fillStyle = css("--ink"); ctx.strokeStyle = css("--panel"); ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(X(q[0]), Y(q[1]), 6.5, 0, 7); ctx.fill(); ctx.stroke();
if (hlIs("q")) pulse(ctx, X(q[0]), Y(q[1]), css("--accent"));
if (hlIs("Q")) { const tt = (performance.now() - S.hl.t0) / 1000; ctx.save(); ctx.strokeStyle = css("--accent"); ctx.lineWidth = 4; ctx.globalAlpha = 0.9 * (1 - tt / 1.7); ctx.strokeRect(2, 2, w - 4, w - 4); ctx.restore();
ik(S.target).sols.forEach((s2) => pulse(ctx, X(s2[0]), Y(s2[1]), css("--target"))); }
}
function polyQ(ctx, pts, X, Y, w, color, lw, alpha = 1) {
ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.globalAlpha = alpha; ctx.beginPath(); let prev = null;
for (const qq of pts) { const x = X(qq[0]), y = Y(qq[1]); if (!prev || Math.abs(x - prev[0]) > w / 2 || Math.abs(y - prev[1]) > w / 2) ctx.moveTo(x, y); else ctx.lineTo(x, y); prev = [x, y]; }
ctx.stroke(); ctx.restore();
}
// ================= equations strip =================
const EQ = {
fk: [["p(q)=\\begin{pmatrix} l_1\\cos\\theta_1 + l_2\\cos(\\theta_1+\\theta_2)\\\\ l_1\\sin\\theta_1 + l_2\\sin(\\theta_1+\\theta_2)\\end{pmatrix}", "Forward kinematics: add two arrows head to tail. One pose, one hand position."]],
ik: [["\\min_{q\\in\\mathcal{Q}}\\ \\lVert p(q)-p^*\\rVert_2^2", "IK as a search over allowed poses 𝒬"],
["\\cos\\theta_2=\\frac{r^2-l_1^2-l_2^2}{2\\,l_1l_2}", "Closed form for two links; the ± on θ₂ gives A and B"]],
jac: [["\\dot p = J(q)\\,\\dot q", "Joint speeds in, hand speed out"], ["\\dot q = J(q)^{+}\\,\\dot p^*", "Differential IK: invert it"], ["\\det J = l_1 l_2\\sin\\theta_2", "Zero when the arm is straight or folded"]],
fb: [["\\dot q = J(q)^{+}\\big(\\dot p^* + k_p\\,\\Delta p\\big)", "Plan's velocity plus a push toward the measured error"], ["e_{k+1}=(1-k_p\\Delta t)\\,e_k", "Discrete update: smooth if k_pΔt < 1, unstable if > 2"]],
r3: [["p(q)=T_0R(q_1)\\;T_1R(q_2)\\cdots T_4R(q_5)\\;T_{\\text{tip}}", "Forward kinematics in 3D: for each joint a fixed step T, then a turn R, multiplied in order"],
["J=\\frac{\\partial p}{\\partial q}\\ \\text{ is } 3\\times 5", "Five joint angles in, three numbers (x, y, z) out. Not square, so there is no J⁻¹"],
["\\dot q = J^{\\top}\\big(JJ^{\\top}+\\lambda^2 I\\big)^{-1}\\dot p^*", "Tab 3's damped J⁺, unchanged: it never needed J to be square"]],
};
function renderEq() {
$("eq").innerHTML = EQ[S.tab].map(([tex, gloss], i) => `<div class="eqc"><div class="tex" id="tex${i}"></div><div class="gloss">${gloss}</div></div>`).join("");
EQ[S.tab].forEach(([tex], i) => { const el = $("tex" + i);
if (window.katex) { try { katex.render(tex, el, { throwOnError: false, displayMode: false }); return; } catch (e) {} }
el.textContent = tex.replace(/\\[a-z]+/gi, (m) => ({ "\\theta": "θ", "\\cos": "cos", "\\sin": "sin", "\\dot": "", "\\det": "det", "\\min": "min", "\\Delta": "Δ" }[m] ?? "")).replace(/[{}]/g, ""); });
}
// ================= symbols key =================
// [symbol, plain English, highlight key]. Highlight keys: DOM ids or canvas objects handled in pulse().
const SYMS = {
fk: [["q = (θ₁, θ₂)", "the two joint angles. Shoulder and elbow: the θ sliders, and the black dot on the map", "q"],
["l₁, l₂", "how long each link is (the length sliders)", "l"],
["p(q)", "where the hand ends up: its (x, y) coordinates on the left", "p"],
["cos θ, sin θ", "how far a link of length 1 reaches to the right (cos) and up (sin) when it points at angle θ", null],
["θ₁ + θ₂", "link 2's absolute direction, because θ₂ is measured from link 1, not from the ground", "q"]],
ik: [["p*", "the target: where you want the hand. The green crosshair you drag", "target"],
["q", "the pose the arm is in right now (black dot on the map)", "q"],
["𝒬", "the set of allowed poses: the white part of the map. Floor, shelf and joint limits paint some of it out", "Q"],
["‖p(q) − p*‖²", "the squared distance from hand to target. Zero means you're there", "target"],
["min over q", "search every allowed pose for the smallest miss. That search is inverse kinematics", "Q"],
["r", "the target's distance from the base; inside the ring means reachable", "target"],
["A, B", "the two exact answers (elbow one way or the other), drawn as rings on the map", "Q"]],
jac: [["ṗ", "the hand's velocity. A dot over a symbol means 'per second'", "pdes"],
["q̇", "the joint speeds, in radians per second", "q"],
["J(q)", "the Jacobian: a 2×2 table whose two columns are the two arrows at the hand, one per joint", "J"],
["ṗ*", "the hand velocity you're asking for: the green arrow", "pdes"],
["J⁺", "the best-effort inverse of J. Same as J⁻¹ unless J is singular; then 'damped' keeps it finite", "lam"],
["det J", "how fat the velocity ellipse is; zero means some direction is impossible", "J"],
["λ", "damping: how much to sacrifice accuracy for sane joint speeds near a singularity", "lam"]],
fb: [["Δp = p* − p(q)", "the error: from hand to target, the red dashed line", "dp"],
["k_p", "the gain: how hard to push per unit of error (the slider)", "kp"],
["k_d", "damping: a brake proportional to speed. Only matters once the arm has mass (Inertia on); the course's velocity-commanded arm has none", "kd"],
["ṗ*", "the plan's own velocity, added when 'Feed-forward' is on", "ff"],
["Δt", "how long between controller updates: 1 / control rate", "rate"],
["d", "latency measured in controller ticks", "lat"],
["e_k", "the error at tick k, in the bar chart under the math panel", "bars"]],
r3: [["q = (q₁ … q₅)", "the five joint angles: shoulder pan, shoulder lift, elbow, wrist flex, wrist roll (the five sliders)", "q5"],
["θ₁, θ₂", "the two angles of tabs 1–4, drawn in the side view. Here they come from the lift and elbow joints, which the robot's file counts the other way from their own zeros: θ₁ = 76.0° − lift, θ₂ = −73.8° − elbow", "a3flat"],
["Tₖ", "a fixed step from one joint to the next: a shift and a tilt read from the robot's description file (its URDF). It never changes", null],
["R(qₖ)", "a turn by the angle qₖ about joint k's own axis. Tick 'Joint axes' to see all five", "a3axes"],
["p(q)", "where the fingertip is: (x, y, z) in millimetres, in the URDF's base frame (x points out of the arm's front, z is up)", null],
["J", "one column per joint: the way the fingertip moves when only that joint turns. Tick 'Jacobian columns' to see them as arrows", "a3jac"],
["J⁺", "the pseudo-inverse: of all the joint motions that move the fingertip the way you asked, the smallest one", "a3solve"]],
};
const HL_DOM = { q: ["t1", "t2"], l: ["l1", "l2"], lam: ["lam"], kp: ["kp"], kd: ["kd"], ff: ["ff"], rate: ["rate"], lat: ["lat"], bars: ["bars"],
q5: ["a3q0", "a3q1", "a3q2", "a3q3", "a3q4"], a3flat: ["a3flat"], a3axes: ["a3axes"], a3jac: ["a3jac"], a3solve: ["a3solve"] };
function renderSyms() {
$("symList").innerHTML = SYMS[S.tab].map(([sym, txt, key]) => `<li><button class="sym" ${key ? `data-hl="${key}"` : "disabled"}>${sym}</button><span>${txt}</span></li>`).join("");
$("symList").querySelectorAll("button[data-hl]").forEach((b) => b.addEventListener("click", () => highlight(b.dataset.hl)));
}
function highlight(key) {
S.hl = { key, t0: performance.now() };
for (const id of HL_DOM[key] || []) { const el = $(id); if (!el) continue; const box = el.closest(".ctl") || el.closest("label") || el; box.classList.add("hl"); setTimeout(() => box.classList.remove("hl"), 1700); }
}
// pulsing ring at a canvas point while a symbol is highlighted
function pulse(ctx, x, y, color) {
if (!S.hl) return; const t = (performance.now() - S.hl.t0) / 1000; if (t > 1.7) { S.hl = null; return; }
ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.globalAlpha = 0.9 * (1 - t / 1.7);
ctx.beginPath(); ctx.arc(x, y, 16 + 10 * Math.sin(t * 9), 0, 7); ctx.stroke(); ctx.restore();
}
const hlIs = (k) => S.hl && S.hl.key === k && performance.now() - S.hl.t0 < 1700;
// ================= math panel =================
function renderMath() {
const q = shownQ(), { h } = fk(q), st = status(q);
const pose = `q = (θ₁, θ₂) = (${fd(q[0])}, ${fd(q[1])})`;
let m = "";
if (S.tab === "fk") {
m = `${pose}\n\np(q) = ( l₁cos θ₁ + l₂cos(θ₁+θ₂) , l₁sin θ₁ + l₂sin(θ₁+θ₂) )\n` +
` = ( ${f2(S.l1)}·${f3(Math.cos(q[0]))} + ${f2(S.l2)}·${f3(Math.cos(q[0] + q[1]))} , ${f2(S.l1)}·${f3(Math.sin(q[0]))} + ${f2(S.l2)}·${f3(Math.sin(q[0] + q[1]))} )\n` +
` = ( ${f3(h[0])} , ${f3(h[1])} )\n\nlink 2 absolute angle θ₁+θ₂ = ${fd(wrap(q[0] + q[1]))}\nreach |p| = ${f3(Math.hypot(h[0], h[1]))} workspace: ${f2(Math.abs(S.l1 - S.l2))} ≤ |p| ≤ ${f2(R())}`;
} else if (S.tab === "ik") {
const res = ik(S.target), p = S.target;
m = `target p* = (${f3(p[0])}, ${f3(p[1])}) r² = ${f3(res.r2)}\ncos θ₂ = (r² − l₁² − l₂²) / (2 l₁ l₂) = ${f3(res.c2)}\n`;
if (!res.sols.length) { const c = closestCached(p);
m += `|cos θ₂| > 1 → no exact solution (out of reach)\n\nclosest feasible (min ‖p(q) − p*‖²): ${c ? `(${fd(c.q[0])}, ${fd(c.q[1])}), miss ${f3(c.d)}` : "—"}`; }
else {
m += `θ₁ = atan2(y, x) − atan2(l₂ sin θ₂, l₁ + l₂ cos θ₂)\n\n`;
res.sols.forEach((s, k) => { const s2 = status(s); m += `${k ? "B" : "A"} = (${fd(s[0]).padStart(5)}, ${fd(s[1]).padStart(5)}) ${s2 === 0 ? '<span class="pill ok">valid</span>' : '<span class="pill bad">' + STATUS_TXT[s2] + "</span>"}\n`; });
if (!res.sols.some((s) => status(s) === 0)) { const c = closestCached(p); m += `\nboth blocked → closest feasible ${c ? `(${fd(c.q[0])}, ${fd(c.q[1])}), miss ${f3(c.d)}` : "—"}`; }
}
m += `\ncurrent ${pose}` + (S.iter ? `\niterative IK: step ${S.iter.k}, error ${f3(S.iter.err)}${S.iter.done ? " ✓ converged" : ""}` : "");
} else if (S.tab === "jac") {
const J = jac(q), d = det2(J), P = S.inv === "plain" ? inv2(J) : dampedPinv(J, S.lam), qd = P ? mv(P, S.pdes) : null, back = qd ? mv(J, qd) : null;
const cls = Math.abs(d) < 0.08 ? "bad" : Math.abs(d) < 0.3 ? "warn" : "ok";
m = `${pose}\n\nJ(q) = [ ${f3(J[0][0]).padStart(7)} ${f3(J[0][1]).padStart(7)} ] ← columns = the two arrows\n [ ${f3(J[1][0]).padStart(7)} ${f3(J[1][1]).padStart(7)} ]\n\n` +
`det J = l₁l₂ sin θ₂ = ${f3(d)} <span class="pill ${cls}">${cls === "bad" ? "near singular" : cls === "warn" ? "getting close" : "well-conditioned"}</span>\n\nṗ* = (${f3(S.pdes[0])}, ${f3(S.pdes[1])})\n` +
(qd ? `q̇ = ${S.inv === "plain" ? "J⁻¹" : "J⁺(λ=" + S.lam.toFixed(2) + ")"} ṗ* = (${f3(qd[0])}, ${f3(qd[1])}) rad/s ${Math.max(Math.abs(qd[0]), Math.abs(qd[1])) > 8 ? '<span class="pill bad">huge joint speed</span>' : ""}\ncheck J q̇ = (${f3(back[0])}, ${f3(back[1])})`
: `q̇ = J⁻¹ ṗ* → <span class="pill bad">J is singular: no inverse</span>`);
} else if (S.tab === "fb") {
const F = S.fb, dt = 1 / F.rate, kd = F.kp * dt, tp = targetAt(F.t).p, err = Math.hypot(tp[0] - h[0], tp[1] - h[1]), d = Math.round(F.lat / 1000 / dt);
const regime = kd < 1 ? ["ok", "smooth"] : kd < 2 ? ["warn", "overshoots, settles"] : ["bad", "unstable"];
const e = errSeq(kd, d, 40), unstable = Math.abs(e[e.length - 1]) > 1.05, ringing = e.some((x) => x < -0.01);
const actual = unstable ? ["bad", "unstable"] : ringing ? ["warn", "rings"] : ["ok", "smooth"];
const kc = 2 * Math.sqrt(F.kp), zeta = F.kp > 0 ? F.kd / kc : 0, damp = zeta < 0.05 ? ["bad", "undamped: oscillates forever"] : zeta < 0.8 ? ["warn", "underdamped: rings, then settles"] : zeta <= 1.25 ? ["ok", "≈ critically damped"] : ["warn", "overdamped: slow"];
m = F.inertia
? `q̈ = J(q)⁺ ( k_p Δp + k_d (${F.ff ? "ṗ*" : "0"} − ṗ) − J̇q̇ ), Δp = p* − p(q) ← a command is now an acceleration\n\nk_p = ${F.kp.toFixed(1)} (spring) k_d = ${F.kd.toFixed(2)} (damper) inner loop 500 Hz (control-rate slider idle) latency ${F.lat} ms\n` +
`critical damping k_d = 2√k_p = ${kc.toFixed(1)} ζ = k_d / 2√k_p = ${zeta.toFixed(2)} <span class="pill ${damp[0]}">${damp[1]}</span>\n`
: `q̇ = J(q)⁺ ( ${F.ff ? "ṗ*" : "0 "} + k_p Δp ), Δp = p* − p(q)\n\nk_p = ${F.kp.toFixed(1)} /s Δt = 1/${F.rate} Hz = ${(dt * 1000).toFixed(1)} ms latency ${F.lat} ms = ${d} tick${d === 1 ? "" : "s"}\n` +
`k_p·Δt = ${kd.toFixed(2)} <span class="pill ${regime[0]}">rule of thumb: ${regime[1]}</span> <span class="pill ${actual[0]}">${d ? "with " + d + "-tick delay" : "exact sequence"}: ${actual[1]}</span>\n`;
m +=
`model error ${F.merr > 0 ? "+" : ""}${F.merr}% (controller's J uses wrong link lengths)\n\n|Δp| now = ${f3(err)}` +
(F.path === "step" && F.lastStep ? `\nlast step: overshoot ${F.lastStep.os.toFixed(0)}% · settles (2%) in ${F.lastStep.settle == null ? "> 3 s" : F.lastStep.settle.toFixed(2) + " s"}` : "") + `\n${pose} ${st ? '<span class="pill bad">' + STATUS_TXT[st] + "</span>" : ""}`;
}
$("math").innerHTML = m;
}
function errSeq(kpdt, d, n = 40) { const e = new Array(d + 1).fill(1); for (let k = 0; k < n; k++) e.push(e[e.length - 1] - kpdt * e[e.length - 1 - d]); return e.slice(d); }
function errSeq2(kp, kd, dt, n = 80) { // unit-mass spring–damper from a unit step, semi-implicit Euler at the control rate
const out = []; let e = 1, v = 0; for (let k = 0; k < n; k++) { out.push(e); v += (-kp * e - kd * v) * dt; e += v * dt; } return out;
}
function drawBars() {
const { ctx, w, h } = fit(bars), F = S.fb, dt = 1 / F.rate, d = Math.round(F.lat / 1000 / dt);
const e = F.inertia ? errSeq2(F.kp, F.kd, dt, 90) : errSeq(F.kp * dt, d, 36);
$("barsCap").innerHTML = F.inertia
? "<b>Second-order error</b> from a unit step: ë = −k<sub>p</sub>e − k<sub>d</sub>ė (one bar per 20 ms). With k<sub>d</sub> = 0 the bars never shrink."
: "<b>Discrete-time error</b>: e<sub>k+1</sub> = e<sub>k</sub> − k<sub>p</sub>Δt · e<sub>k−d</sub> (d = latency in ticks), starting from a unit step";
ctx.clearRect(0, 0, w, h); ctx.fillStyle = css("--chip"); ctx.fillRect(0, 0, w, h);
const mid = h / 2, bw = w / e.length, sc = (h / 2 - 4) / 1.6;
ctx.strokeStyle = css("--line"); ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke();
e.forEach((v, i) => { const vv = clamp(v, -1.6, 1.6); ctx.fillStyle = Math.abs(v) > 1.6 ? css("--bad") : v >= 0 ? css("--accent") : css("--warn");
ctx.fillRect(i * bw + 1, vv >= 0 ? mid - vv * sc : mid, Math.max(1, bw - 2), Math.abs(vv) * sc); });
ctx.fillStyle = css("--muted"); ctx.font = "11px ui-monospace, Menlo, monospace"; ctx.fillText("e_k (tick →)", 4, 12);
}
// ================= feedback simulation =================
function targetAt(t) {
const Rr = R(), path = S.fb.path;
if (path === "step") { const A = [0.55 * Rr, 0.42 * Rr], B = [-0.3 * Rr, 0.62 * Rr]; return { p: Math.floor(t / 3) % 2 ? B : A, v: [0, 0] }; }
if (path === "circle") { const c = [0.25 * Rr, 0.45 * Rr], r = 0.28 * Rr, w = 0.9; return { p: [c[0] + r * Math.cos(w * t), c[1] + r * Math.sin(w * t)], v: [-r * w * Math.sin(w * t), r * w * Math.cos(w * t)] }; }
if (path === "sweep") { const w = 0.7, A = 0.75 * Rr; return { p: [A * Math.sin(w * t), 0.12 * Rr], v: [A * w * Math.cos(w * t), 0] }; }
return { p: S.fb.drag.slice(), v: [0, 0] };
}
function resetFB() {
const F = S.fb; Object.assign(F, { t: 0, q: S.q.slice(), qdot: [0, 0], nextCtl: 0, hist: [], trail: [], qtrail: [], qbuf: [], lastHist: 0, stepInfo: null, lastStep: null, switches: [], prevPm: null, qdd: [0, 0] });
if (F.path === "step") beginStep(F, 0);
}
function beginStep(F, t) {
const h0 = fk(F.q).h, tp = targetAt(t).p, D = Math.hypot(tp[0] - h0[0], tp[1] - h0[1]);
F.stepInfo = { t0: t, h0, tp, D, maxS: 0, lastOut: t }; F.switches.push({ t, D });
}
const SIM_DT = 0.002;
function stepFB(realDt) {
const F = S.fb; if (!F.q) resetFB();
let steps = Math.min(Math.round(realDt / SIM_DT), 60);
const lagSteps = Math.round(F.lat / 1000 / SIM_DT), scale = 1 + F.merr / 100;
while (steps-- > 0) {
F.qbuf.push(F.q.slice()); while (F.qbuf.length > lagSteps + 1) F.qbuf.shift();
if (F.inertia) {
// The arm has mass: a command is an acceleration, and PD = spring (k_p) + damper (k_d). This loop is the servo's
// inner loop, so it runs every sim step (500 Hz) on the latest (possibly delayed) measurement; the control-rate slider is idle here.
const qm = F.qbuf[0], pm = fk(qm).h, tgt = targetAt(F.t), lm = [S.l1 * scale, S.l2 * scale];
const pdot = F.prevPm ? [(pm[0] - F.prevPm[0]) / SIM_DT, (pm[1] - F.prevPm[1]) / SIM_DT] : [0, 0]; F.prevPm = pm;
const vs = F.ff ? tgt.v : [0, 0];
const a = [F.kp * (tgt.p[0] - pm[0]) + F.kd * (vs[0] - pdot[0]), F.kp * (tgt.p[1] - pm[1]) + F.kd * (vs[1] - pdot[1])];
// p̈ = J q̈ + J̇ q̇, so to make the hand accelerate by exactly `a` the controller subtracts its estimate of J̇ q̇
const Jq = jac(qm, lm[0], lm[1]), Jn = jac([qm[0] + F.qdot[0] * SIM_DT, qm[1] + F.qdot[1] * SIM_DT], lm[0], lm[1]);
const jdq = [((Jn[0][0] - Jq[0][0]) * F.qdot[0] + (Jn[0][1] - Jq[0][1]) * F.qdot[1]) / SIM_DT, ((Jn[1][0] - Jq[1][0]) * F.qdot[0] + (Jn[1][1] - Jq[1][1]) * F.qdot[1]) / SIM_DT];
const P = dampedPinv(Jq, 0.02);
F.qdd = (P ? mv(P, [a[0] - jdq[0], a[1] - jdq[1]]) : [0, 0]).map((x) => clamp(x, -400, 400));
F.qdot = [clamp(F.qdot[0] + F.qdd[0] * SIM_DT, -F.qmax, F.qmax), clamp(F.qdot[1] + F.qdd[1] * SIM_DT, -F.qmax, F.qmax)];
} else if (F.t >= F.nextCtl) { // the course's controller: a command is a velocity, updated at the control rate
const qm = F.qbuf[0], pm = fk(qm).h, tgt = targetAt(F.t);
const P = dampedPinv(jac(qm, S.l1 * scale, S.l2 * scale), 0.02);
const v = [(F.ff ? tgt.v[0] : 0) + F.kp * (tgt.p[0] - pm[0]), (F.ff ? tgt.v[1] : 0) + F.kp * (tgt.p[1] - pm[1])];
F.qdot = (P ? mv(P, v) : [0, 0]).map((x) => clamp(x, -F.qmax, F.qmax));
F.nextCtl += 1 / F.rate;
}
F.q = [wrap(F.q[0] + F.qdot[0] * SIM_DT), wrap(F.q[1] + F.qdot[1] * SIM_DT)];
const prevIdx = Math.floor(F.t / 3); F.t += SIM_DT;
if (F.path === "step" && Math.floor(F.t / 3) !== prevIdx) {
if (F.stepInfo) F.lastStep = finishStep(F.stepInfo);
beginStep(F, F.t);
}
if (F.stepInfo) { const I = F.stepInfo, h = fk(F.q).h, dx = I.tp[0] - I.h0[0], dy = I.tp[1] - I.h0[1];
const s = I.D > 1e-6 ? ((h[0] - I.h0[0]) * dx + (h[1] - I.h0[1]) * dy) / (I.D * I.D) : 1; I.maxS = Math.max(I.maxS, s);
if (Math.hypot(I.tp[0] - h[0], I.tp[1] - h[1]) > 0.02 * I.D) I.lastOut = F.t; }
if (F.t - F.lastHist >= 0.02) {
F.lastHist = F.t; const h = fk(F.q).h, tp = targetAt(F.t).p;
F.hist.push([F.t, Math.hypot(tp[0] - h[0], tp[1] - h[1])]); while (F.hist.length && F.hist[0][0] < F.t - 10) F.hist.shift();
F.trail.push(h); if (F.trail.length > 250) F.trail.shift();
F.qtrail.push(F.q.slice()); if (F.qtrail.length > 250) F.qtrail.shift();
}
}
}
function finishStep(I) { const settle = I.lastOut - I.t0; return { os: Math.max(0, (I.maxS - 1) * 100), settle: settle > 2.95 ? null : settle }; }
function drawPlot() {
const { ctx, w, h } = fit(plot); ctx.clearRect(0, 0, w, h); ctx.fillStyle = css("--chip"); ctx.fillRect(0, 0, w, h);
const H = S.fb.hist; if (H.length < 2) return;
const t1 = H[H.length - 1][0], t0 = t1 - 10, ymax = Math.max(0.2, ...H.map((p) => Math.min(p[1], 3)));
const Y = (e) => h - 4 - Math.min(e, 3) / ymax * (h - 14), Xt = (t) => (t - t0) / 10 * w;
ctx.strokeStyle = css("--line"); ctx.setLineDash([3, 3]);
for (const s of S.fb.switches) if (s.t > t0) { ctx.beginPath(); ctx.moveTo(Xt(s.t), 0); ctx.lineTo(Xt(s.t), h); ctx.stroke(); }
// ideal continuous response from each switch: e(t) = D·exp(−k_p (t − t_switch)), the "time constant 1/k_p" curve
if (S.fb.path === "step" && S.fb.kp > 0 && !S.fb.inertia) { ctx.strokeStyle = css("--muted"); ctx.setLineDash([5, 4]); ctx.lineWidth = 1.2;
for (const s of S.fb.switches) { const tEnd = Math.min(t1, s.t + 3); if (tEnd <= t0) continue; ctx.beginPath();
for (let t = Math.max(s.t, t0); t <= tEnd; t += 0.04) { const e = s.D * Math.exp(-S.fb.kp * (t - s.t)); t === Math.max(s.t, t0) ? ctx.moveTo(Xt(t), Y(e)) : ctx.lineTo(Xt(t), Y(e)); } ctx.stroke(); } }
ctx.setLineDash([]); ctx.strokeStyle = css("--bad"); ctx.lineWidth = 1.6; ctx.beginPath();
H.forEach(([t, e], i) => { i ? ctx.lineTo(Xt(t), Y(e)) : ctx.moveTo(Xt(t), Y(e)); }); ctx.stroke();
ctx.fillStyle = css("--muted"); ctx.font = "11px ui-monospace, Menlo, monospace"; ctx.fillText("max " + ymax.toFixed(2), 4, 12);
}
// ================= lessons: tab 5 (data) =================
// Setups are in degrees, in joint order (pan, lift, elbow, wrist flex, wrist roll). `free` is one digit per joint, 1 = unlocked.
// `key` names the check in test.mjs that recomputes the typed answer from the kinematics.
const L_R3 = [
{ t: "The plane was a slice", setup: { q: [0, 0, 0, 0, 0], free: "01100" },
b: "<p>This is the same arm as tabs 1–4, now drawn from the robot's own description file: the URDF of the <b>SO‑101</b>, the current revision of the course's SO‑100. Three of its five arm joints are locked, which is the arm the course asked you to imagine. (Tab 1 said four: it also counted the sixth motor, which opens the jaw and is left out here.)</p><p>The <b>blue sheet</b> is the flat plane you have been working in, and the dashed ring on it is tab 1's reach circle. The <b>side view</b> in the other pane is that sheet seen face-on: it <i>is</i> tabs 1–4, with θ₁ and θ₂ marked. Drag the 3D view to look around, then move the two unlocked sliders. The wrist never leaves the sheet.</p><p>Two things the course rounded off. It drew two equal links; the real upper arm is <b>116 mm</b> and the real forearm is <b>135 mm</b>. And the robot's file counts each joint from its own zero, in its own direction, so the sliders are not θ₁ and θ₂ themselves: θ₁ = 76.0° − lift and θ₂ = −73.8° − elbow.</p>",
ask: { type: "num", key: "reachOuter", q: "Upper arm 116 mm, forearm 135 mm. What is the farthest the wrist can get from the shoulder-lift joint, in mm?", a: 251, tol: 0.5,
why: "116 + 135 = 251 mm, with the elbow straight. That's tab 1's outer circle, l₁ + l₂, and the arm has just straightened to touch it. The inner circle is new: this elbow stops before the arm folds flat, so the wrist can't come closer than 27.9 mm. The shaded part of the side view is smaller than the ring, because the shoulder has limits too.", after: { q: [0, 20, -73.8, 0, 0] } } },
{ t: "Unlock the pan", setup: { q: [-35, 0, 0, 0, 0], free: "11100", cloud: 1, dist: 1020 },
b: "<p>The <b>shoulder pan</b> is unlocked now: the amber joint at the base. Slide it. The whole sheet swings about a vertical axis, like a door on its hinge. The top view shows it best.</p><p>The dots are wrist positions, sampled by setting the three free joints at random (the legend under the picture gives the count). One flat ring, swept around the hinge, has become a <b>volume</b>. A position now takes three numbers (x, y, z), and there are three free joints to supply them.</p>",
ask: { type: "num", key: "panHeight", q: "Slide only the pan, from one end of its range to the other (−110° to +110°). By how many mm does the wrist's height z change?", a: 0, tol: 0.05,
why: "0 mm. The pan axis is vertical, so it carries the whole arm around at a fixed height; only x and y change (watch z in the math panel). Worked from the URDF's own rounded numbers the height wobbles by under 0.01 mm, because the file writes π as 3.14159." } },
{ t: "Three joints for two numbers", setup: { q: [0, -23, 40, 11, 0], free: "01110", mode: "self" },
b: "<p>The pan is locked again, so the arm is back in its plane. But the <b>wrist</b> is unlocked: three joints now move in a plane where the fingertip only needs two numbers, how far out and how high.</p><p>Turn the <b>Wrist flex</b> slider. Each time, the lab re-solves the lift and the elbow so the fingertip stays exactly where it is. The arm slides through pose after pose and the fingertip never moves. Roboticists call that a <i>self-motion</i>.</p>",
ask: { type: "mc", q: "With these three joints free, how many poses put the fingertip on this exact spot?", o: ["Exactly 2, like A and B in tab 2", "A continuous family: one for every wrist angle in a range", "Exactly 1"], a: 1,
why: "One spare joint is one spare choice: you pick the wrist angle and the other two joints make up for it. Tab 2's tidy 'A or B' is gone, and so is its two-line formula; this lab solves it numerically instead. Even 'A or B' was generous for the real arm: measured as tab 2's θ₂, this elbow runs from −170.7° to only +23°, so most targets have just one of the two." } },
{ t: "The map no longer fits", setup: { q: [-20, -20, 30, 20, 0], free: "11111", cloud: 1, plane: 0, dist: 1320 },
b: "<p>In tabs 1–4 the second pane was a <b>map</b>: every pose of the arm was one pixel, because a pose was two angles and a screen has two directions. That map is where you saw obstacles, the two IK answers, and the solver's basins.</p><p>All five joints are unlocked now. The dots are sampled fingertip positions: a sketch of the arm's workspace (it ignores the arm bumping into itself or its base). Orbit around it.</p>",
ask: { type: "num", key: "N", q: "With five joints free, how many numbers does it take to name one pose of the arm?", a: 5, tol: 0.1,
why: "Five: one angle per joint. A map of every pose would need five dimensions, so it can't be a picture any more. A grid over two angles at 5° steps has 72 × 72 = 5,184 cells; the same grid over this arm's five joint ranges has about 166 million. The obstacles and the families of solutions still live in that space, but nobody can look at it, which is why real planners <i>sample</i> it." } },
{ t: "Why the course writes J⁺", setup: { q: [0, -20, 30, 20, 0], free: "11111", jac: 1, plane: 0, target: [250, 120, 170], mode: "follow" },
b: "<p>Tab 3's Jacobian was a 2 × 2 table, one column per joint, and you could invert it. Each joint still has its own arrow at the fingertip (drawn now, one colour per joint; the wrist roll's is too short to see, about 8 mm per radian), but the fingertip's position is only three numbers. So the table is <b>3 rows × 5 columns</b>. The math panel prints it.</p><p><b>Drag the green target</b>, in the 3D view or in either flat view. The arm follows using the same formula as tab 3, q̇ = Jᵀ(JJᵀ + λ²I)⁻¹ ṗ*. It never needed J to be square: out of all the joint motions that move the fingertip the right way, it takes the smallest.</p>",
ask: { type: "mc", q: "J is now 3 × 5. Can it be inverted the way tab 3's plain inverse was?", o: ["Yes. Any table of numbers has an inverse", "No. Only a square table has an inverse. Five joints for three numbers leaves freedom to spare, and J⁺ uses it to pick the smallest motion", "No, because turns in 3D can't be written in a table"], a: 1,
why: "That's why the course writes J⁺ and not J⁻¹: the plus is what copes with a table that isn't square. The freedom to spare is the self-motion you felt two steps ago." } },
{ t: "Five joints, six numbers", setup: { q: [-30, -30, 50, 30, 40], free: "11111", triad: 1, plane: 0 },
b: "<p>So far we only asked <i>where</i> the fingertip is: three numbers. To pick up a cup the gripper also has to be turned the right way: which way it points (two numbers) and how far it is twisted about that line (one more). Those three are its <i>orientation</i>, drawn as the red, green and blue arrows on the fingertip. Position plus orientation is <b>six numbers</b>.</p><p>The math panel now shows the full Jacobian: six rows, five columns. Five joints can change those six numbers in at most five independent ways.</p>",
ask: { type: "mc", q: "Can this arm put its fingertip at any reachable point, turned any way you choose?", o: ["Yes, given enough joint range", "No. Five joints supply five numbers; position plus orientation needs six, so one way of moving is always missing", "Only close to the base"], a: 1,
why: "At most five of the six rows are independent, and 5 &lt; 6 (the panel counts them live). On this arm the missing motion is the gripper's heading: it always points along the arm's own sheet, so it can't swing left or right without the whole arm panning. And look at what this tab needed for one small arm: a chain of matrices, a table that isn't square, a numerical solver, joint limits, and a map of poses nobody can draw. Add a camera, contact, friction and a soft object, and the hand-written model runs out. That is where Unit 2 ends, and why the rest of the course <b>learns the mapping from data</b>. <a href=\"workbench.html\">Now drive this arm yourself →</a> (the Workbench: seven missions, from teleoperation to training a small policy on your own demonstrations)." } },
];
// ================= lessons =================
const L = {
r3: L_R3,
fk: [
{ t: "Two links, two angles", setup: { q: [30, 60], l1: 1, l2: 1, floor: 0, shelf: 0, lim: 180 },
b: "<p>This is the course's SO‑100 arm with four joints locked, so it moves in a flat plane. You control just two numbers, <b>q = (θ₁, θ₂)</b>: the shoulder angle and the elbow angle.</p><p>The left pane shows where that puts the hand. The right pane is a <b>map of every possible q</b>, and the black dot is you. Drag the θ sliders and watch both move.</p>" },
{ t: "Arrow one", setup: { q: [30, 60] },
b: "<p>Link 1 starts at the base and points at angle θ₁. Its tip, the elbow, sits at <b>l₁·(cos θ₁, sin θ₁)</b>.</p>",
ask: { type: "num", q: "With l₁ = 1 and θ₁ = 30°, what is the elbow's x‑coordinate?", a: 0.866, tol: 0.01, why: "cos 30° = 0.866, so the elbow is at (0.866, 0.5)." } },
{ t: "Arrow two is relative", setup: { q: [30, 60] },
b: "<p>θ₂ is measured from the <b>grey dashed line</b>, which is link 1's own direction, not the ground. So link 2 points at <b>θ₁ + θ₂</b> in absolute terms. Forgetting this is the most common forward-kinematics mistake.</p>",
ask: { type: "num", q: "At q = (30°, 60°), what absolute angle does link 2 point at (degrees)?", a: 90, tol: 0.5, why: "30° + 60° = 90°, straight up. That's why link 2 is vertical." } },
{ t: "Add the arrows", setup: { q: [30, 60] },
b: "<p>Hand = arrow 1 + arrow 2 = (0.866 + 0, 0.5 + 1) = <b>(0.866, 1.5)</b>. That's forward kinematics: one pose in, exactly one hand position out.</p>",
ask: { type: "num", q: "Predict the hand's y‑coordinate at q = (0°, 90°).", a: 1, tol: 0.01, why: "Link 1 lies flat to (1, 0). Link 2 points at 0° + 90° = up, adding (0, 1). Hand = (1, 1). The arm has just moved there.", after: { q: [0, 90] } } },
{ t: "The map is every pose", setup: { q: [30, 60] },
b: "<p><b>Hover over the right-hand map.</b> Each pixel is a pose, and a grey ghost arm shows it on the left. Moving sideways changes the shoulder; moving up and down changes the elbow. Click to jump there.</p>",
ask: { type: "mc", q: "Where on the map is the arm perfectly straight?", o: ["Along the horizontal line θ₂ = 0", "Along the vertical line θ₁ = 0", "Only at the centre point"], a: 0, why: "Straight means the elbow isn't bent: θ₂ = 0, whatever the shoulder does. That whole row comes back in the Jacobian tab as a singularity." } },
{ t: "The workspace is a ring", setup: { q: [45, 130], l2: 0.5 },
b: "<p>The blue tint is everywhere the hand can reach. With equal links it's a full disk. <b>Link 2 is now half as long</b>, and a hole has opened in the middle: the hand can't fold back to the base.</p>",
ask: { type: "num", q: "With l₁ = 1 and l₂ = 0.5, what is the radius of the hole?", a: 0.5, tol: 0.01, why: "|l₁ − l₂| = 0.5. The outer radius is l₁ + l₂ = 1.5. The workspace is a ring (annulus)." } },
],
ik: [
{ t: "Run it backwards", setup: { l1: 1, l2: 1, floor: 0, shelf: 0, lim: 180, target: [0.866, 1.5], q: [30, 60], basins: 0 },
b: "<p>Now you choose where the hand should go (<b>drag the green target</b>) and ask which q gets it there. That's inverse kinematics.</p><p>Look at the map: there are two rings, <b>A</b> and <b>B</b>. They're two different poses that put the hand in the same place.</p>",
ask: { type: "mc", q: "For a target strictly inside the reachable ring, how many poses reach it?", o: ["Exactly 1", "2", "Infinitely many"], a: 1, why: "Two: elbow one way (A) or the other (B). On the ring's edge they merge into one; outside it there are none. An arm with more joints than it needs has infinitely many." } },
{ t: "The closed form", setup: { target: [1, 1], q: [0, 90] },
b: "<p>For two links there's an exact formula. First <b>cos θ₂ = (r² − l₁² − l₂²) / (2 l₁ l₂)</b>, where r is the target's distance from the base. The ± on θ₂ gives you A and B, and two atan2s then give θ₁.</p>",
ask: { type: "num", q: "Target (1, 1), l₁ = l₂ = 1. What is cos θ₂?", a: 0, tol: 0.01, why: "r² = 2, so (2 − 1 − 1) / 2 = 0 → θ₂ = ±90°. The two solutions are (0°, 90°) and (90°, −90°). Check the math panel." } },
{ t: "Out of reach", setup: { target: [2.4, 0.4], q: [30, 30] },
b: "<p>The target is now outside the dashed circle. The formula breaks (|cos θ₂| > 1), so there's no exact answer. The <b>green dotted arm</b> is the course's optimisation form, <b>min ‖p(q) − p*‖²</b>: the closest the hand can get.</p>",
ask: { type: "mc", q: "What does the best miss look like for a far target?", o: ["Arm fully straight, aimed at the target", "Elbow bent at 90°", "Arm folded back on itself"], a: 0, why: "A straight arm gives maximum reach, so the closest point is the stretched-out arm pointing at the target." } },
{ t: "The floor deletes solutions", setup: { floor: 1, shelf: 0, target: [0.9, 0.25], q: [-46.8, 124.4] },
b: "<p>Floor on. The target sits low and to the right. Solution A would put the elbow <b>through the floor</b>, so it's drawn red. On the map, its ring sits inside the tan region. The arm is currently in that blocked pose, so press <b>Jump to B</b>.</p>",
ask: { type: "mc", q: "Why does the floor change IK but not FK?", o: ["It changes the FK formula", "It removes poses from the allowed set 𝒬; FK still maps every pose to a hand position", "It moves the target"], a: 1, why: "FK is pure geometry and never changes. Obstacles carve holes in configuration space: the tan region on the map. That's what makes IK a constrained search." } },
{ t: "Iterative IK: which answer?", setup: { floor: 0, shelf: 0, target: [0.866, 1.5], q: [-120, -30], basins: 1 },
b: "<p>Arms with six or more joints have no neat formula, so solvers <b>iterate</b>: they nudge q downhill on ‖p(q) − p*‖² using the Jacobian (tab 3). Press <b>▶ Solve iteratively</b> and watch the blue path on the map.</p><p>The tint on the map is the <b>basin</b> of each solution: start anywhere green and you converge to A; start in purple and you get B. Click somewhere else on the map and solve again.</p>",
ask: { type: "mc", q: "Which solution does an iterative solver find?", o: ["Always A", "The one whose basin you start in, usually the nearby one", "A random one each time"], a: 1, why: "It's local search, so the start decides the answer. Real robots seed IK with their current pose, so they get the solution that needs the least motion." } },
{ t: "Inside the ring, still unreachable", setup: { floor: 1, shelf: 1, target: [1.2, 1.35], q: [60, -40], basins: 0 },
b: "<p>Floor and shelf on. This target is well inside the ring, yet <b>both A and B are red</b>: the forearm has to pass through the shelf either way. The green dotted arm is the best feasible pose.</p><p>This is what the course means by constraints making 𝒬 <i>much more complex</i>.</p>",
ask: { type: "num", q: "How many valid IK solutions does this target have?", a: 0, tol: 0.1, why: "Zero. Geometry says two, and the shelf blocks both. A planner would have to go around, or give up and reach as close as it can." } },
],
jac: [
{ t: "Each joint's nudge", setup: { q: [30, 60], pdes: [0.1, 0] },
b: "<p>Press <b>〰 Wiggle shoulder</b>, then <b>〰 Wiggle elbow</b>. Each wiggle moves the hand along one arrow. Those arrows are the <b>columns of the Jacobian J</b>. Column 1 is the hand velocity per unit of shoulder speed; column 2 is the same for the elbow.</p>",
ask: { type: "mc", q: "At q = (30°, 60°) the forearm points straight up. Which way does column 2 point?", o: ["Straight left", "Straight up", "Toward the base"], a: 0, why: "(−1, 0). Turning the elbow swings the tip of the forearm sideways, perpendicular to the forearm." } },
{ t: "Velocities in, velocities out", setup: { q: [30, 60], pdes: [0.1, 0], inv: "plain" },
b: "<p><b>ṗ = J q̇</b>: joint speeds in, hand speed out. Differential IK flips it: choose the hand speed you want and get joint speeds, <b>q̇ = J⁺ ṗ*</b>. The green arrow is ṗ* = (0.1, 0), meaning move right.</p>",
ask: { type: "num", q: "Predict the shoulder speed q̇₁ (rad/s) for ṗ* = (0.1, 0).", a: 0, tol: 0.01, why: "q̇ = (0, −0.1). Only the elbow turns, clockwise. The forearm is vertical, so swinging it moves the hand straight right. The math panel confirms it." } },
{ t: "The ellipse and det J", setup: { q: [30, 60], pdes: [0.1, 0] },
b: "<p>The ellipse at the hand is every hand velocity you can get from joint speeds of size 1. A round ellipse means you can move well in every direction. <b>det J = l₁ l₂ sin θ₂</b> tracks how fat it is.</p>",
ask: { type: "num", q: "det J at (30°, 60°) with l₁ = l₂ = 1?", a: 0.866, tol: 0.01, why: "sin 60° = 0.866." } },
{ t: "Singularity", setup: { q: [30, 14], inv: "plain", pdesOutward: 0.25 },
b: "<p>The elbow is nearly straight, and ṗ* points straight outward. Press <b>▶ Run diff-IK</b>. As the arm straightens, det J → 0 and the plain inverse demands exploding joint speeds (it auto-stops above 60 rad/s).</p><p>Then press <b>↺ Reset this step</b>, choose <b>Damped J⁺</b> and run again: it slows to a stop instead of blowing up.</p>",
ask: { type: "mc", q: "At which elbow angles is det J = 0?", o: ["θ₂ = 0° and ±180°", "θ₂ = ±90°", "θ₁ = 0°"], a: 0, why: "sin θ₂ = 0: arm fully straight or fully folded. Those are the red dashed rows on the map." } },
{ t: "Drive it yourself", setup: { q: [60, 70], inv: "damped", pdes: [0, 0] },
b: "<p>Hold the arrow keys (or the pad) to command a hand velocity. Diff-IK turns it into joint speeds every frame, which is Cartesian teleoperation. Try steering along the edge of the workspace.</p><p>LeRobot's SO‑100 teleop works differently: a <b>leader arm</b> you move by hand, whose joint angles the follower simply copies.</p>",
ask: { type: "mc", q: "Why is leader–follower (joint-space) teleop popular for collecting robot demonstrations?", o: ["It needs no IK or Jacobian, so singularities and multiple solutions never come up", "It's more precise than any Cartesian method", "It doesn't need motors on the follower"], a: 0, why: "Copying joint angles skips this whole tab. The human's arm does the inverse kinematics." } },
],
fb: [
{ t: "Open loop trusts the model", setup: { l1: 1, l2: 1, floor: 0, shelf: 0, lim: 180, q: [30, 60], path: "circle", kp: 0, merr: 30, ff: 1, lat: 0, rate: 50, qmax: 8 },
b: "<p>The box moves in a circle. The controller only follows the plan's velocity ṗ* (k_p = 0), and its model of the arm is <b>30% wrong</b>. Watch the hand drift off the circle.</p>",
ask: { type: "mc", q: "Why does it drift?", o: ["Its J is built from wrong link lengths, and nothing measures the resulting error", "The motors are too slow", "The target moves too fast"], a: 0, why: "Open-loop control computes commands once and hopes. Model error accumulates with nothing to correct it." } },
{ t: "Close the loop", setup: { path: "circle", kp: 5, merr: 30, ff: 1, lat: 0, rate: 50 },
b: "<p>Now <b>k_p = 5</b>: add k_p·Δp, a push toward wherever the hand actually is off by. The model is just as wrong, and the arm tracks anyway. <i>Feedback makes good systems out of imperfect components.</i></p>",
ask: { type: "num", q: "Ideally the error shrinks with time constant 1/k_p. What is it for k_p = 5 (seconds)?", a: 0.2, tol: 0.01, why: "1/5 = 0.2 s. Double k_p and the error closes twice as fast, up to a point (next step). Switch the target to Step and the grey dashed curve on the error plot is exactly this ideal e^(−k_p t)." } },
{ t: "Gain vs update rate", setup: { path: "step", kp: 30, merr: 0, ff: 1, lat: 0, rate: 50 },
b: "<p>Step target. A real controller updates every Δt, not continuously, so the error update is <b>e ← (1 − k_pΔt)·e</b>. The bar chart under the math panel plays that sequence for the current settings. Blue bars are the error on the starting side; amber bars mean it has overshot.</p>",
ask: { type: "mc", q: "At 50 Hz, what happens with k_p = 75?", o: ["Smooth approach", "Overshoots and rings, then settles", "Diverges"], a: 1, why: "k_pΔt = 1.5 is between 1 and 2: overshoot, then settle. The arm is now set to it, and the bars alternate sign. The arm itself only overshoots a few percent, because the <b>joint speed limit</b> caps the big early move and the ringing only starts once it's close. That's <i>saturation</i>, a second nonlinearity real controllers live with. Raise the speed limit to 20 and the ringing grows.", after: { kp: 75 } } },
{ t: "Slow loops can't take high gain", setup: { path: "step", kp: 25, rate: 10, lat: 0, merr: 0 },
b: "<p>Same arm, now updated at <b>10 Hz</b>. k_p = 25 would be nothing special at 50 Hz.</p>",
ask: { type: "num", q: "What is k_p·Δt here?", a: 2.5, tol: 0.01, why: "25 × 0.1 = 2.5 > 2, so it's unstable. Only the joint speed limit keeps the arm on screen. That's why fast inner loops sit under slow policies (field guide §3.5)." } },
{ t: "Latency", setup: { path: "step", kp: 30, rate: 50, lat: 60, merr: 0, kd: 0 },
b: "<p>Back to 50 Hz and k_p = 30 (k_pΔt = 0.6, 'smooth' by the rule). Now add <b>60 ms of latency</b>: the controller acts on a measurement three ticks old. Compare the two pills in the math panel. This is the slow shower pipe.</p>",
ask: { type: "mc", q: "What's the best first fix?", o: ["Lower k_p, or cut the latency", "Raise k_p to correct faster", "Raise the joint speed limit"], a: 0, why: "Delay plus high gain means acting hard on stale news. Lower the gain or shorten the loop. More gain makes it worse." } },
{ t: "Tracking isn't planning", setup: { path: "sweep", kp: 5, rate: 50, lat: 0, merr: 0, floor: 1, shelf: 0 },
b: "<p>Floor on. The controller tracks the box just fine, straight through the floor (the arm turns red). Nothing in q̇ = J⁺(ṗ* + k_pΔp) knows about obstacles; that's a separate planner's job.</p>",
ask: { type: "mc", q: "Which of the course's four limitations of classical pipelines is this?", o: ["Integration: separate modules that each only do their own job", "Modelling limitations", "Ignoring data trends"], a: 0, why: "Planning, control and perception are separate modules with fixed interfaces. Each one is blind to what the others handle, and errors compound between them." } },
{ t: "Give the arm mass (beyond the course)", setup: { path: "step", kp: 30, rate: 50, lat: 0, merr: 0, inertia: 1, kd: 0, qmax: 20 },
b: "<p>Unit 2's arm has no inertia: a command <i>is</i> a velocity, so proportional control can only overshoot through discretisation or latency. Real arms have mass, so a command is a <b>push</b> (an acceleration). <b>Inertia</b> is now on.</p><p>Watch the hand: with k_p alone it sails past the target and swings back, again and again, and once the target jumps it even <i>orbits</i> the target like a planet. Proportional control on a mass is a spring with no friction: nothing removes energy. Now slide <b>k_d</b> up: that's the damper, the D in PD, and the loop that runs inside every servo under a learned walking policy. Fastest settling with no overshoot is at <b>k_d = 2√k_p ≈ 11</b>.</p>",
ask: { type: "mc", q: "With inertia on and k_d = 0, what does the hand do after the step?", o: ["Oscillates around the target and never settles", "Settles smoothly, just slower", "Stops short of the target"], a: 0, why: "A spring pulls harder the further you are, but nothing removes energy, so the hand keeps swinging through the target. k_d removes energy in proportion to speed. The arm is now set to k_d = 11, critical damping: compare the error trace.", after: { kd: 11 } } },
],
};
const MORE = {
fk: ["Make l₁ short and l₂ long. Where does the hole go?", "Set the elbow limit to 90° and watch whole bands of the map turn grey.", "Turn on the floor: over half the map disappears, because any pose with the elbow or hand below y = 0 is gone."],
ik: ["Turn on Basins, then drag the target slowly. The basin boundary bends and moves.", "Put the target exactly on the outer circle: A and B merge into a single ring.", "With the floor on, find a target where A is valid and B is blocked, then the reverse."],
jac: ["Fold the arm back (θ₂ near ±180°) and look at the ellipse: the other singularity.", "Teleop into a straight arm with the plain inverse selected, and watch the joint-speed pill.", "Change l₂ and watch det J = l₁l₂ sin θ₂ scale with it."],
fb: ["Circle target with feed-forward OFF: the hand lags behind the box by a steady amount. Raise k_p to shrink the lag.", "Negative model error (−30%) and k_p = 0: the drift goes the other way.", "Drag mode: fling the target around and watch the joint speed limit clip the response."],
r3: ["Lock everything except the pan and tick 'Reachable points': the workspace collapses to a single arc.", "Unlock all five joints, tick Follow, and drag the target far out in front of the arm. It stretches toward the target and stops: tab 2's 'closest feasible pose', in 3D. Now drag it behind the base: this downhill search can stall against a joint limit before it finds the closest pose.", "Unlock only the lift and elbow, tick 'Jacobian columns', and straighten the elbow until the wrist touches the dashed ring: the two arrows line up. That is tab 3's singularity, still here.", "Unlock only the wrist roll and turn it: the fingertip barely moves, because it sits just 7.9 mm off the roll axis. The J column in the math panel is in single digits."],
};
// Every step starts from its tab's baseline so nothing leaks in from another tab (link lengths, limits, obstacles, inverse type).
const BASE = {
fk: { l1: 1, l2: 1, lim: 180, floor: 0, shelf: 0 },
ik: { l1: 1, l2: 1, lim: 180, floor: 0, shelf: 0, basins: 0 },
jac: { l1: 1, l2: 1, lim: 180, floor: 0, shelf: 0, inv: "plain", lam: 0.1 },
fb: { l1: 1, l2: 1, lim: 180, floor: 0, shelf: 0, q: [30, 60], qmax: 8, ff: 1, merr: 0, lat: 0, rate: 50, kd: 0, inertia: 0 },
r3: { q: [0, 0, 0, 0, 0], free: "01100", plane: 1, cloud: 0, axes: 0, jac: 0, triad: 0, mode: "", target: null, view: "q", dist: 860 },
};
const stepSetup = (tab, i) => ({ ...BASE[tab], ...L[tab][i].setup });
let LS = { fk: 0, ik: 0, jac: 0, fb: 0, r3: 0, ...store.get("ls", {}) }, DONE = store.get("done", {});
function applySetup(s, animate = true) {
if (!s) return;
if (S.tab === "r3") { A3.apply(s, animate); return; } // tab 5 keeps its own state, in arm3d.js
let world = false;
for (const k of ["l1", "l2", "lim"]) if (s[k] != null) { S[k] = s[k]; $(k).value = s[k]; world = true; }
for (const k of ["floor", "shelf"]) if (s[k] != null) { S[k] = !!s[k]; $(k).checked = !!s[k]; world = true; }
if (s.basins != null) { S.basins = !!s.basins; $("basins").checked = S.basins; syncBasinsUI(); }
if (world) rebuildWorld();
if (s.target) S.target = s.target.slice();
if (s.target2) S.target2 = s.target2.slice();
if (s.inv) { S.inv = s.inv; document.querySelector(`input[name=inv][value=${s.inv}]`).checked = true; }
if (s.pdes) S.pdes = s.pdes.slice();
if (s.lam != null) { S.lam = s.lam; $("lam").value = s.lam; }
S.runJ = false; syncRunJ(); S.iter = null;
const F = S.fb; let fbChanged = false;
for (const k of ["kp", "kd", "rate", "lat", "merr", "qmax"]) if (s[k] != null) { F[k] = s[k]; $(k).value = s[k]; fbChanged = true; }
if (s.ff != null) { F.ff = !!s.ff; $("ff").checked = F.ff; }
if (s.inertia != null) { F.inertia = !!s.inertia; $("inertia").checked = F.inertia; syncInertiaUI(); fbChanged = true; }
if (s.path) { F.path = s.path; $("path").value = s.path; fbChanged = true; }
if (s.q) { const to = [rad(s.q[0]), rad(s.q[1])]; if (animate && S.tab !== "fb") S.anim = { from: S.q.slice(), to, t0: performance.now(), dur: 650 }; else S.q = to; }
if (s.pdesOutward) { const q = s.q ? [rad(s.q[0]), rad(s.q[1])] : S.q, h = fk(q).h, n = Math.hypot(h[0], h[1]); S.pdes = [h[0] / n * s.pdesOutward, h[1] / n * s.pdesOutward]; }
if (S.tab === "fb" && (fbChanged || s.q || world)) { if (s.q) S.q = [rad(s.q[0]), rad(s.q[1])]; resetFB(); }
outputs(); syncSliders();
}
function renderLesson(apply) {
const list = L[S.tab], i = clamp(LS[S.tab], 0, list.length - 1), st = list[i];
$("lsTab").textContent = { fk: "Forward kinematics", ik: "Inverse kinematics", jac: "Jacobian", fb: "Feedback", r3: "The real arm (3D)" }[S.tab];
$("lsCount").textContent = `${i + 1} / ${list.length}`;
$("lsTitle").textContent = st.t; $("lsBody").innerHTML = st.b;
$("lsDots").innerHTML = list.map((_, k) => `<button aria-label="Step ${k + 1}" class="${k === i ? "cur" : DONE[S.tab + k] ? "done" : ""}" data-k="${k}"></button>`).join("");
$("lsDots").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => { LS[S.tab] = +b.dataset.k; store.set("ls", LS); renderLesson(true); }));
$("lsPrev").disabled = i === 0; $("lsNext").textContent = i === list.length - 1 ? "Done ✓" : "Next →";
$("more").innerHTML = MORE[S.tab].map((x) => `<li>${x}</li>`).join(""); syncTabProgress();
const A = st.ask, box = $("lsAsk");
if (!A) box.innerHTML = "";
else if (A.type === "num") {
box.innerHTML = `<div class="ask"><div class="q">🤔 Predict: ${A.q}</div><div class="row" style="margin:0"><input type="number" step="any" id="ansIn" aria-label="Your answer"><button id="ansBtn">Check</button><button id="ansShow" class="ghost">Show answer</button></div><div class="fb" id="ansFb"></div></div>`;
const check = (reveal) => { const v = parseFloat($("ansIn").value), ok = !reveal && Math.abs(v - A.a) <= A.tol;
$("ansFb").className = "fb " + (ok ? "good" : reveal ? "" : "no");
$("ansFb").innerHTML = (ok ? "✓ Right." : reveal ? `Answer: <b>${A.a}</b>.` : isNaN(v) ? "Type a number first." : `Not quite. Try again, or show the answer.`) + (ok || reveal ? `<span class="why">${A.why}</span>` : "");
if (ok || reveal) { DONE[S.tab + i] = 1; store.set("done", DONE); syncTabProgress(); if (A.after) applySetup(A.after); } };
$("ansBtn").onclick = () => check(false); $("ansShow").onclick = () => check(true);
$("ansIn").addEventListener("keydown", (e) => { if (e.key === "Enter") check(false); });
} else {
box.innerHTML = `<div class="ask"><div class="q">🤔 ${A.q}</div><div class="opts">${A.o.map((o, k) => `<button data-k="${k}">${o}</button>`).join("")}</div><div class="fb" id="ansFb"></div></div>`;
box.querySelectorAll(".opts button").forEach((b) => b.addEventListener("click", () => {
const k = +b.dataset.k, ok = k === A.a; b.classList.add(ok ? "right" : "wrong");
$("ansFb").className = "fb " + (ok ? "good" : "no");
$("ansFb").innerHTML = ok ? `✓ Right.<span class="why">${A.why}</span>` : "Not that one. Try another.";
if (ok) { DONE[S.tab + i] = 1; store.set("done", DONE); syncTabProgress(); if (A.after) applySetup(A.after); } }));
}
if (apply) applySetup(stepSetup(S.tab, i));
}
$("lsPrev").onclick = () => { LS[S.tab] = Math.max(0, LS[S.tab] - 1); store.set("ls", LS); renderLesson(true); };
const TABS = ["fk", "ik", "jac", "fb", "r3"];
$("lsNext").onclick = () => {
const n = L[S.tab].length, i = LS[S.tab];
if (!L[S.tab][i].ask) { DONE[S.tab + i] = 1; store.set("done", DONE); }
if (i < n - 1) { LS[S.tab]++; store.set("ls", LS); renderLesson(true); return; }
const next = TABS[TABS.indexOf(S.tab) + 1];
if (next) { toast("Tab complete. On to " + { ik: "inverse kinematics", jac: "the Jacobian", fb: "feedback control", r3: "the real arm, in 3D" }[next] + "."); setTab(next); }
else toast("That's all five tabs. The free-play experiments below go further.");
};
$("lsSetup").onclick = () => applySetup(stepSetup(S.tab, LS[S.tab]));
// ================= loop =================
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (S.tab === "r3") { requestAnimationFrame(frame); if (A3.ready) Arm3D.frame(now); return; } // ask for the next frame first: an error in tab 5 must not stop the loop
if (S.anim) { const u = clamp((now - S.anim.t0) / S.anim.dur, 0, 1), e = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
S.q = [0, 1].map((k) => wrap(S.anim.from[k] + wrap(S.anim.to[k] - S.anim.from[k]) * e)); if (u >= 1) { S.q = S.anim.to.slice(); S.anim = null; } syncSliders(); }
if (S.tab === "jac") {
const kx = (S.keys.right ? 1 : 0) - (S.keys.left ? 1 : 0), ky = (S.keys.up ? 1 : 0) - (S.keys.down ? 1 : 0), tele = kx || ky;
if (tele) S.pdes = [kx * 0.45, ky * 0.45];
if (S.runJ || tele) {
const J = jac(S.q), P = S.inv === "plain" ? inv2(J) : dampedPinv(J, S.lam);
if (!P) { S.runJ = false; syncRunJ(); toast("J is singular: the plain inverse doesn't exist here."); }
else { const qd = mv(P, S.pdes), big = Math.max(Math.abs(qd[0]), Math.abs(qd[1]));
if (big > 60) { S.runJ = false; syncRunJ(); toast(`Stopped: the plain inverse asked for ${big.toFixed(0)} rad/s.`); }
else { S.q = [wrap(S.q[0] + qd[0] * dt), wrap(S.q[1] + qd[1] * dt)]; syncSliders(); } }
}
}
if (S.tab === "ik" && S.iter && !S.iter.done) {
S.iter.acc += dt; while (S.iter.acc > 1 / 30 && !S.iter.done) { S.iter.acc -= 1 / 30;
const r = ikStep(S.q, S.target); S.q = r.q; S.iter.err = r.err; S.iter.k++; S.iter.path.push(S.q.slice()); S.iter.hand.push(fk(S.q).h);
if (r.err < 1e-3 || S.iter.k >= 150) S.iter.done = true; }
syncSliders();
}
if (S.tab === "fb" && S.fb.running) stepFB(dt);
drawTask(); drawCS(); renderMath();
if (S.tab === "fb") { drawPlot(); drawBars(); }
requestAnimationFrame(frame);
}
// ================= UI wiring =================
function syncSliders() { $("t1").value = Math.round(deg(S.q[0])); $("t2").value = Math.round(deg(S.q[1])); $("t1o").textContent = fd(S.q[0]); $("t2o").textContent = fd(S.q[1]); }
function outputs() {
$("l1o").textContent = S.l1.toFixed(2); $("l2o").textContent = S.l2.toFixed(2); $("limo").textContent = S.lim + "°"; $("lamo").textContent = S.lam.toFixed(2);
const F = S.fb; $("kpo").textContent = F.kp.toFixed(1); $("kdo").textContent = F.kd.toFixed(2); $("rateo").textContent = F.rate; $("lato").textContent = F.lat;
$("merro").textContent = (F.merr > 0 ? "+" : "") + F.merr + "%"; $("qmaxo").textContent = F.qmax.toFixed(1); syncSliders();
}
// tab 5 lives in arm3d.js, fetched the first time the tab is opened. A plain script tag, so it also works from a downloaded copy.
const A3 = { ready: false, loading: false, queue: [], hash: null,
load() {
if (this.ready || this.loading) return; this.loading = true; $("math").textContent = "Loading the 3D arm…";
const el = document.createElement("script"); el.src = "arm3d.js";
el.onload = () => { this.loading = false;
try { Arm3D.init({ css, toast }); for (const st of this.queue) Arm3D.apply(st, false); this.queue = [];
if (this.hash) { Arm3D.readParams(this.hash); this.hash = null; } this.ready = true; }
catch (err) { $("math").textContent = "The 3D tab hit an error while starting: " + err.message; } };
el.onerror = () => { this.loading = false; $("math").textContent = "Couldn't load arm3d.js.\nIf you saved this page to open it offline, save arm3d.js into the same folder."; toast("The 3D tab's file (arm3d.js) didn't load."); };
document.head.appendChild(el);
},
apply(s, animate) { if (this.ready) Arm3D.apply(s, animate); else { this.queue.push(s); this.load(); } },
};
const CS_NOTE = { r3: "", fk: "", ik: "rings: the two IK solutions of the target", jac: "red field: |det J| shrinking toward a singularity", fb: "dashed rings: IK solutions of the moving target" };
function syncTabProgress() {
document.querySelectorAll("nav.tabs button").forEach((b) => { const tab = b.dataset.tab, n = L[tab].length; let done = 0; for (let k = 0; k < n; k++) if (DONE[tab + k]) done++;
b.querySelector(".prog i").style.transform = `scaleX(${done / n})`; b.title = `${done} of ${n} steps done`; });
}
function setTab(tab, apply = true) {
S.tab = tab; document.querySelectorAll("nav.tabs button").forEach((b) => { b.setAttribute("aria-selected", b.dataset.tab === tab); b.tabIndex = b.dataset.tab === tab ? 0 : -1; });
$("csNote").textContent = CS_NOTE[tab]; S.fkTrail = [];
document.querySelectorAll("[data-panel]").forEach((el) => el.classList.toggle("hidden", !el.dataset.panel.split(" ").includes(tab)));
S.runJ = false; syncRunJ(); S.iter = null; S.hoverQ = null; S.keys = {}; S.wiggle = null; syncBasinsUI();
renderEq(); renderSyms(); renderLesson(apply); if (tab === "fb") resetFB();
if (tab === "r3") { if (A3.ready) Arm3D.invalidate(); else A3.load(); }
store.set("tab", tab);
}
function syncRunJ() { $("runJ").textContent = S.runJ ? "⏸ Stop" : "▶ Run diff-IK"; }
function syncBasinsUI() { $("basinWrap").classList.toggle("hidden", S.tab !== "ik"); $("basinLegend").classList.toggle("hidden", !(S.tab === "ik" && S.basins)); }
let toastT = 0; function toast(msg) { const el = $("toast"); el.textContent = msg; el.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove("show"), 2600); }
document.querySelectorAll("nav.tabs button").forEach((b) => b.addEventListener("click", () => setTab(b.dataset.tab)));
document.querySelector("nav.tabs").addEventListener("keydown", (e) => { // roving tabs: ← → between topics
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!d) return;
e.preventDefault(); e.stopPropagation(); const next = TABS[(TABS.indexOf(S.tab) + d + TABS.length) % TABS.length];
setTab(next); document.querySelector(`nav.tabs button[data-tab=${next}]`).focus();
});
$("floor").addEventListener("change", (e) => { S.floor = e.target.checked; rebuildWorld(); });
$("shelf").addEventListener("change", (e) => { S.shelf = e.target.checked; rebuildWorld(); });
$("reach").addEventListener("change", (e) => { S.showReach = e.target.checked; });
$("basins").addEventListener("change", (e) => { S.basins = e.target.checked; syncBasinsUI(); });
for (const k of ["l1", "l2", "lim"]) $(k).addEventListener("input", (e) => { S[k] = +e.target.value; rebuildWorld(); outputs(); });
$("t1").addEventListener("input", (e) => { S.anim = null; S.q[0] = rad(+e.target.value); syncSliders(); });
$("t2").addEventListener("input", (e) => { S.anim = null; S.q[1] = rad(+e.target.value); syncSliders(); });
$("lam").addEventListener("input", (e) => { S.lam = +e.target.value; outputs(); });
document.querySelectorAll("input[name=inv]").forEach((r) => r.addEventListener("change", (e) => { S.inv = e.target.value; }));
$("useA").onclick = () => { const s = ik(S.target).sols; if (s[0]) S.anim = { from: S.q.slice(), to: s[0].slice(), t0: performance.now(), dur: 600 }; else toast("Out of reach: no exact solution."); };
$("useB").onclick = () => { const s = ik(S.target).sols; if (s[1]) S.anim = { from: S.q.slice(), to: s[1].slice(), t0: performance.now(), dur: 600 }; else toast("Out of reach: no exact solution."); };
$("solve").onclick = () => { S.iter = { k: 0, err: 0, done: false, acc: 0, path: [S.q.slice()], hand: [fk(S.q).h] }; };
$("wig1").onclick = () => { S.wiggle = { j: 0, t0: performance.now() }; };
$("wig2").onclick = () => { S.wiggle = { j: 1, t0: performance.now() }; };
$("runJ").onclick = () => { S.runJ = !S.runJ; syncRunJ(); };
$("resetJ").onclick = () => { S.anim = { from: S.q.slice(), to: [rad(30), rad(60)], t0: performance.now(), dur: 600 }; S.runJ = false; syncRunJ(); };
$("runF").onclick = () => { S.fb.running = !S.fb.running; $("runF").textContent = S.fb.running ? "⏸ Pause" : "▶ Run"; };
$("resetF").onclick = resetFB;
$("path").addEventListener("change", (e) => { S.fb.path = e.target.value; resetFB(); });
for (const k of ["kp", "kd", "rate", "lat", "merr", "qmax"]) $(k).addEventListener("input", (e) => { S.fb[k] = +e.target.value; outputs(); if (k === "rate") S.fb.nextCtl = S.fb.t; });
$("ff").addEventListener("change", (e) => { S.fb.ff = e.target.checked; });
function syncInertiaUI() { $("kdRow").classList.toggle("hidden", !S.fb.inertia); }
$("inertia").addEventListener("change", (e) => { S.fb.inertia = e.target.checked; syncInertiaUI(); resetFB(); });
$("theme").onclick = () => { const r = document.documentElement, dark = r.dataset.theme ? r.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
r.dataset.theme = dark ? "light" : "dark"; store.set("theme", r.dataset.theme); rebuildWorld(); };
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", rebuildWorld);
// teleop keys + pad
const KEYMAP = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", w: "up", s: "down", a: "left", d: "right" };
window.addEventListener("keydown", (e) => { if (S.tab !== "jac" || /INPUT|SELECT|TEXTAREA/.test(document.activeElement?.tagName || "") && document.activeElement.type !== "radio" && document.activeElement.type !== "checkbox") return;
const k = KEYMAP[e.key] || KEYMAP[e.key.toLowerCase()]; if (k) { S.keys[k] = true; e.preventDefault(); } });
window.addEventListener("keyup", (e) => { const k = KEYMAP[e.key] || KEYMAP[e.key.toLowerCase()]; if (k) { S.keys[k] = false; if (!Object.values(S.keys).some(Boolean)) S.pdes = [0, 0]; } });
document.querySelectorAll(".pad button").forEach((b) => {
b.addEventListener("pointerdown", (e) => { S.keys[b.dataset.k] = true; b.setPointerCapture(e.pointerId); });
const up = () => { S.keys[b.dataset.k] = false; if (!Object.values(S.keys).some(Boolean)) S.pdes = [0, 0]; };
b.addEventListener("pointerup", up); b.addEventListener("pointercancel", up);
});
// pointer: task pane
function taskPoint(ev) { const r = task.getBoundingClientRect(); return T(r.width).inv(ev.clientX - r.left, ev.clientY - r.top); }
let dragging = false;
function onTask(ev) {
const p = taskPoint(ev);
if (S.tab === "ik") { S.target = p; S.iter = null; }
else if (S.tab === "jac") { const h = fk(S.q).h; S.pdes = [(p[0] - h[0]) / 1.2, (p[1] - h[1]) / 1.2]; }
else if (S.tab === "fb" && S.fb.path === "drag") S.fb.drag = p;
}
task.addEventListener("pointerdown", (e) => { dragging = true; task.setPointerCapture(e.pointerId); onTask(e); });
task.addEventListener("pointermove", (e) => { if (dragging) onTask(e); });
task.addEventListener("pointerup", () => { dragging = false; });
// pointer: C-space pane (hover preview + click/drag to set)
let csDrag = false;
function csQ(ev) { const r = cs.getBoundingClientRect(); return [rad(-180 + (ev.clientX - r.left) / r.width * 360), rad(180 - (ev.clientY - r.top) / r.height * 360)].map(wrap); }
function showTip(ev, q) {
const tip = $("tip"), card = $("csCard").getBoundingClientRect(), h = fk(q).h, st = status(q);
tip.textContent = `(${fd(q[0])}, ${fd(q[1])}) → hand (${f2(h[0])}, ${f2(h[1])})${st ? " · " + STATUS_TXT[st] : ""}`;
tip.classList.remove("hidden"); const x = ev.clientX - card.left + 14, y = ev.clientY - card.top + 14;
tip.style.left = Math.min(x, card.width - tip.offsetWidth - 8) + "px"; tip.style.top = y + "px";
}
cs.addEventListener("pointerdown", (e) => { if (S.tab === "fb") return; csDrag = true; cs.setPointerCapture(e.pointerId); S.anim = null; S.iter = null; S.q = csQ(e); syncSliders(); });
cs.addEventListener("pointermove", (e) => { const q = csQ(e); if (csDrag) { S.q = q; syncSliders(); } if (S.tab !== "fb" && e.pointerType === "mouse") { S.hoverQ = q; showTip(e, q); } });
cs.addEventListener("pointerup", () => { csDrag = false; });
cs.addEventListener("pointerleave", () => { S.hoverQ = null; $("tip").classList.add("hidden"); });
window.addEventListener("resize", () => { reachImg = null; });
// deep links: #ik&floor=1&shelf=1&t1=30&t2=60&tx=1&ty=1&step=3 ...
function readHash() {
const parts = (location.hash || "").replace(/^#/, "").split("&").filter(Boolean), out = { tab: null, s: {} };
for (const p of parts) { const [k, v] = p.split("="); if (v == null) { if (["fk", "ik", "jac", "fb", "r3"].includes(k)) out.tab = k; continue; }
const n = parseFloat(v);
if (["floor", "shelf", "basins", "ff", "inertia"].includes(k)) out.s[k] = n; else if (["l1", "l2", "lim", "kp", "kd", "rate", "lat", "merr", "qmax", "lam"].includes(k)) out.s[k] = n;
else if (k === "path") out.s.path = v; else if (k === "solve") out.solve = n || 1; else if (k === "iter") out.iter = n; else if (k === "theme") out.theme = v; else if (k === "warm") out.warm = n; else if (k === "export") out.export = v;
else if (k === "x2" || k === "y2") { out.s.target2 = out.s.target2 || [0, 0]; out.s.target2[k === "x2" ? 0 : 1] = n; }
else if (k === "px" || k === "py") { out.s.pdes = out.s.pdes || [0.1, 0]; out.s.pdes[k === "px" ? 0 : 1] = n; } else if (k === "inv") out.s.inv = v; else if (k === "step") out.step = n - 1;
else if (k === "t1" || k === "t2") { out.s.q = out.s.q || [30, 60]; out.s.q[k === "t1" ? 0 : 1] = n; }
else if (k === "tx" || k === "ty") { out.s.target = out.s.target || [0.866, 1.5]; out.s.target[k === "tx" ? 0 : 1] = n; } }
return out;
}
$("copy").onclick = async () => {
if (S.tab === "r3") { // tab 5: pose, free joints, camera and toggles
const url = location.href.split("#")[0] + "#" + ["r3", `step=${LS.r3 + 1}`, ...(A3.ready ? Arm3D.linkParams() : [])].join("&");
try { await navigator.clipboard.writeText(url); toast("Link copied"); } catch (e) { history.replaceState(null, "", url); toast("Link is in the address bar"); }
return;
}
const F = S.fb, parts = [S.tab, `step=${LS[S.tab] + 1}`, `t1=${Math.round(deg(S.q[0]))}`, `t2=${Math.round(deg(S.q[1]))}`, `l1=${S.l1}`, `l2=${S.l2}`];
if (S.floor) parts.push("floor=1"); if (S.shelf) parts.push("shelf=1"); if (S.lim < 180) parts.push(`lim=${S.lim}`);
if (S.tab === "ik") { parts.push(`tx=${S.target[0].toFixed(3)}`, `ty=${S.target[1].toFixed(3)}`); if (S.basins) parts.push("basins=1"); }
if (S.tab === "jac") parts.push(`inv=${S.inv}`, `lam=${S.lam}`);
if (S.tab === "fb") parts.push(`path=${F.path}`, `kp=${F.kp}`, `kd=${F.kd}`, `rate=${F.rate}`, `lat=${F.lat}`, `merr=${F.merr}`, `qmax=${F.qmax}`, `ff=${F.ff ? 1 : 0}`, `inertia=${F.inertia ? 1 : 0}`);
const url = location.href.split("#")[0] + "#" + parts.join("&");
try { await navigator.clipboard.writeText(url); toast("Link copied"); } catch (e) { history.replaceState(null, "", url); toast("Link is in the address bar"); }
};
// ================= boot =================
(function boot() {
const th = store.get("theme", null); if (th) document.documentElement.dataset.theme = th;
rebuildWorld(); outputs();
const hsh = readHash(), tab = hsh.tab || store.get("tab", "fk");
if (hsh.step != null) LS[tab] = clamp(hsh.step, 0, L[tab].length - 1);
const deep = Object.keys(hsh.s).length > 0;
if (!deep && tab !== "fb") S.q = [rad(-100), rad(160)]; // start folded: the lesson's own setup animates the arm awake
if (tab === "r3" && /[&#](q|free|cam|plane|cloud|axes|cols|triad|tg|mode|view|n)=/.test(location.hash)) A3.hash = location.hash; // applied once arm3d.js has loaded
setTab(tab, !deep); if (deep) applySetup(hsh.s, false);
if (hsh.theme === "light" || hsh.theme === "dark") { document.documentElement.dataset.theme = hsh.theme; rebuildWorld(); }
if (["task", "cs", "both"].includes(hsh.export)) { S.export = hsh.export; document.body.classList.add("export", "export-" + hsh.export); reachImg = null; }
if ((hsh.solve === 2 || hsh.iter != null) && tab === "ik") { // instant: run the iterative solve now, optionally only N steps (screenshots, animation frames)
const max = hsh.iter != null ? hsh.iter : 150;
S.iter = { k: 0, err: 0, done: false, acc: 0, path: [S.q.slice()], hand: [fk(S.q).h] };
while (!S.iter.done && S.iter.k < max) { const r = ikStep(S.q, S.target); S.q = r.q; S.iter.err = r.err; S.iter.k++; S.iter.path.push(S.q.slice()); S.iter.hand.push(fk(S.q).h); if (r.err < 1e-3 || S.iter.k >= 150) S.iter.done = true; }
if (hsh.iter != null && !S.iter.done) S.iter.done = true; // freeze the frame
syncSliders();
} else if (hsh.solve && tab === "ik") setTimeout(() => $("solve").click(), 300);
if (hsh.warm && tab === "fb") { for (let i = 0; i < hsh.warm / 0.05; i++) stepFB(0.05); } // pre-run the sim (screenshots)
let tries = 0; const whenKatex = () => { if (window.katex) renderEq(); else if (tries++ < 40) setTimeout(whenKatex, 150); }; whenKatex();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>