The virtual pendulum lab: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: pendulum (line 24), live (line 149), assignment (line 215), student (line 222), compare (line 231).
1/// A worked example small enough that changing one number visibly changes
2/// how a run unfolds, and the digest that tells an instructor exactly where
3/// a student's run first parted from the assignment.
4///
5/// **`flutter3d_lab` is not a dependency of this app.** Its real
6/// `PendulumSimulation` is nineteen lines of semi-implicit Euler over
7/// `Portable.sin`; this page reimplements exactly that formula by hand,
8/// against the same `Portable` this app already depends on through
9/// `flutter3d_sim`, and uses the real `DigestTrace` to compare two runs —
10/// the verification mechanism is genuine, only the tiny pendulum formula is
11/// not imported.
12///
13/// Quoted by `pendulum_lab.md` and shown whole in the Source tab.
14library;
16import 'dart:math' as math;
18import 'package:flutter3d/flutter3d.dart';
19import 'package:flutter3d_showcase/src/demo/demo.dart';
20import 'package:flutter3d_showcase/src/demo/scene_kit.dart';
21import 'package:flutter3d_sim/flutter3d_sim.dart';
22import 'package:vector_math/vector_math.dart';
24/// The lab's own worked example: semi-implicit Euler over a gravity
25/// pendulum, the same order of integrator the rest of the engine steps
26/// with.
27final class _Pendulum {
28 _Pendulum({required this.lengthMeters, double startAngle = 0.6})
29 : theta = startAngle,
30 omega = 0.0;
32 static const double _gravity = 9.81;
34 double lengthMeters;
35 double theta;
36 double omega;
38 void step(double dt) {
39 final alpha = -(_gravity / lengthMeters) * Portable.sin(theta);
40 omega += alpha * dt;
41 theta += omega * dt;
42 }
44 Map<String, Object?> get state => <String, Object?>{
45 'theta': theta,
46 'omega': omega,
47 };
48}
50final class PendulumLabDemo extends ShowcaseDemo {
51 late final String _report;
53 double changeAt = 100.0;
54 double newLength = 1.5;
56 static const int _steps = 240;
57 static const int _every = 40;
59 late final List<MeshNode> _rods;
60 late final List<MeshNode> _bobs;
61 late final List<MeshNode> _lamps;
62 bool _restart = true;
63 int _step = 0;
64 double _owed = 0.0;
65 double _pause = 0.0;
66 _Pendulum _assignmentLive = _Pendulum(lengthMeters: 1.0);
67 _Pendulum _studentLive = _Pendulum(lengthMeters: 1.0);
68 DigestTrace _assignmentTrace = DigestTrace(every: _every);
69 DigestTrace _studentTrace = DigestTrace(every: _every);
71 static const double _pivotY = 3.4;
72 static const List<double> _pivotX = <double>[-2.0, 2.0];
75 void configureView(DemoContext context) {
76 context.orbit
77 ..distance = 10.0
78 ..pitch = 0.1
79 ..yaw = 0.0;
80 context.orbit.target.setValues(0.0, 1.6, 0.0);
81 }
84 Scene build(DemoContext context) {
85 _report = _run();
86 final List<Vector4> inks = <Vector4>[
87 Vector4(0.45, 0.8, 0.5, 1.0),
88 Vector4(0.9, 0.6, 0.3, 1.0),
89 ];
90 _rods = <MeshNode>[
91 for (var i = 0; i < 2; i++)
92 blockNode(context, 'rod $i', Vector3(0.06, 1.0, 0.06), inks[i]),
93 ];
94 _bobs = <MeshNode>[
95 for (var i = 0; i < 2; i++) ballNode(context, 'bob $i', 0.28, inks[i]),
96 ];
97 _lamps = <MeshNode>[
98 for (var i = 0; i < _steps ~/ _every; i++)
99 ballNode(
100 context,
101 'lamp $i',
102 0.16,
103 Vector4(0.3, 0.3, 0.33, 1.0),
104 at: Vector3(-1.5 + i * 0.6, 0.2, 1.0),
105 ),
106 ];
107 return sceneOf(<SceneNode>[
108 floorNode(context, width: 9.0, depth: 4.0),
109 for (final double x in _pivotX)
110 blockNode(
111 context,
112 'pivot',
113 Vector3(0.3, 0.12, 0.3),
114 Vector4(0.2, 0.2, 0.24, 1.0),
115 at: Vector3(x, _pivotY + 0.05, 0.0),
116 ),
117 ..._rods,
118 ..._bobs,
119 ..._lamps,
120 ]);
121 }
123 void _begin() {
124 _restart = false;
125 _step = 0;
126 _owed = 0.0;
127 _pause = 0.0;
128 _assignmentLive = _Pendulum(lengthMeters: 1.0);
129 _studentLive = _Pendulum(lengthMeters: 1.0);
130 _assignmentTrace = DigestTrace(every: _every);
131 _studentTrace = DigestTrace(every: _every);
132 for (final MeshNode lamp in _lamps) {
133 lamp.material.baseColor.setValues(0.3, 0.3, 0.33, 1.0);
134 }
135 }
138 void update(DemoContext context, double dt) {
139 if (_restart) _begin();
140 if (_step >= _steps) {
141 _pause += dt;
142 if (_pause > 2.0) _restart = true;
143 } else {
144 // Two thirds of real time, so a swing is slow enough to follow.
145 _owed += dt * 60.0 * 0.66;
146 while (_owed >= 1.0 && _step < _steps) {
147 _owed -= 1.0;
148 _step++;
149 // The same two runs as above, a step at a time: one checkpoint every
150 // `_every` steps, and the student's length changes when the slider
151 // says.
152 _assignmentLive.step(1 / 60);
153 _assignmentTrace.observe(_step, _assignmentLive.state);
154 if (_step == changeAt.round()) _studentLive.lengthMeters = newLength;
155 _studentLive.step(1 / 60);
156 _studentTrace.observe(_step, _studentLive.state);
157 if (_step % _every == 0) {
158 final int where = _step ~/ _every - 1;
159 final Divergence? parted = _studentTrace.divergenceFrom(
160 _assignmentTrace.digests,
161 );
162 final bool bad = parted != null && _step >= parted.step;
163 _lamps[where].material.baseColor.setValues(
164 bad ? 0.9 : 0.35,
165 bad ? 0.3 : 0.85,
166 bad ? 0.3 : 0.4,
167 1.0,
168 );
169 }
170 }
171 }
172 _place(0, _assignmentLive);
173 _place(1, _studentLive);
174 }
176 void _place(int i, _Pendulum p) {
177 final double x = _pivotX[i];
178 final double length = p.lengthMeters * 2.0;
179 final double dx = length * math.sin(p.theta);
180 final double dy = -length * math.cos(p.theta);
181 _bobs[i].setPosition(x + dx, _pivotY + dy, 0.0);
182 _rods[i]
183 ..setScale(1.0, length, 1.0)
184 ..setRotation(Quaternion.axisAngle(Vector3(0.0, 0.0, 1.0), p.theta))
185 ..setPosition(x + dx / 2, _pivotY + dy / 2, 0.0);
186 }
189 List<DemoControl> controls(DemoContext context) => <DemoControl>[
190 SliderControl(
191 'Length changes at step',
192 min: 40,
193 max: 200,
194 value: () => changeAt,
195 onChanged: (double v) {
196 changeAt = v.roundToDouble();
197 _restart = true;
198 },
199 format: (double v) => v.round().toString(),
200 ),
201 SliderControl(
202 'New length',
203 min: 0.6,
204 max: 2.0,
205 value: () => newLength,
206 onChanged: (double v) {
207 newLength = v;
208 _restart = true;
209 },
210 format: (double v) => '${v.toStringAsFixed(2)} m',
211 ),
212 ];
214 static String _run() {
215 final assignment = DigestTrace(every: 10);
216 final assignmentPendulum = _Pendulum(lengthMeters: 1.0);
217 for (var step = 1; step <= 60; step++) {
218 assignmentPendulum.step(1 / 60);
219 assignment.observe(step, assignmentPendulum.state);
220 }
222 // A student who changed the length partway through the run.
223 final studentPendulum = _Pendulum(lengthMeters: 1.0);
224 final student = DigestTrace(every: 10);
225 for (var step = 1; step <= 60; step++) {
226 if (step == 30) studentPendulum.lengthMeters = 1.3;
227 studentPendulum.step(1 / 60);
228 student.observe(step, studentPendulum.state);
229 }
231 final where = student.divergenceFrom(assignment.digests);
233 return 'the assignment and an unchanged replay agree at every '
234 'checkpoint\n'
235 'a student who changed the length at step 30 diverges at $where';
236 }
239 void verify(Scene scene, FrameResult frame) {
240 if (frame.drawCalls < 1) {
241 throw StateError('the pendulum bob was not drawn');
242 }
243 if (!_report.contains('diverges at step 30')) {
244 throw StateError(
245 'changing the length at step 30 should be caught at '
246 'the checkpoint covering it',
247 );
248 }
249 }
250}