Linear Quadratic Regulator Control of Sliding-Base Inverted Rod, based on physics by Brian Beckman.
---
format: typebulb/v1
name: Rocket Balancer
---
**code.tsx**
```tsx
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// ROCKET PHYSICS ---
// Linearized inverted rod with LQR. With M=0, m=1, d=1, equations simplify significantly.
const G = 9.81, MAX_ANGLE = 0.4;
const K = [1, 2.06318, 25.7842, 4.50866]; // Shared LQR gains for both axes
// Scaling and timing
const PHYSICS_DT = 1 / 60;
const POSE_SCALE = 1.5;
function createPhysics(alpha = 0, beta = 0) {
// State: [x, xDot, y, yDot, alpha, alphaDot, beta, betaDot]
let s = [0, 0, 0, 0, alpha, 0, beta, 0];
const deriv = (s: number[], fx: number, fy: number) => [
s[1], -3 * G * s[4] + 4 * fx,
s[3], -3 * G * s[6] + 4 * fy,
s[5], 6 * G * s[4] - 6 * fx,
s[7], 6 * G * s[6] - 6 * fy
];
const lqr = (s: number[]): [number, number] => [
K[0] * s[0] + K[1] * s[1] + K[2] * s[4] + K[3] * s[5],
K[0] * s[2] + K[1] * s[3] + K[2] * s[6] + K[3] * s[7]
];
return {
step(dt: number, distFx = 0, distFy = 0) {
const u = (st: number[]): [number, number] => { const [cx, cy] = lqr(st); return [cx + distFx, cy + distFy]; };
const add = (a: number[], b: number[], k = 1) => a.map((v, i) => v + k * b[i]);
const k1 = deriv(s, ...u(s)), s2 = add(s, k1, dt / 2);
const k2 = deriv(s2, ...u(s2)), s3 = add(s, k2, dt / 2);
const k3 = deriv(s3, ...u(s3)), s4 = add(s, k3, dt);
const k4 = deriv(s4, ...u(s4));
s = s.map((v, i) => v + (dt / 6) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]));
// Clamp angles
const r = Math.hypot(s[4], s[6]), maxR = Math.sin(MAX_ANGLE);
if (r > maxR) { s[4] *= maxR / r; s[6] *= maxR / r; }
},
getPose() {
const [x,, y,, a,, b] = s;
// For small angles: theta ≈ sqrt(alpha^2 + beta^2), as per Beckman's paper
const theta = Math.min(Math.hypot(a, b), MAX_ANGLE);
const phi = Math.atan2(b, a);
const st = Math.sin(theta);
const ct = Math.cos(theta);
const sp = Math.sin(phi), cp = Math.cos(phi);
return { base: [x, y], tip: [x + st * cp, y + st * sp, ct] };
}
};
}
// APP ---
// Wind simulation
const WIND_STRENGTH_MULTIPLIER = 15;
const WIND_SMOOTHING = 0.08;
const flameCols = [0xffff00, 0xff8800, 0xff3300];
// Rocket dimensions
const ROCKET_RADIUS = 0.225;
const ROCKET_BODY_HEIGHT = 1.5;
const ROCKET_TOP_HEIGHT = 0.5;
const ROCKET_BASE_Y = -1;
const FIN_HEIGHT = 0.75;
const FIN_DEPTH = 0.06;
const NOSE_CONE_STRETCH = 4;
const ROCKET_GROUND_HEIGHT = 2;
const ROCKET_BODY_Y = ROCKET_BASE_Y + ROCKET_BODY_HEIGHT / 2;
const ROCKET_TOP_Y = ROCKET_BASE_Y + ROCKET_BODY_HEIGHT + ROCKET_TOP_HEIGHT / 2;
const NOSE_CONE_Y = ROCKET_BASE_Y + ROCKET_BODY_HEIGHT + ROCKET_TOP_HEIGHT;
// Flame and particle effects
const FLAME_COUNT = 12;
const MAX_PARTICLES = 60;
const PARTICLE_SPAWN_INTERVAL = 0.03;
const PARTICLE_MIN_LIFE = 0.6;
const PARTICLE_MAX_LIFE = 1.1;
// Wind vane position
const WIND_VANE_X = -5;
const WIND_VANE_POLE_HEIGHT = 4;
function App() {
const containerRef = useRef<HTMLDivElement>(null);
const [wind, setWind] = useState(0.5);
const windRef = useRef(wind);
const [dark, setDark] = useState(() => document.documentElement.dataset.theme === 'dark');
useEffect(() => {
const obs = new MutationObserver(() => setDark(document.documentElement.dataset.theme === 'dark'));
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
useEffect(() => { windRef.current = wind; }, [wind]);
useEffect(() => {
if (!containerRef.current) return;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 100);
camera.position.set(5, 3.33, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
containerRef.current.appendChild(renderer.domElement);
const mat = (c: number, em = 0.3) => new THREE.MeshStandardMaterial({
color: c, metalness: 0.6, roughness: 0.4, emissive: dark ? c : 0, emissiveIntensity: dark ? em : 0
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(90, 32, 32), new THREE.ShaderMaterial({
uniforms: { t: { value: new THREE.Color(dark ? 0x000510 : 0x4a90d9) }, b: { value: new THREE.Color(dark ? 0x1a3050 : 0xe0f4ff) } },
vertexShader: `varying vec3 p; void main(){p=(modelMatrix*vec4(position,1)).xyz;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1);}`,
fragmentShader: `uniform vec3 t,b;varying vec3 p;void main(){gl_FragColor=vec4(mix(b,t,smoothstep(0.,.8,normalize(p).y)),1.);}`,
side: THREE.BackSide
})));
scene.add(new THREE.AmbientLight(0xffffff, dark ? 0.3 : 0.4));
const sun = new THREE.DirectionalLight(dark ? 0x8888ff : 0xffffff, 0.8);
sun.position.set(10, 20, 10); sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -20, right: 20, top: 20, bottom: -20 });
scene.add(sun);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(50, 50), new THREE.MeshStandardMaterial({ color: dark ? 0x4a7c1a : 0x4a7c59, roughness: 0.8 }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
const grid = new THREE.GridHelper(50, 50, 0x000000, 0x333333); grid.position.y = 0.01; scene.add(grid);
if (dark) {
const pos = new Float32Array(3000);
for (let i = 0; i < 3000; i += 3) {
const r = 70 + Math.random() * 20, t = Math.random() * Math.PI / 2, p = Math.random() * Math.PI * 2;
pos[i] = r * Math.sin(t) * Math.cos(p); pos[i + 1] = r * Math.cos(t); pos[i + 2] = r * Math.sin(t) * Math.sin(p);
}
const stars = new THREE.BufferGeometry(); stars.setAttribute('position', new THREE.BufferAttribute(pos, 3));
scene.add(new THREE.Points(stars, new THREE.PointsMaterial({ color: 0xffffff, size: 0.1 })));
}
const rocket = new THREE.Group();
[[new THREE.CylinderGeometry(ROCKET_RADIUS, ROCKET_RADIUS, ROCKET_BODY_HEIGHT, 16), 0xd32f2f, ROCKET_BODY_Y],
[new THREE.CylinderGeometry(ROCKET_RADIUS, ROCKET_RADIUS, ROCKET_TOP_HEIGHT, 16), 0xffffff, ROCKET_TOP_Y],
].forEach(([g, c, y]) => {
const m = new THREE.Mesh(g as THREE.BufferGeometry, mat(c as number));
m.position.y = y as number; m.castShadow = true; rocket.add(m);
});
const noseCone = new THREE.Mesh(
new THREE.SphereGeometry(ROCKET_RADIUS, 16, 16, 0, Math.PI * 2, 0, Math.PI / 2),
mat(0xffffff)
);
noseCone.position.y = NOSE_CONE_Y;
noseCone.scale.y = NOSE_CONE_STRETCH;
noseCone.castShadow = true;
rocket.add(noseCone);
const finG = new THREE.ExtrudeGeometry(new THREE.Shape().moveTo(0, 0).lineTo(ROCKET_RADIUS, 0).lineTo(0, FIN_HEIGHT), { depth: FIN_DEPTH, bevelEnabled: false });
finG.translate(0, 0, -FIN_DEPTH / 2);
for (let i = 0; i < 3; i++) {
const w = new THREE.Group(), f = new THREE.Mesh(finG, mat(0x1976d2));
f.position.set(ROCKET_RADIUS, ROCKET_BASE_Y, 0); f.castShadow = true;
w.add(f); w.rotation.y = i * Math.PI * 2 / 3; rocket.add(w);
}
const flames = new THREE.Group();
const coreFlames = Array.from({ length: FLAME_COUNT }, () => {
const a = Math.random() * Math.PI * 2, r = Math.random() * ROCKET_RADIUS;
const m = new THREE.Mesh(new THREE.ConeGeometry(0.05 + Math.random() * 0.1, 0.3 + Math.random() * 0.6, 6),
new THREE.MeshBasicMaterial({ color: flameCols[~~(Math.random() * 3)], transparent: true, opacity: 0.7 }));
m.position.set(Math.cos(a) * r, ROCKET_BASE_Y - Math.random() * 0.5, Math.sin(a) * r);
flames.add(m);
return { m, baseY: m.position.y, spd: 0.5 + Math.random() };
});
const light = new THREE.PointLight(0xff6600, 2, 4); light.position.y = ROCKET_BASE_Y - 0.2; flames.add(light);
const particles: { m: THREE.Mesh; life: number; maxLife: number; vel: THREE.Vector3 }[] = [];
rocket.add(flames); scene.add(rocket);
const vane = new THREE.Group();
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, WIND_VANE_POLE_HEIGHT, 8), mat(0x444444)); pole.position.y = WIND_VANE_POLE_HEIGHT / 2; pole.castShadow = true; vane.add(pole);
const arrow = new THREE.Group();
const am = mat(0xff6b6b);
[new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.8, 8), am), new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.3, 8), am), new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.15, 0.02), am)]
.forEach((m, i) => { m.rotation.z = [Math.PI / 2, -Math.PI / 2, 0][i]; m.position.x = [0, 0.55, -0.55][i]; arrow.add(m); });
arrow.position.y = WIND_VANE_POLE_HEIGHT; vane.add(arrow); vane.position.x = WIND_VANE_X; scene.add(vane);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.minDistance = 3; controls.maxDistance = 20; controls.maxPolarAngle = Math.PI / 2;
const physics = createPhysics(0.15, 0.1);
let t = 0, spawnTimer = 0, animId: number;
const windForce = { fx: 0, fy: 0 };
const updateWind = (dt: number) => {
const currentWind = windRef.current;
if (currentWind <= 0) {
windForce.fx *= 0.95;
windForce.fy *= 0.95;
return;
}
const strength = currentWind * WIND_STRENGTH_MULTIPLIER;
// Mix 3 frequencies: slow drift + gusts + turbulence (natural fractal-like variation)
const nx = Math.sin(t * 0.3) * 0.5 + Math.sin(t * 1.8) * 0.3 + Math.sin(t * 4.2) * 0.2;
const ny = Math.cos(t * 0.25) * 0.5 + Math.cos(t * 1.5) * 0.3 + Math.cos(t * 3.8) * 0.2;
windForce.fx += (strength * nx - windForce.fx) * WIND_SMOOTHING;
windForce.fy += (strength * ny - windForce.fy) * WIND_SMOOTHING;
};
const animate = () => {
animId = requestAnimationFrame(animate);
t += 0.01;
updateWind(1 / 60);
arrow.rotation.y = Math.atan2(windForce.fy, windForce.fx);
physics.step(PHYSICS_DT, windForce.fx, windForce.fy);
const { base: [x, y], tip: [tx, ty, tz] } = physics.getPose();
// Hover above the cart position - offset so flame are well above ground
const baseVec = new THREE.Vector3(x * POSE_SCALE, ROCKET_GROUND_HEIGHT, y * POSE_SCALE);
const tipVec = new THREE.Vector3(tx * POSE_SCALE, tz * POSE_SCALE, ty * POSE_SCALE);
tipVec.y += ROCKET_GROUND_HEIGHT; // Match the base offset
// Position at base and lean along the rod direction
rocket.position.copy(baseVec);
// Orient rocket so its +Y axis points from base to tip
const rodDir = new THREE.Vector3().subVectors(tipVec, baseVec).normalize();
rocket.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), rodDir);
coreFlames.forEach(({ m, baseY, spd }) => {
const f = t * 10 * spd;
m.scale.set(0.8 + Math.sin(f * 1.7) * 0.3, 1 + Math.sin(f) * 0.4, 0.8 + Math.sin(f * 1.7) * 0.3);
m.position.y = baseY + Math.sin(f * 1.3) * 0.1;
});
light.intensity = 2 + Math.sin(t * 15) * 0.5;
if ((spawnTimer += PHYSICS_DT) > PARTICLE_SPAWN_INTERVAL && particles.length < MAX_PARTICLES) {
spawnTimer = 0;
const a = Math.random() * Math.PI * 2, r = Math.random() * ROCKET_RADIUS * 1.67;
const m = new THREE.Mesh(new THREE.SphereGeometry(0.06 + Math.random() * 0.08, 6, 6),
new THREE.MeshBasicMaterial({ color: flameCols[~~(Math.random() * 3)], transparent: true, opacity: 0.8 }));
m.position.set(Math.cos(a) * r, ROCKET_BASE_Y - Math.random() * 0.3, Math.sin(a) * r);
flames.add(m);
particles.push({ m, life: 0, maxLife: PARTICLE_MIN_LIFE + Math.random() * (PARTICLE_MAX_LIFE - PARTICLE_MIN_LIFE), vel: new THREE.Vector3((Math.random() - 0.5) * 3, -(0.5 + Math.random()), (Math.random() - 0.5) * 3) });
}
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
if ((p.life += PHYSICS_DT) > p.maxLife) { flames.remove(p.m); particles.splice(i, 1); continue; }
const r = p.life / p.maxLife;
p.m.position.addScaledVector(p.vel, PHYSICS_DT);
(p.m.material as THREE.MeshBasicMaterial).opacity = 0.8 * (1 - r);
p.m.scale.setScalar(1 + r * 1.2);
}
controls.update();
camera.position.y = Math.max(camera.position.y, 0.5);
renderer.render(scene, camera);
};
animate();
const resize = () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); };
addEventListener("resize", resize);
return () => {
cancelAnimationFrame(animId);
removeEventListener("resize", resize);
controls.dispose();
containerRef.current?.removeChild(renderer.domElement);
renderer.dispose();
};
}, [dark]);
return (
<>
<div ref={containerRef} />
<div className="controls">
<div>
<h3 className="controls-title">Linear Quadratic Regulator Control of Sliding-Base Inverted Rod</h3>
<a href="https://community.wolfram.com/groups/-/m/t/3581351" target="_blank" rel="noopener noreferrer" className="controls-link">Explanation</a>
</div>
<label>
<span>Wind Intensity</span>
<input type="range" min="0" max="1" step="0.01" value={wind} onChange={e => setWind(+e.target.value)} />
<span className="value">{Math.round(wind * 100)}%</span>
</label>
</div>
</>
);
}
createRoot(document.getElementById("root")!).render(<App />);
```
**styles.css**
```css
:root {
--bg-panel: rgba(255, 255, 255, 0.15);
--text-primary: #ffffff;
--border-color: #ddd;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
}
#root {
width: 100vw;
height: 100vh;
}
canvas {
display: block;
}
.controls > div {
margin-bottom: 16px;
}
.controls-link {
display: block;
margin-top: 4px;
color: #2196f3;
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
.controls-link:hover {
text-decoration: underline;
}
.controls {
position: absolute;
bottom: 20px;
right: 20px;
background: transparent;
border-radius: 8px;
padding: 16px 20px;
min-width: 250px;
max-width: 300px;
}
.controls-title {
margin: 0 0 0 0;
font-size: 16px;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
color: var(--text-primary);
line-height: 1.3;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.controls label {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
.controls input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--border-color);
outline: none;
-webkit-appearance: none;
}
.controls input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #2196f3;
cursor: pointer;
}
.controls input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #2196f3;
cursor: pointer;
border: none;
}
.controls .value {
text-align: right;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
html[data-theme="dark"] {
--bg-panel: rgba(30, 30, 30, 0.15);
--text-primary: #e0e0e0;
--border-color: #444;
}
html[data-theme="dark"] .controls .value {
color: rgba(255, 255, 255, 0.7);
}
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"three": "^0.181.2"
},
"description": "Linear Quadratic Regulator Control of Sliding-Base Inverted Rod, based on physics by Brian Beckman."
}
```