simview 3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. simview/__init__.py +69 -0
  2. simview/__main__.py +174 -0
  3. simview/launcher.py +109 -0
  4. simview/live.py +204 -0
  5. simview/merge.py +593 -0
  6. simview/model.py +690 -0
  7. simview/py.typed +0 -0
  8. simview/scene.py +603 -0
  9. simview/server.py +739 -0
  10. simview/state.py +190 -0
  11. simview/static/css/controls.css +36 -0
  12. simview/static/js/SimView.js +601 -0
  13. simview/static/js/components/AnimationController.js +654 -0
  14. simview/static/js/components/BatchManager.js +198 -0
  15. simview/static/js/components/InteractionController.js +446 -0
  16. simview/static/js/components/InteractionControls.js +116 -0
  17. simview/static/js/components/Scene.js +166 -0
  18. simview/static/js/components/StateStore.js +188 -0
  19. simview/static/js/config.js +233 -0
  20. simview/static/js/main.js +3 -0
  21. simview/static/js/objects/Body.js +616 -0
  22. simview/static/js/objects/StaticObject.js +181 -0
  23. simview/static/js/objects/Terrain.js +514 -0
  24. simview/static/js/objects/utils.js +378 -0
  25. simview/static/js/ui/AnalysisPanel.js +233 -0
  26. simview/static/js/ui/BatchLegend.js +207 -0
  27. simview/static/js/ui/BodyStateWindow.js +521 -0
  28. simview/static/js/ui/Controls.js +586 -0
  29. simview/static/js/ui/ErrorMetrics.js +664 -0
  30. simview/static/js/ui/Legend.js +87 -0
  31. simview/static/js/ui/PlaybackControls.js +314 -0
  32. simview/static/js/ui/ScalarPlotter.js +575 -0
  33. simview/static/js/utils/blobCodec.js +73 -0
  34. simview/static/js/utils/bodyTransforms.js +151 -0
  35. simview/static/js/utils/csv.js +46 -0
  36. simview/static/js/utils/errorMath.js +69 -0
  37. simview/static/js/utils/injectStyles.js +16 -0
  38. simview/static/js/utils/interpolate.js +65 -0
  39. simview/static/js/utils/liveFollow.js +17 -0
  40. simview/static/js/utils/loadRecordingLibs.js +34 -0
  41. simview/static/js/utils/viewState.js +226 -0
  42. simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
  43. simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
  44. simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
  45. simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
  46. simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
  47. simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
  48. simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
  49. simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
  50. simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
  51. simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
  52. simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
  53. simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
  54. simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
  55. simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
  56. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
  57. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
  58. simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
  59. simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
  60. simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
  61. simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
  62. simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
  63. simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
  64. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
  65. simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
  66. simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
  67. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
  68. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
  69. simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
  70. simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
  71. simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
  72. simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
  73. simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
  74. simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
  75. simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
  76. simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
  77. simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
  78. simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
  79. simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
  80. simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
  81. simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
  82. simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
  83. simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
  84. simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
  85. simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
  86. simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
  87. simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
  88. simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
  89. simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
  90. simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
  91. simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
  92. simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
  93. simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
  94. simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
  95. simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
  96. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
  97. simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
  98. simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
  99. simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
  100. simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
  101. simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
  102. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
  103. simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
  104. simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
  105. simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
  106. simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
  107. simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
  108. simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
  109. simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
  110. simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
  111. simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
  112. simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
  113. simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
  114. simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
  115. simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
  116. simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
  117. simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
  118. simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
  119. simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
  120. simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
  121. simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
  122. simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
  123. simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
  124. simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
  125. simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
  126. simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
  127. simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
  128. simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
  129. simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
  130. simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
  131. simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
  132. simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
  133. simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
  134. simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
  135. simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
  136. simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
  137. simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
  138. simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
  139. simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
  140. simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
  141. simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
  142. simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
  143. simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
  144. simview/static/lib/download.js +132 -0
  145. simview/static/lib/js-colormaps.js +19335 -0
  146. simview/static/lib/tar.js +334 -0
  147. simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
  148. simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
  149. simview/static/lib/three-0.174.0/three.core.js +48830 -0
  150. simview/static/lib/three-0.174.0/three.module.js +17313 -0
  151. simview/static/lib/uPlot.esm.js +6140 -0
  152. simview/static/lib/uPlot.min.css +2 -0
  153. simview/static/textures/contacts/red-cross0.png +0 -0
  154. simview/static/textures/points/ball0.png +0 -0
  155. simview/static/textures/points/ball1.png +0 -0
  156. simview/templates/index.html +81 -0
  157. simview/utils.py +41 -0
  158. simview-3.2.dist-info/METADATA +552 -0
  159. simview-3.2.dist-info/RECORD +163 -0
  160. simview-3.2.dist-info/WHEEL +5 -0
  161. simview-3.2.dist-info/entry_points.txt +2 -0
  162. simview-3.2.dist-info/licenses/LICENSE +28 -0
  163. simview-3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,654 @@
1
+ import { PlaybackControls } from "../ui/PlaybackControls.js";
2
+ import { resolveStateBodies } from "../utils/bodyTransforms.js";
3
+ import { loadRecordingLibs } from "../utils/loadRecordingLibs.js";
4
+ import { interpolateTransformRows, lerpVectorRows } from "../utils/interpolate.js";
5
+
6
+ // Optional per-batch 3-vector attributes that get lerp'd alongside
7
+ // bodyTransform on the interpolated path. Everything else (contacts, and any
8
+ // other ragged/non-numeric field) snaps to the nearest frame instead.
9
+ const INTERPOLATED_VECTOR_ATTRS = ["velocity", "angularVelocity", "force", "torque"];
10
+
11
+ // Candidate MediaRecorder mimeTypes for the "webm" recording format, in
12
+ // preference order (VP9 first for better quality/size, VP8 as the widely
13
+ // supported fallback). Picked at runtime via MediaRecorder.isTypeSupported
14
+ // since browser support varies -- see pickSupportedMimeType().
15
+ const WEBM_MIME_CANDIDATES = [
16
+ "video/webm;codecs=vp9",
17
+ "video/webm;codecs=vp8",
18
+ "video/webm",
19
+ ];
20
+
21
+ // Some Chromium builds support recording directly to MP4 (H.264); when
22
+ // available it's exposed as an extra "mp4" option in the format dropdown
23
+ // (see PlaybackControls.js), purely as a nicety -- webm is always the
24
+ // fallback and works everywhere MediaRecorder does.
25
+ const MP4_MIME_CANDIDATES = ["video/mp4;codecs=avc1", "video/mp4"];
26
+
27
+ function pickSupportedMimeType(candidates) {
28
+ if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) {
29
+ return null;
30
+ }
31
+ return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || null;
32
+ }
33
+
34
+ // Exported so PlaybackControls can decide whether to offer "mp4" in the
35
+ // format dropdown without duplicating the MediaRecorder feature-detection.
36
+ export function isMp4RecordingSupported() {
37
+ return pickSupportedMimeType(MP4_MIME_CANDIDATES) !== null;
38
+ }
39
+
40
+ function extensionForMimeType(mimeType) {
41
+ return mimeType.startsWith("video/mp4") ? "mp4" : "webm";
42
+ }
43
+
44
+ // Triggers a browser download of `blob` named `filename` via a temporary,
45
+ // never-appended <a download> link -- no library needed for this (CCapture's
46
+ // webm path used to pull in a `download()` helper from lib/download.js; the
47
+ // PNG-sequence path below still uses that helper since tar.js/download.js
48
+ // stay vendored for it).
49
+ function downloadBlob(blob, filename) {
50
+ const url = URL.createObjectURL(blob);
51
+ const a = document.createElement("a");
52
+ a.href = url;
53
+ a.download = filename;
54
+ a.click();
55
+ // Revoke on a delay rather than immediately: some browsers kick off the
56
+ // download asynchronously, and revoking the URL too early can abort it.
57
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
58
+ }
59
+
60
+ export class AnimationController {
61
+ constructor(app, simulationTimestep) {
62
+ this.app = app;
63
+ this.playbackControls = null;
64
+ this.store = null;
65
+ this.isPlaying = false;
66
+ this.playbackSpeed = 1;
67
+ this.isRecording = false;
68
+ this.isLoadingRecorder = false;
69
+ this.startTime = null;
70
+ this.recordingFormat = "webm"; // Default recording format
71
+
72
+ // WEBM/MP4 recording state (native MediaRecorder path -- see
73
+ // startRecording/stopRecording/captureFrame below). null when not
74
+ // recording a video.
75
+ this._mediaRecorder = null;
76
+ this._captureTrack = null; // CanvasCaptureMediaStreamTrack, for requestFrame()
77
+ this._recordedChunks = null;
78
+ this._recordingMimeType = null;
79
+ this._recordingStopped = null; // Promise resolved once the MediaRecorder actually stops
80
+
81
+ // PNG-sequence recording state (tar.js-packed, see captureFrame below).
82
+ this._pngTar = null;
83
+ this._pngFrameCount = 0;
84
+ // Promises for PNG frames still being encoded/appended asynchronously
85
+ // (toBlob + arrayBuffer are both async) -- stopRecording awaits all of
86
+ // these before calling tar.save(), so the last frame(s) of the loop
87
+ // are never dropped from the archive.
88
+ this._pngPending = [];
89
+ this.currentStateIndex = 0;
90
+ this.totalTime = 0; // Total animation time
91
+ this.currentTime = 0; // Current time in the animation
92
+ this.simulationTimestep = simulationTimestep; // Simulation timestep
93
+ this.lastUpdateTime = null;
94
+
95
+ // Memoizes the two resolveStateBodies() results bracketing the
96
+ // current interpolation interval, keyed by their (lo, hi) state
97
+ // indices -- consecutive RAF ticks (or scrubs) landing in the same
98
+ // interval reuse both resolved frames instead of re-resolving.
99
+ this._resolvedCache = { lo: -1, hi: -1, frameLo: null, frameHi: null };
100
+ }
101
+
102
+ loadAnimation(store) {
103
+ if (!store || store.length === 0) {
104
+ console.warn("No states received for animation.");
105
+ return;
106
+ }
107
+ this.store = store;
108
+ this.totalTime = this.store.lastTime();
109
+
110
+ // Infer simulation timestep if not provided or invalid
111
+ if ((!this.simulationTimestep || isNaN(this.simulationTimestep)) && this.store.length > 1) {
112
+ const dt = this.store.timeAt(1) - this.store.timeAt(0);
113
+ if (dt > 0) {
114
+ console.log(`Inferred simulation timestep from states: ${dt}`);
115
+ this.simulationTimestep = dt;
116
+ } else {
117
+ console.warn("Could not infer valid timestep from states, defaulting to 1/60");
118
+ this.simulationTimestep = 1 / 60;
119
+ }
120
+ } else if (!this.simulationTimestep) {
121
+ console.warn("No timestep provided and not enough states to infer. Defaulting to 1/60");
122
+ this.simulationTimestep = 1 / 60;
123
+ }
124
+
125
+ this.playbackControls = new PlaybackControls(this);
126
+ this.goToTime(0);
127
+ }
128
+
129
+ onStatesAppended() {
130
+ if (this.store && this.store.length > 0) {
131
+ this.totalTime = this.store.lastTime();
132
+ }
133
+ if (this.playbackControls) {
134
+ this.playbackControls.forceRedraw();
135
+ }
136
+ }
137
+
138
+ play() {
139
+ if (!this.isPlaying) {
140
+ this.isPlaying = true;
141
+ this.lastUpdateTime = performance.now();
142
+ }
143
+ }
144
+
145
+ pause() {
146
+ this.isPlaying = false;
147
+ }
148
+
149
+ setSpeed(speed) {
150
+ this.playbackSpeed = speed;
151
+ }
152
+
153
+ setRecordingFormat(format) {
154
+ this.recordingFormat = format;
155
+ }
156
+
157
+ forceRedrawStaticElements() {
158
+ this.playbackControls.forceRedraw();
159
+ this.app.bodyStateWindow.forceRedraw();
160
+ }
161
+
162
+ // Binary search over store.timeAt(i) (time-ordered, but not necessarily
163
+ // uniformly spaced -- adaptive-step simulations break the old
164
+ // targetTime/simulationTimestep shortcut) for the index whose time is
165
+ // nearest targetTime.
166
+ getStateIndexForTime(targetTime) {
167
+ const store = this.store;
168
+ let lo = 0;
169
+ let hi = store.length - 1;
170
+ if (targetTime <= store.timeAt(lo)) return lo;
171
+ if (targetTime >= store.timeAt(hi)) return hi;
172
+ while (lo < hi - 1) {
173
+ const mid = (lo + hi) >> 1;
174
+ if (store.timeAt(mid) <= targetTime) {
175
+ lo = mid;
176
+ } else {
177
+ hi = mid;
178
+ }
179
+ }
180
+ // lo and hi now straddle targetTime; pick whichever is closer.
181
+ return targetTime - store.timeAt(lo) <= store.timeAt(hi) - targetTime ? lo : hi;
182
+ }
183
+
184
+ // Binary search for the bracketing pair (lo, hi = lo+1 clamped) around
185
+ // targetTime, plus alpha in [0, 1] for interpolating between them. Unlike
186
+ // getStateIndexForTime this never rounds to the nearer frame -- it always
187
+ // returns the pair straddling targetTime (or the boundary pair if
188
+ // targetTime is outside the timeline).
189
+ getBracketingIndices(targetTime) {
190
+ const store = this.store;
191
+ const last = store.length - 1;
192
+ let lo = 0;
193
+ let hi = last;
194
+ if (targetTime <= store.timeAt(lo)) {
195
+ return { lo, hi: Math.min(lo + 1, last), alpha: 0 };
196
+ }
197
+ if (targetTime >= store.timeAt(hi)) {
198
+ return { lo: Math.max(hi - 1, 0), hi, alpha: 1 };
199
+ }
200
+ while (lo < hi - 1) {
201
+ const mid = (lo + hi) >> 1;
202
+ if (store.timeAt(mid) <= targetTime) {
203
+ lo = mid;
204
+ } else {
205
+ hi = mid;
206
+ }
207
+ }
208
+ const tLo = store.timeAt(lo);
209
+ const tHi = store.timeAt(hi);
210
+ const span = tHi - tLo;
211
+ const alpha = span > 0 ? (targetTime - tLo) / span : 0; // guard duplicate timestamps
212
+ return { lo, hi, alpha };
213
+ }
214
+
215
+ seekToIndex(index) {
216
+ this.currentStateIndex = index;
217
+ this.currentTime = this.store.timeAt(index);
218
+ this.updateScene();
219
+ }
220
+
221
+ stepForward() {
222
+ if (this.isPlaying) return; // Ignore if playing
223
+ const newIndex = (this.currentStateIndex + 1) % this.store.length;
224
+ this.seekToIndex(newIndex);
225
+ this.forceRedrawStaticElements();
226
+ }
227
+
228
+ stepBackward() {
229
+ if (this.isPlaying) return; // Ignore if playing
230
+ const newIndex =
231
+ (this.currentStateIndex - 1 + this.store.length) % this.store.length;
232
+ this.seekToIndex(newIndex);
233
+ this.forceRedrawStaticElements();
234
+ }
235
+
236
+ goToTime(time) {
237
+ time = Math.min(Math.max(time, 0), this.totalTime); // Clamp out-of-bounds time
238
+ if (this.app.uiState?.smoothInterpolation) {
239
+ // Keep the exact scrubbed time (not snapped to the nearest frame) so
240
+ // the interpolated path in updateScene() can render in-between poses;
241
+ // currentStateIndex still tracks the nearest frame for index-snapped
242
+ // consumers (BodyStateWindow, ScalarPlotter, ErrorMetrics, trails).
243
+ this.currentTime = time;
244
+ this.currentStateIndex = this.getStateIndexForTime(time);
245
+ this.updateScene();
246
+ } else {
247
+ const index = this.getStateIndexForTime(time);
248
+ this.seekToIndex(index);
249
+ }
250
+ if (!this.isPlaying) {
251
+ this.forceRedrawStaticElements();
252
+ }
253
+ }
254
+
255
+ async startRecording() {
256
+ if (this.isRecording || this.isLoadingRecorder) return;
257
+
258
+ if (this.recordingFormat === "png") {
259
+ // Only the PNG-sequence path needs the lazily-loaded tar.js/download.js
260
+ // (see loadRecordingLibs.js) -- webm/mp4 recording is all native
261
+ // MediaRecorder APIs, nothing to load.
262
+ this.isLoadingRecorder = true;
263
+ try {
264
+ await loadRecordingLibs();
265
+ } finally {
266
+ this.isLoadingRecorder = false;
267
+ }
268
+ }
269
+ if (this.isRecording) return;
270
+
271
+ // Reset animation to start; the recording always captures exactly one
272
+ // full loop from time 0 (see captureFrame's auto-stop below).
273
+ this.seekToIndex(0);
274
+
275
+ if (this.recordingFormat === "png") {
276
+ this._pngTar = new Tar();
277
+ this._pngFrameCount = 0;
278
+ this._pngPending = [];
279
+ } else {
280
+ if (!this.#startVideoRecording()) return;
281
+ }
282
+
283
+ this.isRecording = true;
284
+ this.startTime = performance.now();
285
+ if (!this.isPlaying) {
286
+ this.play();
287
+ }
288
+ }
289
+
290
+ // Sets up canvas.captureStream() + MediaRecorder for the webm/mp4 format.
291
+ // Returns false (and leaves no half-initialized state behind) if the
292
+ // browser lacks the needed APIs.
293
+ #startVideoRecording() {
294
+ const canvas = this.app.scene.renderer.domElement;
295
+ if (typeof canvas.captureStream !== "function") {
296
+ console.error("This browser does not support canvas.captureStream(); cannot record video.");
297
+ return false;
298
+ }
299
+
300
+ const mimeType =
301
+ this.recordingFormat === "mp4"
302
+ ? pickSupportedMimeType(MP4_MIME_CANDIDATES)
303
+ : pickSupportedMimeType(WEBM_MIME_CANDIDATES);
304
+ if (!mimeType || typeof MediaRecorder === "undefined") {
305
+ console.error("This browser does not support MediaRecorder; cannot record video.");
306
+ return false;
307
+ }
308
+
309
+ // captureStream(0) puts the track in "manual" mode: it only produces a
310
+ // new frame when track.requestFrame() is called, which captureFrame()
311
+ // below does once per actually-rendered frame -- deterministic
312
+ // frame-for-frame capture instead of a realtime capture racing the
313
+ // simulation's own timestep. Fall back to a realtime 60fps stream if
314
+ // requestFrame isn't supported (older browsers).
315
+ const stream = canvas.captureStream(0);
316
+ const [track] = stream.getVideoTracks();
317
+ if (!track || typeof track.requestFrame !== "function") {
318
+ this._captureTrack = null;
319
+ stream.getTracks().forEach((t) => t.stop());
320
+ this._mediaRecorder = new MediaRecorder(canvas.captureStream(60), { mimeType });
321
+ } else {
322
+ this._captureTrack = track;
323
+ this._mediaRecorder = new MediaRecorder(stream, { mimeType });
324
+ }
325
+
326
+ this._recordingMimeType = mimeType;
327
+ this._recordedChunks = [];
328
+ this._mediaRecorder.ondataavailable = (event) => {
329
+ if (event.data && event.data.size > 0) {
330
+ this._recordedChunks.push(event.data);
331
+ }
332
+ };
333
+ this._recordingStopped = new Promise((resolve) => {
334
+ this._mediaRecorder.onstop = resolve;
335
+ });
336
+ this._mediaRecorder.start();
337
+ return true;
338
+ }
339
+
340
+ getTotalTime() {
341
+ return this.totalTime;
342
+ }
343
+
344
+ getCurrentTime() {
345
+ return this.currentTime;
346
+ }
347
+
348
+ getCurrentStateIndex() {
349
+ return this.currentStateIndex;
350
+ }
351
+
352
+ getCurrentState() {
353
+ return this.store.getFrame(this.currentStateIndex);
354
+ }
355
+
356
+ stopRecording() {
357
+ if (!this.isRecording) return;
358
+ this.isRecording = false;
359
+ this.startTime = null;
360
+
361
+ if (this._pngTar) {
362
+ this.#stopPngRecording();
363
+ return;
364
+ }
365
+
366
+ this.#stopVideoRecording();
367
+ }
368
+
369
+ async #stopPngRecording() {
370
+ const tar = this._pngTar;
371
+ this._pngTar = null;
372
+ this._pngFrameCount = 0;
373
+ // Wait for every in-flight toBlob()/arrayBuffer() encode to finish
374
+ // appending to the tar before saving it, so the last frame(s)
375
+ // captured before the auto-stop aren't silently dropped.
376
+ const pending = this._pngPending;
377
+ this._pngPending = [];
378
+ await Promise.all(pending);
379
+
380
+ const blob = tar.save();
381
+ download(blob, "simview-recording.tar", "application/x-tar");
382
+ }
383
+
384
+ async #stopVideoRecording() {
385
+ const recorder = this._mediaRecorder;
386
+ if (!recorder) return;
387
+ const stopped = this._recordingStopped;
388
+ if (recorder.state !== "inactive") {
389
+ recorder.stop();
390
+ }
391
+ await stopped;
392
+
393
+ const chunks = this._recordedChunks || [];
394
+ const mimeType = this._recordingMimeType || "video/webm";
395
+ const blob = new Blob(chunks, { type: mimeType });
396
+ downloadBlob(blob, `simview-recording.${extensionForMimeType(mimeType)}`);
397
+
398
+ if (this._captureTrack) {
399
+ this._captureTrack.stop();
400
+ }
401
+ this._mediaRecorder = null;
402
+ this._captureTrack = null;
403
+ this._recordedChunks = null;
404
+ this._recordingMimeType = null;
405
+ this._recordingStopped = null;
406
+ }
407
+
408
+ animate(now) {
409
+ if (!this.isPlaying || !this.store) return;
410
+ if (this.lastUpdateTime === null) {
411
+ this.lastUpdateTime = now;
412
+ }
413
+ const dt = (now - this.lastUpdateTime) / 1000;
414
+ this.lastUpdateTime = now;
415
+ this.currentTime += dt * this.playbackSpeed;
416
+ if (this.totalTime > 0) {
417
+ this.currentTime = this.currentTime % this.totalTime;
418
+ } else {
419
+ this.currentTime = 0; // Single-state scene: totalTime % would be NaN
420
+ }
421
+ const newStateIndex = this.getStateIndexForTime(this.currentTime);
422
+ const indexChanged = newStateIndex !== this.currentStateIndex;
423
+ this.currentStateIndex = newStateIndex;
424
+
425
+ if (this.app.uiState?.smoothInterpolation) {
426
+ // Interpolated path: render every tick regardless of whether the
427
+ // nearest index changed, so bodies move smoothly between states
428
+ // instead of snapping. Index-snapped consumers (playbackControls'
429
+ // frame counter, bodyStateWindow readouts) still only refresh
430
+ // when the nearest frame actually changes -- their own throttles
431
+ // also apply, matching today's cadence.
432
+ this.updateScene();
433
+ if (indexChanged) {
434
+ if (this.playbackControls) {
435
+ this.playbackControls.animate(now);
436
+ }
437
+ if (this.app.bodyStateWindow) {
438
+ this.app.bodyStateWindow.animate(now);
439
+ }
440
+ }
441
+ } else if (indexChanged) {
442
+ this.updateScene();
443
+ if (this.playbackControls) {
444
+ this.playbackControls.animate(now);
445
+ }
446
+ if (this.app.bodyStateWindow) {
447
+ this.app.bodyStateWindow.animate(now);
448
+ }
449
+ }
450
+ }
451
+
452
+ captureFrame(now) {
453
+ if (!this.isRecording) return;
454
+ const elapsed = now - this.startTime;
455
+ const duration = this.totalTime * 1000; // Convert to milliseconds
456
+
457
+ if (this._pngTar) {
458
+ this.#capturePngFrame();
459
+ } else if (this._captureTrack) {
460
+ // Manual-mode capture stream (see #startVideoRecording): pull
461
+ // exactly one new frame per actually-rendered frame, so the
462
+ // output is frame-for-frame deterministic rather than racing a
463
+ // realtime clock.
464
+ this._captureTrack.requestFrame();
465
+ }
466
+ // Realtime (non-manual) MediaRecorder streams need no per-frame call
467
+ // here -- the browser samples the canvas on its own timer.
468
+
469
+ // Stop recording if we've completed one loop
470
+ if (elapsed >= duration) {
471
+ this.playbackControls.recordButtonClick();
472
+ }
473
+ }
474
+
475
+ // Encodes the current canvas as a PNG blob and appends it to the in-progress
476
+ // tar (see startRecording/stopRecording) -- this is all CCapture's old "png"
477
+ // format did, just without CCapture itself. Frame filenames are zero-padded
478
+ // so a naive lexicographic sort (e.g. `tar tf` or an archive browser)
479
+ // reconstructs playback order, matching CCapture's own PNG-sequence naming.
480
+ #capturePngFrame() {
481
+ const canvas = this.app.scene.renderer.domElement;
482
+ const tar = this._pngTar;
483
+ const index = this._pngFrameCount++;
484
+ const encoded = new Promise((resolve) => {
485
+ canvas.toBlob((blob) => {
486
+ if (!blob || !tar) {
487
+ resolve();
488
+ return;
489
+ }
490
+ blob
491
+ .arrayBuffer()
492
+ .then((buf) => {
493
+ const name = String(index).padStart(7, "0") + ".png";
494
+ tar.append(name, new Uint8Array(buf));
495
+ })
496
+ .finally(resolve);
497
+ }, "image/png");
498
+ });
499
+ this._pngPending.push(encoded);
500
+ }
501
+
502
+ // Single-frame PNG screenshot (see ui/PlaybackControls.js's camera button
503
+ // and the "S" keyboard shortcut). Renders the scene once immediately
504
+ // before capturing -- the renderer is created with preserveDrawingBuffer
505
+ // (see config.js RENDERER_CONFIG), so the canvas already holds the last
506
+ // rendered frame's pixels, but re-rendering synchronously first guards
507
+ // against capturing a stale frame if this fires between animation ticks
508
+ // (e.g. right after a scrub, before the next rAF has redrawn). Downloads
509
+ // as `simview_t<currentTime>s.png` via a temporary <a download>, same
510
+ // pattern as downloadBlob() above.
511
+ captureScreenshot() {
512
+ const { scene, renderer } = this.app.scene;
513
+ const camera = this.app.scene.camera;
514
+ renderer.render(scene, camera);
515
+ const canvas = renderer.domElement;
516
+ canvas.toBlob((blob) => {
517
+ if (!blob) {
518
+ console.error("Screenshot capture failed: canvas.toBlob returned null.");
519
+ return;
520
+ }
521
+ const time = this.currentTime.toFixed(3);
522
+ downloadBlob(blob, `simview_t${time}s.png`);
523
+ }, "image/png");
524
+ }
525
+
526
+ updateScene() {
527
+ if (!this.app.bodies || this.currentStateIndex >= this.store.length) return;
528
+ if (this.app.uiState?.smoothInterpolation && this.store.length > 1) {
529
+ this.updateSceneInterpolated();
530
+ } else {
531
+ this.updateSceneSnapped();
532
+ }
533
+ if (this.app.scalarPlotter) {
534
+ this.app.scalarPlotter.setEndIndex(this.currentStateIndex);
535
+ }
536
+ }
537
+
538
+ // Exactly today's behavior: render the nearest recorded frame, no
539
+ // interpolation. Used whenever smooth interpolation is turned off.
540
+ updateSceneSnapped() {
541
+ const state = this.store.getFrame(this.currentStateIndex);
542
+ // Resolve parent-relative (rigid/articulated) transforms and expand
543
+ // grouped names into ordinary absolute-world transforms, then update
544
+ // each body exactly as before.
545
+ const resolved = resolveStateBodies(
546
+ this.app.bodyMeta,
547
+ this.app.bodyTopoOrder,
548
+ this.app.batchManager.simBatches,
549
+ state.bodies
550
+ );
551
+ resolved.forEach((resolvedBodyState, name) => {
552
+ const body = this.app.bodies.get(name);
553
+ if (body) {
554
+ // Update the body state - this handles both batched and non-batched formats
555
+ body.updateState(resolvedBodyState);
556
+ }
557
+ });
558
+ }
559
+
560
+ // Resolves the two frames bracketing this.currentTime (memoized by (lo, hi)
561
+ // so consecutive ticks/scrubs within the same interval don't re-resolve),
562
+ // then linearly interpolates position/vector attributes and slerps
563
+ // orientation between them before handing each body its blended state.
564
+ updateSceneInterpolated() {
565
+ const { lo, hi, alpha } = this.getBracketingIndices(this.currentTime);
566
+
567
+ let { frameLo, frameHi } = this._resolvedCache;
568
+ if (this._resolvedCache.lo !== lo || this._resolvedCache.hi !== hi) {
569
+ const simBatches = this.app.batchManager.simBatches;
570
+ frameLo = resolveStateBodies(
571
+ this.app.bodyMeta,
572
+ this.app.bodyTopoOrder,
573
+ simBatches,
574
+ this.store.getFrame(lo).bodies
575
+ );
576
+ frameHi =
577
+ hi === lo
578
+ ? frameLo
579
+ : resolveStateBodies(
580
+ this.app.bodyMeta,
581
+ this.app.bodyTopoOrder,
582
+ simBatches,
583
+ this.store.getFrame(hi).bodies
584
+ );
585
+ this._resolvedCache = { lo, hi, frameLo, frameHi };
586
+ }
587
+
588
+ // Union of body names resolved in either bracketing frame -- a body
589
+ // only present in one (e.g. just appeared/disappeared this frame)
590
+ // falls back to a nearest-frame snap for that body instead of being
591
+ // dropped.
592
+ const names = new Set([...frameLo.keys(), ...frameHi.keys()]);
593
+ names.forEach((name) => {
594
+ const body = this.app.bodies.get(name);
595
+ if (!body) return;
596
+
597
+ const a = frameLo.get(name);
598
+ const b = frameHi.get(name);
599
+ if (!a || !b) {
600
+ // Present in only one bracketing frame: nothing sensible to
601
+ // interpolate against, snap to whichever exists.
602
+ body.updateState(a || b);
603
+ return;
604
+ }
605
+
606
+ body.updateState(this.interpolateBodyState(a, b, alpha));
607
+ });
608
+ }
609
+
610
+ // Blends two resolved bodyState entries (same shape resolveStateBodies
611
+ // produces) into one interpolated bodyState: bodyTransform position lerp +
612
+ // quaternion slerp (shortest path), optional vector attributes lerp'd,
613
+ // everything else (contacts, and any other ragged field) snaps to
614
+ // whichever frame alpha is closer to.
615
+ interpolateBodyState(a, b, alpha) {
616
+ const out = { ...(alpha < 0.5 ? a : b) };
617
+
618
+ if (a.bodyTransform && b.bodyTransform) {
619
+ const rowsA = Array.isArray(a.bodyTransform[0]) ? a.bodyTransform : [a.bodyTransform];
620
+ const rowsB = Array.isArray(b.bodyTransform[0]) ? b.bodyTransform : [b.bodyTransform];
621
+ out.bodyTransform = interpolateTransformRows(rowsA, rowsB, alpha);
622
+ }
623
+
624
+ INTERPOLATED_VECTOR_ATTRS.forEach((attr) => {
625
+ if (!a[attr] || !b[attr]) return;
626
+ const rowsA = Array.isArray(a[attr][0]) ? a[attr] : [a[attr]];
627
+ const rowsB = Array.isArray(b[attr][0]) ? b[attr] : [b[attr]];
628
+ out[attr] = lerpVectorRows(rowsA, rowsB, alpha);
629
+ });
630
+
631
+ return out;
632
+ }
633
+
634
+ dispose() {
635
+ if (this.playbackControls) {
636
+ this.playbackControls.dispose();
637
+ this.playbackControls = null;
638
+ }
639
+ if (this.isRecording) {
640
+ this.isRecording = false;
641
+ if (this._mediaRecorder && this._mediaRecorder.state !== "inactive") {
642
+ this._mediaRecorder.stop();
643
+ }
644
+ if (this._captureTrack) {
645
+ this._captureTrack.stop();
646
+ }
647
+ this._mediaRecorder = null;
648
+ this._captureTrack = null;
649
+ this._recordedChunks = null;
650
+ this._pngTar = null;
651
+ this._pngPending = [];
652
+ }
653
+ }
654
+ }