<!-- 2-link planar arm: workspace (left) and configuration space (right) -->
<div class="panels">
<div class="panel">
<div class="panel-title">Workspace</div>
<canvas id="ws" width="220" height="220"></canvas>
</div>
<div class="panel">
<div class="panel-title">Configuration Space</div>
<canvas id="cs" width="220" height="220"></canvas>
</div>
</div>
<div class="sliders">
<label>θ₁ <input id="t1" type="range" min="0" max="359" value="45"> <span id="t1val">45°</span></label>
<label>θ₂ <input id="t2" type="range" min="0" max="359" value="90"> <span id="t2val">90°</span></label>
</div>
<div class="hint-box">Drag the sliders to rotate each joint. The arm turns red when it collides with the obstacle. The green dot in the right panel marks the current (θ₁, θ₂) pair — red zone = forbidden configurations.</div>
/* Layout for the two-panel demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; background: #fff; padding: 8px; }
.panels { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.panel-title { font-size: .78rem; font-weight: 600; color: #555; letter-spacing: .02em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; background: #f4f7fa; display: block; }
.sliders { display: flex; flex-direction: column; gap: 6px; margin: 10px auto; max-width: 460px; }
label { display: flex; align-items: center; gap: 8px; font-size: .88rem; font-weight: 500; }
input[type=range] { flex: 1; accent-color: #1d3557; }
span { min-width: 3em; text-align: right; font-variant-numeric: tabular-nums; }
.hint-box { font-size: .8rem; color: #555; line-height: 1.45; margin-top: 6px; max-width: 460px; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Arm geometry: L1 = upper link length, L2 = forearm length */
const L1 = 70, L2 = 55;
const BASE = { x: 110, y: 110 };
/* Rectangular obstacle in workspace (x, y, width, height) */
const OBS = { x: 130, y: 60, w: 50, h: 40 };
const wsCanvas = document.getElementById('ws');
const csCanvas = document.getElementById('cs');
const wsCtx = wsCanvas.getContext('2d');
const csCtx = csCanvas.getContext('2d');
/* Configuration space: 220×220 pixels, axes = theta1 (x) and theta2 (y) */
const CSPACE_SIZE = 220;
let forbiddenMap = null;
function toRad(deg) { return deg * Math.PI / 180; }
/* Forward kinematics: compute elbow and tip positions from joint angles */
function forwardKinematics(t1Deg, t2Deg) {
const a1 = toRad(t1Deg);
const a2 = toRad(t1Deg + t2Deg);
const elbow = {
x: BASE.x + L1 * Math.cos(a1),
y: BASE.y - L1 * Math.sin(a1)
};
const tip = {
x: elbow.x + L2 * Math.cos(a2),
y: elbow.y - L2 * Math.sin(a2)
};
return { elbow, tip };
}
/* Liang-Barsky segment-rectangle intersection test */
function segmentIntersectsRect(ax, ay, bx, by, rx, ry, rw, rh) {
/* Liang-Barsky parametric clipping loop (p/q per edge) */
const dx = bx - ax, dy = by - ay;
let t0 = 0, t1 = 1;
const ps = [-dx, dx, -dy, dy];
const qs = [ax - rx, (rx + rw) - ax, ay - ry, (ry + rh) - ay];
for (let i = 0; i < 4; i++) {
const p = ps[i], q = qs[i];
if (Math.abs(p) < 1e-9) { if (q < 0) return false; continue; }
const r = q / p;
if (p < 0) { if (r > t1) return false; if (r > t0) t0 = r; }
else { if (r < t0) return false; if (r < t1) t1 = r; }
}
return t0 <= t1;
}
/* Point-in-rectangle helper */
function pointInRect(px, py, rx, ry, rw, rh) {
return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
}
/* Returns true if any part of the arm intersects the obstacle */
function collides(t1Deg, t2Deg) {
const { elbow, tip } = forwardKinematics(t1Deg, t2Deg);
/* Check upper link */
if (segmentIntersectsRect(BASE.x, BASE.y, elbow.x, elbow.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
/* Check forearm */
if (segmentIntersectsRect(elbow.x, elbow.y, tip.x, tip.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
/* Check joint and tip points */
if (pointInRect(elbow.x, elbow.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
if (pointInRect(tip.x, tip.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
return false;
}
/* Build (and redraw) the forbidden-zone image for the configuration-space canvas */
function buildForbiddenMap() {
const imgData = csCtx.createImageData(CSPACE_SIZE, CSPACE_SIZE);
const map = new Uint8Array(CSPACE_SIZE * CSPACE_SIZE);
for (let row = 0; row < CSPACE_SIZE; row++) {
for (let col = 0; col < CSPACE_SIZE; col++) {
const t1 = (col / CSPACE_SIZE) * 360;
const t2 = ((CSPACE_SIZE - 1 - row) / CSPACE_SIZE) * 360;
const hit = collides(t1, t2) ? 1 : 0;
map[row * CSPACE_SIZE + col] = hit;
const idx = (row * CSPACE_SIZE + col) * 4;
if (hit) {
imgData.data[idx] = 220; imgData.data[idx + 1] = 50; imgData.data[idx + 2] = 60; imgData.data[idx + 3] = 200;
} else {
imgData.data[idx] = 240; imgData.data[idx + 1] = 245; imgData.data[idx + 2] = 250; imgData.data[idx + 3] = 255;
}
}
}
csCtx.putImageData(imgData, 0, 0);
forbiddenMap = map;
/* Draw light grid lines every 90 degrees */
csCtx.save();
csCtx.strokeStyle = '#aaa';
csCtx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const p = (i / 4) * CSPACE_SIZE;
csCtx.beginPath(); csCtx.moveTo(p, 0); csCtx.lineTo(p, CSPACE_SIZE); csCtx.stroke();
csCtx.beginPath(); csCtx.moveTo(0, p); csCtx.lineTo(CSPACE_SIZE, p); csCtx.stroke();
}
csCtx.restore();
}
function drawWorkspace(t1Deg, t2Deg) {
wsCtx.clearRect(0, 0, wsCanvas.width, wsCanvas.height);
/* Draw the obstacle */
wsCtx.fillStyle = '#888';
wsCtx.fillRect(OBS.x, OBS.y, OBS.w, OBS.h);
wsCtx.strokeStyle = '#555';
wsCtx.lineWidth = 1;
wsCtx.strokeRect(OBS.x, OBS.y, OBS.w, OBS.h);
const { elbow, tip } = forwardKinematics(t1Deg, t2Deg);
const hit = collides(t1Deg, t2Deg);
/* Draw arm links (red if colliding) */
wsCtx.lineCap = 'round';
wsCtx.lineWidth = 6;
wsCtx.strokeStyle = hit ? '#e63946' : '#1d3557';
wsCtx.beginPath();
wsCtx.moveTo(BASE.x, BASE.y);
wsCtx.lineTo(elbow.x, elbow.y);
wsCtx.stroke();
wsCtx.strokeStyle = hit ? '#e63946' : '#457b9d';
wsCtx.lineWidth = 5;
wsCtx.beginPath();
wsCtx.moveTo(elbow.x, elbow.y);
wsCtx.lineTo(tip.x, tip.y);
wsCtx.stroke();
/* Draw joint circles */
wsCtx.fillStyle = '#f1faee';
wsCtx.strokeStyle = '#222';
wsCtx.lineWidth = 1.5;
[BASE, elbow].forEach(pt => {
wsCtx.beginPath();
wsCtx.arc(pt.x, pt.y, 5, 0, 2 * Math.PI);
wsCtx.fill(); wsCtx.stroke();
});
wsCtx.fillStyle = '#a8dadc';
wsCtx.beginPath();
wsCtx.arc(tip.x, tip.y, 4, 0, 2 * Math.PI);
wsCtx.fill(); wsCtx.stroke();
}
function drawCspacePoint(t1Deg, t2Deg) {
/* Redraw forbidden zones first */
buildForbiddenMap();
const cx = (t1Deg / 360) * CSPACE_SIZE;
const cy = (1 - t2Deg / 360) * CSPACE_SIZE;
const hit = collides(t1Deg, t2Deg);
csCtx.beginPath();
csCtx.arc(cx, cy, 5, 0, 2 * Math.PI);
csCtx.fillStyle = hit ? '#e63946' : '#2a9d8f';
csCtx.fill();
csCtx.strokeStyle = '#fff';
csCtx.lineWidth = 1.5;
csCtx.stroke();
/* Axis corner labels (theta1 along x, theta2 along y) */
csCtx.save();
csCtx.font = '9px system-ui, sans-serif';
csCtx.fillStyle = '#444';
csCtx.fillText('0°', 2, CSPACE_SIZE - 2);
csCtx.fillText('360°', CSPACE_SIZE - 26, CSPACE_SIZE - 2);
csCtx.fillText('360°', 2, 10);
csCtx.restore();
}
function update() {
const t1 = parseInt(document.getElementById('t1').value);
const t2 = parseInt(document.getElementById('t2').value);
document.getElementById('t1val').textContent = t1 + '°';
document.getElementById('t2val').textContent = t2 + '°';
drawWorkspace(t1, t2);
drawCspacePoint(t1, t2);
}
document.getElementById('t1').addEventListener('input', update);
document.getElementById('t2').addEventListener('input', update);
/* Initial render */
update();
<!-- 2-link planar arm: workspace (left) and configuration space (right) -->
<div class="panels">
<div class="panel">
<div class="panel-title">Workspace</div>
<canvas id="ws" width="220" height="220"></canvas>
</div>
<div class="panel">
<div class="panel-title">Configuration Space</div>
<canvas id="cs" width="220" height="220"></canvas>
</div>
</div>
<div class="sliders">
<label>θ₁ <input id="t1" type="range" min="0" max="359" value="45"> <span id="t1val">45°</span></label>
<label>θ₂ <input id="t2" type="range" min="0" max="359" value="90"> <span id="t2val">90°</span></label>
</div>
<div class="hint-box">Drag the sliders to rotate each joint. The arm turns red when it collides with the obstacle. The green dot in the right panel marks the current (θ₁, θ₂) pair — red zone = forbidden configurations.</div>
/* Layout for the two-panel demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; background: #fff; padding: 8px; }
.panels { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.panel-title { font-size: .78rem; font-weight: 600; color: #555; letter-spacing: .02em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; background: #f4f7fa; display: block; }
.sliders { display: flex; flex-direction: column; gap: 6px; margin: 10px auto; max-width: 460px; }
label { display: flex; align-items: center; gap: 8px; font-size: .88rem; font-weight: 500; }
input[type=range] { flex: 1; accent-color: #1d3557; }
span { min-width: 3em; text-align: right; font-variant-numeric: tabular-nums; }
.hint-box { font-size: .8rem; color: #555; line-height: 1.45; margin-top: 6px; max-width: 460px; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Arm geometry: L1 = upper link length, L2 = forearm length */
const L1 = 70, L2 = 55;
const BASE = { x: 110, y: 110 };
/* Rectangular obstacle in workspace (x, y, width, height) */
const OBS = { x: 130, y: 60, w: 50, h: 40 };
const wsCanvas = document.getElementById('ws');
const csCanvas = document.getElementById('cs');
const wsCtx = wsCanvas.getContext('2d');
const csCtx = csCanvas.getContext('2d');
/* Configuration space: 220×220 pixels, axes = theta1 (x) and theta2 (y) */
const CSPACE_SIZE = 220;
let forbiddenMap = null;
function toRad(deg) { return deg * Math.PI / 180; }
/* Forward kinematics: compute elbow and tip positions from joint angles */
function forwardKinematics(t1Deg, t2Deg) {
const a1 = toRad(t1Deg);
const a2 = toRad(t1Deg + t2Deg);
const elbow = {
x: BASE.x + L1 * Math.cos(a1),
y: BASE.y - L1 * Math.sin(a1)
};
const tip = {
x: elbow.x + L2 * Math.cos(a2),
y: elbow.y - L2 * Math.sin(a2)
};
return { elbow, tip };
}
/* Liang-Barsky segment-rectangle intersection test */
function segmentIntersectsRect(ax, ay, bx, by, rx, ry, rw, rh) {
/* Liang-Barsky parametric clipping loop (p/q per edge) */
const dx = bx - ax, dy = by - ay;
let t0 = 0, t1 = 1;
const ps = [-dx, dx, -dy, dy];
const qs = [ax - rx, (rx + rw) - ax, ay - ry, (ry + rh) - ay];
for (let i = 0; i < 4; i++) {
const p = ps[i], q = qs[i];
if (Math.abs(p) < 1e-9) { if (q < 0) return false; continue; }
const r = q / p;
if (p < 0) { if (r > t1) return false; if (r > t0) t0 = r; }
else { if (r < t0) return false; if (r < t1) t1 = r; }
}
return t0 <= t1;
}
/* Point-in-rectangle helper */
function pointInRect(px, py, rx, ry, rw, rh) {
return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
}
/* Returns true if any part of the arm intersects the obstacle */
function collides(t1Deg, t2Deg) {
const { elbow, tip } = forwardKinematics(t1Deg, t2Deg);
/* Check upper link */
if (segmentIntersectsRect(BASE.x, BASE.y, elbow.x, elbow.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
/* Check forearm */
if (segmentIntersectsRect(elbow.x, elbow.y, tip.x, tip.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
/* Check joint and tip points */
if (pointInRect(elbow.x, elbow.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
if (pointInRect(tip.x, tip.y, OBS.x, OBS.y, OBS.w, OBS.h)) return true;
return false;
}
/* Build (and redraw) the forbidden-zone image for the configuration-space canvas */
function buildForbiddenMap() {
const imgData = csCtx.createImageData(CSPACE_SIZE, CSPACE_SIZE);
const map = new Uint8Array(CSPACE_SIZE * CSPACE_SIZE);
for (let row = 0; row < CSPACE_SIZE; row++) {
for (let col = 0; col < CSPACE_SIZE; col++) {
const t1 = (col / CSPACE_SIZE) * 360;
const t2 = ((CSPACE_SIZE - 1 - row) / CSPACE_SIZE) * 360;
const hit = collides(t1, t2) ? 1 : 0;
map[row * CSPACE_SIZE + col] = hit;
const idx = (row * CSPACE_SIZE + col) * 4;
if (hit) {
imgData.data[idx] = 220; imgData.data[idx + 1] = 50; imgData.data[idx + 2] = 60; imgData.data[idx + 3] = 200;
} else {
imgData.data[idx] = 240; imgData.data[idx + 1] = 245; imgData.data[idx + 2] = 250; imgData.data[idx + 3] = 255;
}
}
}
csCtx.putImageData(imgData, 0, 0);
forbiddenMap = map;
/* Draw light grid lines every 90 degrees */
csCtx.save();
csCtx.strokeStyle = '#aaa';
csCtx.lineWidth = 0.5;
for (let i = 1; i < 4; i++) {
const p = (i / 4) * CSPACE_SIZE;
csCtx.beginPath(); csCtx.moveTo(p, 0); csCtx.lineTo(p, CSPACE_SIZE); csCtx.stroke();
csCtx.beginPath(); csCtx.moveTo(0, p); csCtx.lineTo(CSPACE_SIZE, p); csCtx.stroke();
}
csCtx.restore();
}
function drawWorkspace(t1Deg, t2Deg) {
wsCtx.clearRect(0, 0, wsCanvas.width, wsCanvas.height);
/* Draw the obstacle */
wsCtx.fillStyle = '#888';
wsCtx.fillRect(OBS.x, OBS.y, OBS.w, OBS.h);
wsCtx.strokeStyle = '#555';
wsCtx.lineWidth = 1;
wsCtx.strokeRect(OBS.x, OBS.y, OBS.w, OBS.h);
const { elbow, tip } = forwardKinematics(t1Deg, t2Deg);
const hit = collides(t1Deg, t2Deg);
/* Draw arm links (red if colliding) */
wsCtx.lineCap = 'round';
wsCtx.lineWidth = 6;
wsCtx.strokeStyle = hit ? '#e63946' : '#1d3557';
wsCtx.beginPath();
wsCtx.moveTo(BASE.x, BASE.y);
wsCtx.lineTo(elbow.x, elbow.y);
wsCtx.stroke();
wsCtx.strokeStyle = hit ? '#e63946' : '#457b9d';
wsCtx.lineWidth = 5;
wsCtx.beginPath();
wsCtx.moveTo(elbow.x, elbow.y);
wsCtx.lineTo(tip.x, tip.y);
wsCtx.stroke();
/* Draw joint circles */
wsCtx.fillStyle = '#f1faee';
wsCtx.strokeStyle = '#222';
wsCtx.lineWidth = 1.5;
[BASE, elbow].forEach(pt => {
wsCtx.beginPath();
wsCtx.arc(pt.x, pt.y, 5, 0, 2 * Math.PI);
wsCtx.fill(); wsCtx.stroke();
});
wsCtx.fillStyle = '#a8dadc';
wsCtx.beginPath();
wsCtx.arc(tip.x, tip.y, 4, 0, 2 * Math.PI);
wsCtx.fill(); wsCtx.stroke();
}
function drawCspacePoint(t1Deg, t2Deg) {
/* Redraw forbidden zones first */
buildForbiddenMap();
const cx = (t1Deg / 360) * CSPACE_SIZE;
const cy = (1 - t2Deg / 360) * CSPACE_SIZE;
const hit = collides(t1Deg, t2Deg);
csCtx.beginPath();
csCtx.arc(cx, cy, 5, 0, 2 * Math.PI);
csCtx.fillStyle = hit ? '#e63946' : '#2a9d8f';
csCtx.fill();
csCtx.strokeStyle = '#fff';
csCtx.lineWidth = 1.5;
csCtx.stroke();
/* Axis corner labels (theta1 along x, theta2 along y) */
csCtx.save();
csCtx.font = '9px system-ui, sans-serif';
csCtx.fillStyle = '#444';
csCtx.fillText('0°', 2, CSPACE_SIZE - 2);
csCtx.fillText('360°', CSPACE_SIZE - 26, CSPACE_SIZE - 2);
csCtx.fillText('360°', 2, 10);
csCtx.restore();
}
function update() {
const t1 = parseInt(document.getElementById('t1').value);
const t2 = parseInt(document.getElementById('t2').value);
document.getElementById('t1val').textContent = t1 + '°';
document.getElementById('t2val').textContent = t2 + '°';
drawWorkspace(t1, t2);
drawCspacePoint(t1, t2);
}
document.getElementById('t1').addEventListener('input', update);
document.getElementById('t2').addEventListener('input', update);
/* Initial render */
update();
Notice that a straight path between two points in configuration space may cut through the red zone even when both endpoints look fine. That is why path planning cannot simply interpolate joint angles: it must find a route that stays entirely in the white region.
Comments
Loading comments...