Skip to content

Commit 1b21f01

Browse files
salim4nclaude
andcommitted
feat(demo-drone-navigation): quadcopter learns to fly to moving targets
New hero demo — the first IgnitionAI env with real rigid-body physics. A quadcopter with mass, gravity, drag, torque from asymmetric thrust, and 4 rotors. 8 discrete action combos (hover low/high, forward/back, left/right, yaw) that DQN can learn without needing continuous PPO. Target navigation task: - A target sphere spawns at a random point in a 6×4×6 m volume - When the drone center reaches within 0.4 m, +50 reward and new target - Crash on ground contact or arena exit → -20 and reset - Dense shaping: -distance×0.1 + progressDelta×2 - |angVel|×0.01 Physics architecture: - Hand-rolled rigid body integration (semi-implicit Euler at 50 Hz) rather than @react-three/rapier. Rapier from a non-React context is painful because its hooks assume a <Physics> parent, and we need the env to be synchronously callable from @ignitionai/core's training loop. Our own integration (gravity, drag, body-frame thrust, torque, angular damping) gives us real physics semantics without the WASM weight. - Drops Rapier dependency entirely → smaller bundle (~625 KB gzip, same ballpark as the other 3D demos). - The env mutates its own THREE.Vector3/Euler fields; the R3F scene reads them in useFrame, same pattern as CartPole 3D / Car Circuit. Scene: - Drone = indigo box + 4 arms + 4 rotor discs that spin with thrust - Target = pulsing emissive sphere - Ground plane + Grid (drei) with section coloring - Chase camera with lerp smoothing - HUD showing episode / steps / captures / mode - "← IgnitionAI" back link to / matching other demos Integration: - packages/web/scripts/build-demos.mjs: adds 6th entry, built and copied to public/demos/drone-navigation/ - packages/web/components/demos.tsx: demotes Car Circuit from HERO DEMO, promotes Drone Navigation with accent #a855f7 and tech label "R3F · Physics" Local smoke test: dev server at port 3030 renders the scene, Train button starts the DQN loop, episodes increment (78 eps / 5932 steps in ~2 seconds at speed 25×), physics tumble + crash + reset cycle all working. Full web build passes with the demo embedded at /demos/drone-navigation/. Spec: specs/020-demo-drone-navigation/spec.md — 1-page P1 user story, 18 FRs, 5 SCs, architectural assumption about hand-rolled physics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ba674dc commit 1b21f01

15 files changed

Lines changed: 1129 additions & 6 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "demo-drone-navigation",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": { "dev": "vite", "build": "vite build", "test": "vitest run" },
7+
"dependencies": {
8+
"@ignitionai/backend-tfjs": "workspace:*",
9+
"@ignitionai/core": "workspace:*",
10+
"@react-three/drei": "^10.0.6",
11+
"@react-three/fiber": "^9.1.2",
12+
"three": "^0.162.0",
13+
"react": "^19.0.0",
14+
"react-dom": "^19.0.0",
15+
"recharts": "^2.15.3",
16+
"zustand": "^5.0.3"
17+
},
18+
"devDependencies": {
19+
"@types/react": "19.1.2",
20+
"@types/react-dom": "19.1.2",
21+
"@types/three": "^0.162.0",
22+
"@vitejs/plugin-react": "^4.3.4",
23+
"typescript": "~5.7.2",
24+
"vite": "^6.3.1",
25+
"vitest": "^3.1.1"
26+
}
27+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { useCallback, useEffect, useRef } from 'react'
2+
import { IgnitionEnvTFJS } from '@ignitionai/backend-tfjs'
3+
import { DroneEnv } from './drone-env'
4+
import { Scene3D } from './Scene3D'
5+
import { HUD } from './HUD'
6+
import { Controls } from './Controls'
7+
import { useDemoStore } from './store'
8+
9+
export default function App() {
10+
const envRef = useRef<DroneEnv>(new DroneEnv())
11+
const trainerRef = useRef<IgnitionEnvTFJS | null>(null)
12+
13+
const setMode = useDemoStore((s) => s.setMode)
14+
const setStats = useDemoStore((s) => s.setStats)
15+
const incrementEpisode = useDemoStore((s) => s.incrementEpisode)
16+
const resetStore = useDemoStore((s) => s.reset)
17+
18+
// Sync store stats from the env on a timer so React isn't reading
19+
// the mutable env state on every render.
20+
useEffect(() => {
21+
const id = setInterval(() => {
22+
const env = envRef.current
23+
setStats({
24+
totalSteps: trainerRef.current?.stepCount ?? 0,
25+
captures: env.captures,
26+
lastReward: env.reward(),
27+
})
28+
}, 200)
29+
return () => clearInterval(id)
30+
}, [setStats])
31+
32+
// Wrap DroneEnv.reset so we can count episodes through the store
33+
useEffect(() => {
34+
const env = envRef.current
35+
const originalReset = env.reset.bind(env)
36+
env.reset = () => {
37+
originalReset()
38+
incrementEpisode()
39+
}
40+
return () => {
41+
env.reset = originalReset
42+
}
43+
}, [incrementEpisode])
44+
45+
const startTrainer = useCallback(() => {
46+
if (!trainerRef.current) {
47+
trainerRef.current = new IgnitionEnvTFJS(envRef.current)
48+
}
49+
return trainerRef.current
50+
}, [])
51+
52+
const handleTrain = useCallback(() => {
53+
const trainer = startTrainer()
54+
trainer.train('dqn')
55+
trainer.setSpeed(25)
56+
setMode('training')
57+
}, [setMode, startTrainer])
58+
59+
const handleInfer = useCallback(() => {
60+
const trainer = trainerRef.current
61+
if (!trainer) {
62+
// No trained agent yet — kick off training first so the model exists
63+
handleTrain()
64+
return
65+
}
66+
trainer.infer()
67+
setMode('inference')
68+
}, [handleTrain, setMode])
69+
70+
const handleReset = useCallback(() => {
71+
trainerRef.current?.stop()
72+
envRef.current.reset()
73+
resetStore()
74+
}, [resetStore])
75+
76+
const handleSpeedChange = useCallback((speed: number) => {
77+
trainerRef.current?.setSpeed(speed)
78+
}, [])
79+
80+
return (
81+
<div
82+
style={{
83+
minHeight: '100vh',
84+
background: '#0a0a1a',
85+
color: '#e2e8f0',
86+
fontFamily: 'system-ui, sans-serif',
87+
}}
88+
>
89+
<header
90+
style={{
91+
textAlign: 'center',
92+
padding: '16px 0 6px',
93+
position: 'relative',
94+
}}
95+
>
96+
<a
97+
href="/"
98+
style={{
99+
position: 'absolute',
100+
left: 24,
101+
top: 18,
102+
color: '#94a3b8',
103+
fontSize: 13,
104+
textDecoration: 'none',
105+
padding: '6px 12px',
106+
border: '1px solid #334155',
107+
borderRadius: 8,
108+
background: '#0f172a',
109+
}}
110+
aria-label="Back to IgnitionAI landing page"
111+
>
112+
← IgnitionAI
113+
</a>
114+
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800 }}>
115+
Ignition<span style={{ color: '#6366f1' }}>AI</span>
116+
<span style={{ fontSize: 14, fontWeight: 400, color: '#888', marginLeft: 12 }}>
117+
Drone Navigation
118+
</span>
119+
</h1>
120+
<p style={{ margin: '6px 0 0', color: '#888', fontSize: 13 }}>
121+
Rigid-body physics · 8 thrust combos · DQN learns to fly
122+
</p>
123+
</header>
124+
125+
{/* 3D Scene — hero area with HUD overlay */}
126+
<div
127+
style={{
128+
position: 'relative',
129+
height: '60vh',
130+
minHeight: 420,
131+
maxHeight: 620,
132+
margin: '0 auto',
133+
maxWidth: 1100,
134+
}}
135+
>
136+
<Scene3D env={envRef.current} />
137+
<HUD />
138+
</div>
139+
140+
<Controls
141+
onTrain={handleTrain}
142+
onInfer={handleInfer}
143+
onReset={handleReset}
144+
onSpeedChange={handleSpeedChange}
145+
/>
146+
147+
<p
148+
style={{
149+
textAlign: 'center',
150+
color: '#64748b',
151+
fontSize: 12,
152+
padding: '8px 24px 24px',
153+
maxWidth: 720,
154+
margin: '0 auto',
155+
}}
156+
>
157+
Press <strong>Train</strong> and wait ~3 minutes at 25× speed. The drone starts tumbling,
158+
then learns to hover, then chases the pulsing target. Each target captured earns a big
159+
reward and a new one spawns. Hitting the ground or flying out of the arena crashes the
160+
episode. Click <strong>Infer</strong> when it looks good to watch the trained policy play.
161+
</p>
162+
</div>
163+
)
164+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState } from 'react'
2+
3+
interface Props {
4+
onTrain: () => void
5+
onInfer: () => void
6+
onReset: () => void
7+
onSpeedChange: (speed: number) => void
8+
}
9+
10+
export function Controls({ onTrain, onInfer, onReset, onSpeedChange }: Props) {
11+
const [speed, setSpeed] = useState(25)
12+
13+
return (
14+
<div
15+
style={{
16+
display: 'flex',
17+
gap: 12,
18+
alignItems: 'center',
19+
justifyContent: 'center',
20+
padding: '12px 0',
21+
flexWrap: 'wrap',
22+
}}
23+
>
24+
<button
25+
onClick={onTrain}
26+
style={{
27+
padding: '10px 22px',
28+
background: '#22c55e',
29+
color: '#0a0a1a',
30+
border: 'none',
31+
borderRadius: 8,
32+
fontWeight: 700,
33+
fontSize: 14,
34+
cursor: 'pointer',
35+
}}
36+
>
37+
Train
38+
</button>
39+
<button
40+
onClick={onInfer}
41+
style={{
42+
padding: '10px 22px',
43+
background: '#3b82f6',
44+
color: '#0a0a1a',
45+
border: 'none',
46+
borderRadius: 8,
47+
fontWeight: 700,
48+
fontSize: 14,
49+
cursor: 'pointer',
50+
}}
51+
>
52+
Infer
53+
</button>
54+
<button
55+
onClick={onReset}
56+
style={{
57+
padding: '10px 22px',
58+
background: '#334155',
59+
color: '#e2e8f0',
60+
border: 'none',
61+
borderRadius: 8,
62+
fontWeight: 700,
63+
fontSize: 14,
64+
cursor: 'pointer',
65+
}}
66+
>
67+
Reset
68+
</button>
69+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: 12 }}>
70+
<span style={{ color: '#94a3b8', fontSize: 12 }}>Speed:</span>
71+
<input
72+
type="range"
73+
min={1}
74+
max={50}
75+
value={speed}
76+
onChange={(e) => {
77+
const v = Number(e.target.value)
78+
setSpeed(v)
79+
onSpeedChange(v)
80+
}}
81+
style={{ width: 160 }}
82+
/>
83+
<span style={{ color: '#e2e8f0', fontSize: 12, fontFamily: 'monospace', minWidth: 28 }}>
84+
{speed}×
85+
</span>
86+
</div>
87+
</div>
88+
)
89+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useDemoStore } from './store'
2+
3+
export function HUD() {
4+
const episode = useDemoStore((s) => s.episode)
5+
const totalSteps = useDemoStore((s) => s.totalSteps)
6+
const captures = useDemoStore((s) => s.captures)
7+
const mode = useDemoStore((s) => s.mode)
8+
9+
return (
10+
<div
11+
style={{
12+
position: 'absolute',
13+
top: 16,
14+
left: 16,
15+
padding: '10px 14px',
16+
background: 'rgba(15, 23, 42, 0.75)',
17+
border: '1px solid #334155',
18+
borderRadius: 8,
19+
color: '#e2e8f0',
20+
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
21+
fontSize: 12,
22+
lineHeight: 1.7,
23+
pointerEvents: 'none',
24+
backdropFilter: 'blur(6px)',
25+
}}
26+
>
27+
<div>Episode: <strong>{episode}</strong></div>
28+
<div>Steps: <strong>{totalSteps}</strong></div>
29+
<div>Captures: <strong style={{ color: '#A5B4FC' }}>{captures}</strong></div>
30+
<div>Mode: <strong style={{ color: mode === 'training' ? '#22c55e' : mode === 'inference' ? '#3b82f6' : '#888' }}>{mode.toUpperCase()}</strong></div>
31+
</div>
32+
)
33+
}

0 commit comments

Comments
 (0)