codimate 0.1.3__tar.gz → 0.1.5__tar.gz

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 (74) hide show
  1. {codimate-0.1.3 → codimate-0.1.5}/Cargo.lock +1 -1
  2. {codimate-0.1.3 → codimate-0.1.5}/PKG-INFO +17 -7
  3. {codimate-0.1.3 → codimate-0.1.5}/README.md +10 -6
  4. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-py/Cargo.toml +1 -1
  5. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-reconcile/src/lib.rs +203 -2
  6. {codimate-0.1.3 → codimate-0.1.5}/pyproject.toml +11 -2
  7. {codimate-0.1.3 → codimate-0.1.5}/python/codimate/__init__.py +5 -3
  8. {codimate-0.1.3 → codimate-0.1.5}/python/codimate/layout.py +214 -0
  9. {codimate-0.1.3 → codimate-0.1.5}/python/codimate/scene.py +30 -0
  10. {codimate-0.1.3 → codimate-0.1.5}/python/codimate/trace.py +79 -9
  11. {codimate-0.1.3 → codimate-0.1.5}/Cargo.toml +0 -0
  12. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/Cargo.toml +0 -0
  13. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/src/lib.rs +0 -0
  14. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/animation.rs +0 -0
  15. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/parallel.rs +0 -0
  16. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/playable.rs +0 -0
  17. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/sequence.rs +0 -0
  18. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/stagger.rs +0 -0
  19. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/Cargo.toml +0 -0
  20. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/builder.rs +0 -0
  21. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/easing.rs +0 -0
  22. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/glyph.rs +0 -0
  23. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/lib.rs +0 -0
  24. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/manim_palette.rs +0 -0
  25. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/path.rs +0 -0
  26. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/circle.rs +0 -0
  27. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/connection.rs +0 -0
  28. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/mod.rs +0 -0
  29. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/path.rs +0 -0
  30. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/primitive.rs +0 -0
  31. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/pulse.rs +0 -0
  32. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/rect.rs +0 -0
  33. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/text.rs +0 -0
  34. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/transform.rs +0 -0
  35. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/value.rs +0 -0
  36. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/anchors.rs +0 -0
  37. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/animated.rs +0 -0
  38. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/circle.rs +0 -0
  39. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/connection.rs +0 -0
  40. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/easing.rs +0 -0
  41. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/manim_palette.rs +0 -0
  42. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/path.rs +0 -0
  43. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/ports.rs +0 -0
  44. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/pulse.rs +0 -0
  45. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/rect.rs +0 -0
  46. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/scene.rs +0 -0
  47. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
  48. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/tween.rs +0 -0
  49. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/Cargo.toml +0 -0
  50. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/src/lib.rs +0 -0
  51. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/tests/export.rs +0 -0
  52. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/tests/png.rs +0 -0
  53. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/Cargo.toml +0 -0
  54. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/build.rs +0 -0
  55. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
  56. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
  57. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
  58. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/src/lib.rs +0 -0
  59. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-glyph/Cargo.toml +0 -0
  60. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-glyph/src/lib.rs +0 -0
  61. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-layout/Cargo.toml +0 -0
  62. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-layout/src/lib.rs +0 -0
  63. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-layout/tests/layout.rs +0 -0
  64. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-math/Cargo.toml +0 -0
  65. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-math/src/lib.rs +0 -0
  66. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-py/src/lib.rs +0 -0
  67. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-reconcile/Cargo.toml +0 -0
  68. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/Cargo.toml +0 -0
  69. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/src/command.rs +0 -0
  70. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/src/lib.rs +0 -0
  71. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/src/raster.rs +0 -0
  72. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/tests/raster.rs +0 -0
  73. {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/tests/render.rs +0 -0
  74. {codimate-0.1.3 → codimate-0.1.5}/python/codimate/explain.py +0 -0
@@ -217,7 +217,7 @@ dependencies = [
217
217
 
218
218
  [[package]]
219
219
  name = "codimate-py"
220
- version = "0.1.3"
220
+ version = "0.1.5"
221
221
  dependencies = [
222
222
  "codimate-animation",
223
223
  "codimate-core",
@@ -1,8 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codimate
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.9
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
6
12
  Classifier: Topic :: Multimedia :: Video
7
13
  Classifier: Topic :: Education
8
14
  Requires-Dist: imageio-ffmpeg>=0.4.9
@@ -15,6 +21,10 @@ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
15
21
  [![PyPI](https://img.shields.io/pypi/v/codimate)](https://pypi.org/project/codimate/)
16
22
  [![Python](https://img.shields.io/pypi/pyversions/codimate)](https://pypi.org/project/codimate/)
17
23
 
24
+ ![Planets tracing helices around the Sun's path through the galaxy](docs/media/helical_solar_system.gif)
25
+
26
+ *Two seconds of [`helical_solar_system/`](python/examples/helical_solar_system/)*
27
+
18
28
  Turn a running algorithm into an explainer video.
19
29
 
20
30
  You write your algorithm as normal Python and say what each step looks like.
@@ -23,14 +33,13 @@ Codimate works out the motion, the timing, and every frame.
23
33
  ```python
24
34
  import codimate as cm
25
35
 
26
- @cm.trace()
27
- def bubble_sort(values):
36
+ def bubble_sort(values, emit):
28
37
  for i in range(len(values)):
29
38
  for j in range(len(values) - 1 - i):
30
- cm.emit("compare", items=[values[j], values[j + 1]])
39
+ emit("compare", items=[values[j], values[j + 1]])
31
40
  if values[j] > values[j + 1]:
32
41
  values[j], values[j + 1] = values[j + 1], values[j]
33
- cm.emit("swap", items=[values[j], values[j + 1]])
42
+ emit("swap", items=[values[j], values[j + 1]])
34
43
 
35
44
  def bars(frame):
36
45
  scene = cm.Scene()
@@ -45,7 +54,7 @@ def bars(frame):
45
54
  return scene
46
55
 
47
56
  cm.explain(
48
- trace=bubble_sort(cm.items([3, 1, 4, 2])),
57
+ trace=cm.trace(bubble_sort, cm.items([3, 1, 4, 2])),
49
58
  view=bars,
50
59
  motion=[cm.Rule("*", position="lift_carry_drop", clearance=90)],
51
60
  timing=cm.Timing(default=0.55, events={"swap": 0.9}),
@@ -182,6 +191,7 @@ scene.circle(name, r=, at=)
182
191
  scene.text(name, content, size=, at=)
183
192
  scene.polygon(name, points) # cm.ngon, cm.star
184
193
  scene.curve(name, points, w=) # smooth, through the points
194
+ scene.arc(name, r=, sweep=(0, 120)) # an arc, a dial, a pie slice
185
195
  scene.svg(name, "logo.svg", size=) # vector art, as real geometry
186
196
  scene.image(name, "photo.jpg", size=) # a picture: PNG or JPEG
187
197
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
@@ -334,7 +344,7 @@ with `python docs/build_site.py`.
334
344
  authored, and the one decision you have to make.
335
345
  4. [Reference](docs/reference.md) — every call and parameter, on one page.
336
346
 
337
- Then [`python/examples/`](python/examples/), eight worked examples with notes, and
347
+ Then [`python/examples/`](python/examples/), eleven worked examples with notes, and
338
348
  [the decisions](docs/adr/) behind the design.
339
349
 
340
350
  ```bash
@@ -3,6 +3,10 @@
3
3
  [![PyPI](https://img.shields.io/pypi/v/codimate)](https://pypi.org/project/codimate/)
4
4
  [![Python](https://img.shields.io/pypi/pyversions/codimate)](https://pypi.org/project/codimate/)
5
5
 
6
+ ![Planets tracing helices around the Sun's path through the galaxy](docs/media/helical_solar_system.gif)
7
+
8
+ *Two seconds of [`helical_solar_system/`](python/examples/helical_solar_system/)*
9
+
6
10
  Turn a running algorithm into an explainer video.
7
11
 
8
12
  You write your algorithm as normal Python and say what each step looks like.
@@ -11,14 +15,13 @@ Codimate works out the motion, the timing, and every frame.
11
15
  ```python
12
16
  import codimate as cm
13
17
 
14
- @cm.trace()
15
- def bubble_sort(values):
18
+ def bubble_sort(values, emit):
16
19
  for i in range(len(values)):
17
20
  for j in range(len(values) - 1 - i):
18
- cm.emit("compare", items=[values[j], values[j + 1]])
21
+ emit("compare", items=[values[j], values[j + 1]])
19
22
  if values[j] > values[j + 1]:
20
23
  values[j], values[j + 1] = values[j + 1], values[j]
21
- cm.emit("swap", items=[values[j], values[j + 1]])
24
+ emit("swap", items=[values[j], values[j + 1]])
22
25
 
23
26
  def bars(frame):
24
27
  scene = cm.Scene()
@@ -33,7 +36,7 @@ def bars(frame):
33
36
  return scene
34
37
 
35
38
  cm.explain(
36
- trace=bubble_sort(cm.items([3, 1, 4, 2])),
39
+ trace=cm.trace(bubble_sort, cm.items([3, 1, 4, 2])),
37
40
  view=bars,
38
41
  motion=[cm.Rule("*", position="lift_carry_drop", clearance=90)],
39
42
  timing=cm.Timing(default=0.55, events={"swap": 0.9}),
@@ -170,6 +173,7 @@ scene.circle(name, r=, at=)
170
173
  scene.text(name, content, size=, at=)
171
174
  scene.polygon(name, points) # cm.ngon, cm.star
172
175
  scene.curve(name, points, w=) # smooth, through the points
176
+ scene.arc(name, r=, sweep=(0, 120)) # an arc, a dial, a pie slice
173
177
  scene.svg(name, "logo.svg", size=) # vector art, as real geometry
174
178
  scene.image(name, "photo.jpg", size=) # a picture: PNG or JPEG
175
179
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
@@ -322,7 +326,7 @@ with `python docs/build_site.py`.
322
326
  authored, and the one decision you have to make.
323
327
  4. [Reference](docs/reference.md) — every call and parameter, on one page.
324
328
 
325
- Then [`python/examples/`](python/examples/), eight worked examples with notes, and
329
+ Then [`python/examples/`](python/examples/), eleven worked examples with notes, and
326
330
  [the decisions](docs/adr/) behind the design.
327
331
 
328
332
  ```bash
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codimate-py"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -85,8 +85,8 @@ pub struct Shape {
85
85
 
86
86
  /// Every `kind` Python may send. An unknown kind is a Python `ValueError`,
87
87
  /// never a silently missing shape.
88
- pub const KINDS: [&str; 9] = [
89
- "rect", "circle", "text", "line", "formula", "polygon", "curve", "svg", "image",
88
+ pub const KINDS: [&str; 10] = [
89
+ "rect", "circle", "text", "line", "formula", "polygon", "curve", "svg", "image", "arc",
90
90
  ];
91
91
 
92
92
  /// A rectangle with rounded corners, in local space, centred on the anchor.
@@ -235,6 +235,79 @@ fn curve_path(s: &Shape) -> Path {
235
235
  Path { segments, closed }
236
236
  }
237
237
 
238
+ /// How many cubics an arc is built from, whatever it sweeps.
239
+ ///
240
+ /// Fixed rather than "one per 90 degrees", because two arcs only tween if they
241
+ /// have the same path structure — the rule `polygon` follows for corners. A
242
+ /// fixed count is what lets a pie fill or an angle mark grow smoothly, which is
243
+ /// the whole reason this kind exists rather than a `curve` through points on a
244
+ /// circle. Eight keeps every span under 45 degrees even at a full turn, where
245
+ /// the cubic approximation is good to a few thousandths of a radius.
246
+ const ARC_SPANS: usize = 8;
247
+
248
+ /// An arc or a sector in local space.
249
+ ///
250
+ /// `w`/`h` are the bounding box, so an ellipse costs nothing extra; `x2`/`y2`
251
+ /// are the start and end angle in degrees; `r` above zero closes it back to the
252
+ /// centre, making a pie slice rather than an open curve.
253
+ ///
254
+ /// Angles run clockwise from twelve o'clock, matching `cm.ngon`, whose first
255
+ /// corner also points straight up.
256
+ /// The same arc, from plain numbers — so the tween can interpolate the
257
+ /// *angles* and build a true arc at every instant.
258
+ ///
259
+ /// Interpolating the path's control points instead looks right for an open
260
+ /// arc and is wrong for a sector: halfway between a collapsed slice and a
261
+ /// 300-degree one is not a 150-degree slice, it is a self-crossing shape that
262
+ /// fills as a sliver and a half-disc. Caught by rendering one.
263
+ fn arc_at(rx: f32, ry: f32, from_deg: f32, to_deg: f32, sector: bool) -> Path {
264
+ // Screen y grows downward, so subtracting a quarter turn puts zero at the
265
+ // top and makes a rising angle read as clockwise.
266
+ let at = |deg: f32| {
267
+ let a = deg.to_radians() - std::f32::consts::FRAC_PI_2;
268
+ (Vec2::new(rx * a.cos(), ry * a.sin()), a)
269
+ };
270
+
271
+ let (start, theta0) = at(from_deg);
272
+ let (_, theta1) = at(to_deg);
273
+ let step = (theta1 - theta0) / ARC_SPANS as f32;
274
+ // The classic cubic fit to a circular span: exact at both ends and at the
275
+ // midpoint, which is why the error stays small for spans under a quarter
276
+ // turn.
277
+ let k = 4.0 / 3.0 * (step / 4.0).tan();
278
+
279
+ let mut segments = Vec::with_capacity(ARC_SPANS + 3);
280
+ if sector {
281
+ segments.push(Segment::MoveTo(Vec2::new(0.0, 0.0)));
282
+ segments.push(Segment::Line(Vec2::new(0.0, 0.0), start));
283
+ } else {
284
+ segments.push(Segment::MoveTo(start));
285
+ }
286
+
287
+ let mut from = start;
288
+ let mut a = theta0;
289
+ for _ in 0..ARC_SPANS {
290
+ let b = a + step;
291
+ let to = Vec2::new(rx * b.cos(), ry * b.sin());
292
+ // The tangent at each end, scaled by the span — the control points sit
293
+ // along it, which is what keeps the join smooth.
294
+ let c1 = Vec2::new(from.x - k * rx * a.sin(), from.y + k * ry * a.cos());
295
+ let c2 = Vec2::new(to.x + k * rx * b.sin(), to.y - k * ry * b.cos());
296
+ segments.push(Segment::Cubic(from, c1, c2, to));
297
+ from = to;
298
+ a = b;
299
+ }
300
+
301
+ if sector {
302
+ segments.push(Segment::Line(from, Vec2::new(0.0, 0.0)));
303
+ }
304
+
305
+ Path {
306
+ segments,
307
+ closed: sector,
308
+ }
309
+ }
310
+
238
311
  /// A line in local space: from the anchor to the far end.
239
312
  fn line_path(s: &Shape) -> Path {
240
313
  Path {
@@ -272,6 +345,29 @@ impl Shape {
272
345
  }
273
346
  }
274
347
 
348
+ // The angles tween, and the arc is rebuilt at every instant — so
349
+ // a dial sweeps and a pie fills through shapes that are all
350
+ // genuinely arcs. This is the point of the kind: a `curve` through
351
+ // points on a circle changes its point count when the sweep
352
+ // changes, and then it snaps instead of sweeping.
353
+ "arc" => {
354
+ let (rx0, ry0) = (self.w / 2.0, self.h / 2.0);
355
+ let (rx1, ry1) = (other.w / 2.0, other.h / 2.0);
356
+ let (a0, b0) = (self.x2, self.y2);
357
+ let (a1, b1) = (other.x2, other.y2);
358
+ let sector = other.r > 0.0;
359
+ Geometry::path(Animated::new(move |t| {
360
+ let mix = |x: f32, y: f32| x + (y - x) * t;
361
+ arc_at(
362
+ mix(rx0, rx1),
363
+ mix(ry0, ry1),
364
+ mix(a0, a1),
365
+ mix(b0, b1),
366
+ sector,
367
+ )
368
+ }))
369
+ }
370
+
275
371
  // Same rule as a polygon: matching sample counts interpolate, and
276
372
  // otherwise the later shape stands for the whole segment.
277
373
  "curve" => {
@@ -1408,6 +1504,9 @@ fn bounds(shape: &Shape) -> (f32, f32, f32, f32) {
1408
1504
  ys.iter().cloned().fold(f32::MIN, f32::max),
1409
1505
  );
1410
1506
  }
1507
+ // The whole ellipse, not the swept part: an arc that grows would
1508
+ // otherwise resize the camera as it sweeps.
1509
+ "arc" => (shape.w, shape.h),
1411
1510
  "line" => {
1412
1511
  let (x0, x1) = (shape.x.min(shape.x2), shape.x.max(shape.x2));
1413
1512
  let (y0, y1) = (shape.y.min(shape.y2), shape.y.max(shape.y2));
@@ -1855,6 +1954,108 @@ mod tests {
1855
1954
 
1856
1955
  /// An SVG on disk, because `svg_art` reads a path rather than a string —
1857
1956
  /// the payload carries a file name, so the cache can key on it.
1957
+ fn arc_path(s: &Shape) -> Path {
1958
+ arc_at(s.w / 2.0, s.h / 2.0, s.x2, s.y2, s.r > 0.0)
1959
+ }
1960
+
1961
+ fn arc_shape(r: f32, from: f32, to: f32, sector: bool) -> Shape {
1962
+ Shape {
1963
+ item: "arc".into(),
1964
+ kind: "arc".into(),
1965
+ w: r * 2.0,
1966
+ h: r * 2.0,
1967
+ x2: from,
1968
+ y2: to,
1969
+ r: if sector { 1.0 } else { 0.0 },
1970
+ color: "white".into(),
1971
+ opacity: 1.0,
1972
+ ..Default::default()
1973
+ }
1974
+ }
1975
+
1976
+ /// Every point the arc visits is on the circle it claims to be — the
1977
+ /// eight-span cubic fit is an approximation, so this is what says how good.
1978
+ #[test]
1979
+ fn an_arc_stays_on_its_circle() {
1980
+ for (from, to) in [(0.0, 90.0), (0.0, 270.0), (0.0, 360.0), (45.0, -120.0)] {
1981
+ let path = arc_path(&arc_shape(100.0, from, to, false));
1982
+ for seg in &path.segments {
1983
+ let ends = match *seg {
1984
+ Segment::Cubic(a, _, _, b) => vec![a, b],
1985
+ Segment::MoveTo(a) => vec![a],
1986
+ _ => vec![],
1987
+ };
1988
+ for p in ends {
1989
+ let radius = (p.x * p.x + p.y * p.y).sqrt();
1990
+ assert!(
1991
+ (radius - 100.0).abs() < 0.5,
1992
+ "{from}..{to}: a point sits at {radius}, not 100"
1993
+ );
1994
+ }
1995
+ }
1996
+ }
1997
+ }
1998
+
1999
+ /// A sector closes back to its centre and an open arc does not — the
2000
+ /// difference between a pie slice and a curved line.
2001
+ #[test]
2002
+ fn a_sector_returns_to_the_centre() {
2003
+ let open = arc_path(&arc_shape(50.0, 0.0, 120.0, false));
2004
+ let pie = arc_path(&arc_shape(50.0, 0.0, 120.0, true));
2005
+
2006
+ assert!(!open.closed && pie.closed);
2007
+ // Distance from the centre, not `x`: an arc starting at zero degrees
2008
+ // begins at the top, where `x` is zero.
2009
+ assert!(
2010
+ matches!(open.segments.first(), Some(Segment::MoveTo(p)) if p.x.hypot(p.y) > 1.0),
2011
+ "an open arc starts on its circle, not at the centre"
2012
+ );
2013
+ assert!(
2014
+ matches!(pie.segments.first(), Some(Segment::MoveTo(p)) if p.x.abs() < 1e-6 && p.y.abs() < 1e-6),
2015
+ "a pie starts at its centre"
2016
+ );
2017
+ assert!(
2018
+ matches!(pie.segments.last(), Some(Segment::Line(_, p)) if p.x.abs() < 1e-6 && p.y.abs() < 1e-6),
2019
+ "and comes back to it"
2020
+ );
2021
+ }
2022
+
2023
+ /// The reason this kind exists rather than a `curve` through points on a
2024
+ /// circle, and the bug the first version had: halfway through a sweep the
2025
+ /// shape must be a real arc at the halfway angle.
2026
+ ///
2027
+ /// Interpolating the path's control points gives a self-crossing sliver
2028
+ /// instead, which looks fine on an open arc and obviously wrong on a pie.
2029
+ #[test]
2030
+ fn a_sweep_passes_through_real_arcs() {
2031
+ let closed = arc_shape(100.0, 0.0, 0.0, true);
2032
+ let open_to_300 = arc_shape(100.0, 0.0, 300.0, true);
2033
+
2034
+ let geometry = closed.geometry(&open_to_300);
2035
+ let codimate_core::ConcreteGeometry::Path { path: halfway } = geometry.resolve(0.5) else {
2036
+ panic!("an arc resolves to a path");
2037
+ };
2038
+ let expected = arc_path(&arc_shape(100.0, 0.0, 150.0, true));
2039
+
2040
+ let ends = |p: &Path| -> Vec<Vec2> {
2041
+ p.segments
2042
+ .iter()
2043
+ .filter_map(|s| match *s {
2044
+ Segment::Cubic(_, _, _, b) => Some(b),
2045
+ _ => None,
2046
+ })
2047
+ .collect()
2048
+ };
2049
+ let (got, want) = (ends(&halfway), ends(&expected));
2050
+ assert_eq!(got.len(), want.len());
2051
+ for (a, b) in got.iter().zip(&want) {
2052
+ assert!(
2053
+ (a.x - b.x).abs() < 0.01 && (a.y - b.y).abs() < 0.01,
2054
+ "halfway through a 0..300 sweep should be the 150 arc: {a:?} vs {b:?}"
2055
+ );
2056
+ }
2057
+ }
2058
+
1858
2059
  fn svg_on_disk(name: &str, body: &str) -> String {
1859
2060
  let path = std::env::temp_dir().join(format!("codimate-test-{name}.svg"));
1860
2061
  std::fs::write(&path, body).expect("temp file");
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "codimate"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "Turn a running algorithm into an explainer video"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -13,9 +13,19 @@ requires-python = ">=3.9"
13
13
  # own — so `pip install codimate` is enough on a bare machine. `typst`, which
14
14
  # only `scene.formula` needs, stays a documented prerequisite.
15
15
  dependencies = ["imageio-ffmpeg>=0.4.9"]
16
+ # The per-version entries are not decoration: `requires-python` above is what
17
+ # pip enforces, but PyPI's sidebar and the shields.io badge both read these
18
+ # instead, and without them the badge reads "missing". One abi3-py39 wheel
19
+ # serves every version listed.
16
20
  classifiers = [
17
21
  "Programming Language :: Rust",
18
22
  "Programming Language :: Python :: Implementation :: CPython",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
19
29
  "Topic :: Multimedia :: Video",
20
30
  "Topic :: Education",
21
31
  ]
@@ -41,7 +51,6 @@ select = ["E", "W", "F", "PLR0913"]
41
51
  ignore = ["E741"]
42
52
 
43
53
  [tool.ruff.lint.per-file-ignores]
44
- "python/examples/attention/weights.py" = ["E501"]
45
54
 
46
55
  [tool.ruff.lint.pylint]
47
56
  max-args = 5
@@ -20,7 +20,7 @@ encoding — happens in Rust (ADR 0008).
20
20
 
21
21
  ## What a moment looks like
22
22
 
23
- - `Scene` — one picture: `rect`, `circle`, `polygon`, `curve`, `arrow`,
23
+ - `Scene` — one picture: `rect`, `circle`, `arc`, `polygon`, `curve`, `arrow`,
24
24
  `text`, `line`, `formula`, `svg`, `image`
25
25
  - `ngon`, `star` — corners for a polygon, so you do not compute them
26
26
  - `Group` — several shapes that move together
@@ -48,8 +48,8 @@ encoding — happens in Rust (ADR 0008).
48
48
  from __future__ import annotations
49
49
 
50
50
  from .explain import Explanation, Rule, Timing, ease, explain
51
- from .layout import (Place, Slot, at, canvas, column, height, measure, measure_math,
52
- ngon, row, star, width)
51
+ from .layout import (Axes, Place, Slot, at, axes, canvas, column, height, measure,
52
+ measure_math, ngon, row, star, width)
53
53
  from .scene import Group, Handle, Scene
54
54
  from .trace import Event, Frame, Item, Trace, emit, items, trace
55
55
 
@@ -77,6 +77,8 @@ __all__ = [
77
77
  "at",
78
78
  "row",
79
79
  "column",
80
+ "axes",
81
+ "Axes",
80
82
  "ngon",
81
83
  "star",
82
84
  # putting it together
@@ -306,3 +306,217 @@ def _resolve(given, half, names, default=_UNSET):
306
306
  if low is not None:
307
307
  return float(low) + half
308
308
  return float(high) - half
309
+
310
+
311
+ # --------------------------------------------------------------------------
312
+ # Axes: a coordinate map, not a drawing (ADR 0016)
313
+ # --------------------------------------------------------------------------
314
+
315
+
316
+ def _nice_step(span: float, about: int) -> float:
317
+ """A round step near ``span / about``, from the 1-2-5 sequence.
318
+
319
+ Round steps are not a nicety. A tick is a named shape, so the set of them
320
+ is an identity that survives from one Scene to the next; a step that
321
+ drifted with every frame would rename every tick every frame, and a still
322
+ picture would flicker. 1-2-5 holds steady until the range really changes.
323
+ """
324
+ import math
325
+
326
+ if span <= 0:
327
+ raise ValueError(f"a range needs width, got {span}")
328
+ raw = span / max(int(about), 1)
329
+ power = 10.0 ** math.floor(math.log10(raw))
330
+ for nice in (1.0, 2.0, 5.0):
331
+ if raw <= nice * power:
332
+ return nice * power
333
+ return 10.0 * power
334
+
335
+
336
+ def _places(step: float) -> int:
337
+ """How many decimals a label needs so that two ticks never read alike."""
338
+ import math
339
+
340
+ return max(0, -math.floor(math.log10(step) + 1e-9))
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class Axes:
345
+ """A map from your numbers to pixels. Built by :func:`axes`.
346
+
347
+ It draws nothing you did not ask it to and owns none of your names — see
348
+ :meth:`at`. Its own shapes (frame, ticks, labels) are drawn by
349
+ :meth:`draw`, under names you choose the prefix of.
350
+ """
351
+
352
+ x0: float
353
+ x1: float
354
+ y0: float
355
+ y1: float
356
+ left: float
357
+ top: float
358
+ w: float
359
+ h: float
360
+ ink: str = "#8b96a8"
361
+ label: float = 20.0
362
+ layer: int = 0
363
+
364
+ def looks(self, ink: str = None, label: float = None,
365
+ layer: int = None) -> "Axes":
366
+ """Restyle the frame, ticks and labels. Returns a new Axes.
367
+
368
+ plot = cm.axes(x=(0, 10), y=(0, 5)).looks(ink="#334", label=16)
369
+
370
+ A separate call rather than more arguments on :meth:`draw`, which the
371
+ shapes made the same choice about: no call in this library takes more
372
+ than five things.
373
+ """
374
+ import dataclasses
375
+
376
+ return dataclasses.replace(
377
+ self,
378
+ ink=self.ink if ink is None else str(ink),
379
+ label=self.label if label is None else float(label),
380
+ layer=self.layer if layer is None else int(layer))
381
+
382
+ @property
383
+ def right(self) -> float:
384
+ return self.left + self.w
385
+
386
+ @property
387
+ def bottom(self) -> float:
388
+ return self.top + self.h
389
+
390
+ def at(self, x: float, y: float) -> tuple[float, float]:
391
+ """One data point as a pixel pair, for any shape's ``at=``.
392
+
393
+ scene.circle("dot", r=8, at=plot.at(2.0, 4.0))
394
+
395
+ Handing back pixels rather than drawing is the whole design: what you
396
+ do with them is an ordinary shape with a name of yours, so it tweens,
397
+ `focus` frames it, and a motion Rule can be aimed at it.
398
+ """
399
+ across = (float(x) - self.x0) / (self.x1 - self.x0)
400
+ up = (float(y) - self.y0) / (self.y1 - self.y0)
401
+ return (self.left + across * self.w, self.bottom - up * self.h)
402
+
403
+ def line(self, f, steps: int = 200, over=None) -> list:
404
+ """``steps + 1`` points along ``y = f(x)``, as pixels.
405
+
406
+ scene.curve("f", plot.line(lambda x: x * x), w=4).fill("red")
407
+
408
+ ``over=(lo, hi)`` samples part of the range instead of all of it.
409
+
410
+ An open `curve` is *drawn* rather than filled, so its colour is
411
+ ``fill()`` and its thickness is ``w=`` — ``fill("none", edge=...)``
412
+ draws nothing at all.
413
+
414
+ Every sample is returned, including any that fall outside the box.
415
+ Dropping them would be prettier and is wrong: two curves with different
416
+ point counts do not interpolate (ADR 0010), so a curve that shed a
417
+ point as it left the frame would stop animating. Keep the count and
418
+ clamp the range with ``over=`` if you need it inside.
419
+ """
420
+ lo, hi = (self.x0, self.x1) if over is None else (float(over[0]),
421
+ float(over[1]))
422
+ steps = max(int(steps), 1)
423
+ return [self.at(x, f(x))
424
+ for x in (lo + (hi - lo) * i / steps for i in range(steps + 1))]
425
+
426
+ def ticks(self, axis: str = "x", about: int = 6) -> list:
427
+ """``(n, value, label)`` per tick, where ``n`` is the step multiple.
428
+
429
+ ``n`` is what a tick is named after, not the value. A float that drifts
430
+ by one part in a billion is a different name, and a renamed shape
431
+ leaves and re-enters — which is a fade, on a picture that did not move.
432
+ An integer count of steps cannot drift.
433
+ """
434
+ import math
435
+
436
+ lo, hi = (self.x0, self.x1) if axis == "x" else (self.y0, self.y1)
437
+ step = _nice_step(hi - lo, about)
438
+ digits = _places(step)
439
+ first = math.ceil(lo / step - 1e-9)
440
+ last = math.floor(hi / step + 1e-9)
441
+ out = []
442
+ for n in range(int(first), int(last) + 1):
443
+ value = n * step
444
+ label = f"{value:.{digits}f}"
445
+ out.append((n, value, "0" if label.lstrip("-").strip("0.") == ""
446
+ else label))
447
+ return out
448
+
449
+ def draw(self, scene, name="plot", *, about: int = 6,
450
+ grid: bool = False) -> "Axes":
451
+ """Draw the frame, ticks and labels. Returns the Axes, for chaining.
452
+
453
+ plot = cm.axes(x=(-4, 4), y=(-2, 6)).draw(scene)
454
+
455
+ Every shape is named ``(name, ...)``, so two plots on one canvas do not
456
+ collide and you can restyle or omit any of them by drawing your own.
457
+ Colours and sizes come from :meth:`looks`.
458
+ """
459
+ ink, layer = self.ink, self.layer
460
+ zero_y = self.y0 <= 0.0 <= self.y1
461
+ zero_x = self.x0 <= 0.0 <= self.x1
462
+ base = self.at(self.x0, 0.0)[1] if zero_y else self.bottom
463
+ spine = self.at(0.0, self.y0)[0] if zero_x else self.left
464
+
465
+ scene.line((name, "x-axis"), start=(self.left, base),
466
+ end=(self.right, base), w=1.6).fill(ink).on(layer=layer)
467
+ scene.line((name, "y-axis"), start=(spine, self.top),
468
+ end=(spine, self.bottom), w=1.6).fill(ink).on(layer=layer)
469
+
470
+ for axis, along in (("x", True), ("y", False)):
471
+ for n, value, words in self.ticks(axis, about):
472
+ if n == 0 and (zero_x if along else zero_y):
473
+ continue # the other axis already draws through it
474
+ x, y = (self.at(value, 0.0)[0], base) if along else \
475
+ (spine, self.at(0.0, value)[1])
476
+ if grid:
477
+ ends = ((x, self.top), (x, self.bottom)) if along else \
478
+ ((self.left, y), (self.right, y))
479
+ scene.line((name, "grid", axis, n), start=ends[0],
480
+ end=ends[1], w=1.0).fill(ink) \
481
+ .on(layer=layer - 1, opacity=0.18)
482
+ mark = ((x, y - 5), (x, y + 5)) if along else \
483
+ ((x - 5, y), (x + 5, y))
484
+ scene.line((name, "tick", axis, n), start=mark[0], end=mark[1],
485
+ w=1.6).fill(ink).on(layer=layer)
486
+ where = at(x=x, top=y + 10) if along else at(x=x - 18, y=y)
487
+ scene.text((name, "label", axis, n), words, size=self.label,
488
+ at=where).fill(ink).on(layer=layer)
489
+ return self
490
+
491
+
492
+ def axes(*, x, y, at=None, size=None, within: "Slot | None" = None) -> Axes:
493
+ """A coordinate map from your numbers to pixels.
494
+
495
+ plot = cm.axes(x=(-4, 4), y=(-2, 6), size=(760, 420)).draw(scene)
496
+ scene.curve("f", plot.line(lambda t: t * t), w=4).fill("orange")
497
+ scene.circle("dot", r=8, at=plot.at(t, t * t))
498
+
499
+ ``x`` and ``y`` are ``(low, high)``. ``size`` is ``(w, h)`` in pixels, or
500
+ one number for a square; it defaults to most of the canvas. ``at`` places
501
+ the box's centre, and ``within=slot`` fits it to part of the canvas.
502
+
503
+ It returns points rather than drawing, so what you plot is a shape with a
504
+ name of yours — which is what lets it tween, be framed by `focus`, and be
505
+ aimed at by a motion Rule. See ADR 0016.
506
+ """
507
+ box = _within(within)
508
+ w, h = _size(size)
509
+ if w is None:
510
+ w = box.w * 0.72
511
+ if h is None:
512
+ h = box.h * 0.66
513
+ place = at or Place()
514
+ cx = box.x if place.x is None else place.x
515
+ cy = _resolve((place.y, place.top, place.bottom), h / 2.0,
516
+ ("y", "top", "bottom"), box.y)
517
+ x0, x1 = (float(v) for v in x)
518
+ y0, y1 = (float(v) for v in y)
519
+ if x0 == x1 or y0 == y1:
520
+ raise ValueError(f"a range needs width, got x={x!r} y={y!r}")
521
+ return Axes(x0=x0, x1=x1, y0=y0, y1=y1,
522
+ left=cx - w / 2.0, top=cy - h / 2.0, w=float(w), h=float(h))
@@ -290,6 +290,36 @@ class Group:
290
290
  return self._place(key, "formula", x=x, y=y, text=latex, size=size,
291
291
  layer=10, r=1.0)
292
292
 
293
+ def arc(self, key: Hashable, *, r, sweep, at=None) -> "Handle":
294
+ """A slice of a circle — an angle mark, a pie, a dial, an orbit.
295
+
296
+ scene.arc("angle", r=90, sweep=(0, 50)).fill("none", edge="cyan",
297
+ edge_w=3)
298
+ scene.arc("slice", r=120, sweep=(0, 120)).fill("orange").round(1)
299
+
300
+ `sweep` is `(start, end)` in degrees, clockwise from twelve o'clock —
301
+ the same zero `cm.ngon` uses. `r` is the radius, or `(rx, ry)` for an
302
+ ellipse.
303
+
304
+ Open by default, so it draws as a curved line. `.round(1)` closes it
305
+ back to the centre and makes a pie slice you can fill.
306
+
307
+ Two arcs always tween, however far apart their angles are, so an angle
308
+ mark grows and a pie fills smoothly. That is what this cannot be done
309
+ with `curve`: a curve through points on a circle changes its point
310
+ count when the sweep changes, and then it snaps instead of sweeping.
311
+ """
312
+ rx, ry = (r, r) if isinstance(r, (int, float)) else r
313
+ start, end = sweep
314
+ x, y = self._where(at, 0.0)
315
+ return self._place(
316
+ key, "arc", x=x, y=y,
317
+ # The bounding box, so an ellipse costs nothing extra.
318
+ w=float(rx) * 2, h=float(ry) * 2,
319
+ # `x2`/`y2` are the line's endpoints elsewhere and free here.
320
+ x2=float(start), y2=float(end),
321
+ )
322
+
293
323
  def image(self, key: Hashable, file, *, size=None, at=None) -> "Handle":
294
324
  """A picture — a photo, a screenshot, a figure — drawn into the frame.
295
325
 
@@ -99,15 +99,75 @@ def emit(name: str, **data: Any) -> None:
99
99
  rec.events.append(Event(name=name, state=rec.snapshot(), data=data))
100
100
 
101
101
 
102
- def trace(*, snapshot: Callable[..., Any] = None):
103
- """Turn a normal, state-mutating function into a Trace.
102
+ _MISSING = object()
104
103
 
105
- ``snapshot`` receives the same arguments as your function and returns a
106
- copy of the data worth showing. Defaults to a deep copy of the first
107
- argument — Items keep their identity through it.
104
+
105
+ def _emitter(rec: "_Recorder") -> Callable[..., None]:
106
+ """The `emit` handed to an algorithm: record that something happened."""
107
+
108
+ def emit(name: str, **data: Any) -> None:
109
+ rec.events.append(Event(name=name, state=rec.snapshot(), data=data))
110
+
111
+ emit.__doc__ = globals()["emit"].__doc__
112
+ return emit
113
+
114
+
115
+ def trace(fn=_MISSING, state=_MISSING, /, *, snapshot=None) -> "Trace":
116
+ """Run `fn` over `state` and record what happened.
117
+
118
+ def bubble_sort(values, emit):
119
+ ...
120
+ emit("compare", items=[a, b])
121
+
122
+ cm.explain(trace=cm.trace(bubble_sort, cm.items([3, 1, 4, 2])), ...)
123
+
124
+ Your function is handed the state and an `emit`. Call `emit` *after*
125
+ changing the state — Codimate snapshots the result for you.
126
+
127
+ `emit` is an argument rather than something ambient, so the answer to
128
+ "where did that come from?" is the line above it, and so the function
129
+ stays ordinary Python: `bubble_sort(values, print)` runs it and prints the
130
+ events.
131
+
132
+ `snapshot(state)` returns the part worth showing, if a deep copy of the
133
+ whole state is not what you want. Items keep their identity through the
134
+ default copy.
135
+
136
+ The older form — `@cm.trace()` on a function that calls the module-level
137
+ `cm.emit` — still works and warns. It goes in 0.2.
108
138
  """
139
+ if fn is _MISSING or state is _MISSING:
140
+ return _decorator(snapshot) if fn is _MISSING else _decorator(snapshot)(fn)
141
+
142
+ def capture():
143
+ return snapshot(state) if snapshot is not None else copy.deepcopy(state)
144
+
145
+ rec = _Recorder(snapshot=capture, events=[])
146
+ initial = capture()
147
+
148
+ # The ContextVar is still set, so a helper that has not been moved over
149
+ # yet and still calls `cm.emit` keeps working during the transition.
150
+ token = _recorder.set(rec)
151
+ try:
152
+ fn(state, _emitter(rec))
153
+ finally:
154
+ _recorder.reset(token)
155
+
156
+ return Trace(initial=initial, events=tuple(rec.events))
157
+
158
+
159
+ def _decorator(snapshot):
160
+ """The older `@cm.trace()`, kept working until 0.2."""
161
+ import warnings
162
+
163
+ def decorate(fn):
164
+ warnings.warn(
165
+ "@cm.trace() and the ambient cm.emit are removed in 0.2. Take `emit` "
166
+ "as an argument and call cm.trace(fn, state) instead:\n"
167
+ f" def {fn.__name__}(state, emit): ...\n"
168
+ f" cm.explain(trace=cm.trace({fn.__name__}, state), ...)",
169
+ DeprecationWarning, stacklevel=3)
109
170
 
110
- def decorator(fn):
111
171
  def run(*args, **kwargs) -> Trace:
112
172
  def capture():
113
173
  if snapshot is not None:
@@ -116,20 +176,30 @@ def trace(*, snapshot: Callable[..., Any] = None):
116
176
 
117
177
  rec = _Recorder(snapshot=capture, events=[])
118
178
  initial = capture()
119
-
120
179
  token = _recorder.set(rec)
121
180
  try:
122
181
  fn(*args, **kwargs)
123
182
  finally:
124
183
  _recorder.reset(token)
125
-
126
184
  return Trace(initial=initial, events=tuple(rec.events))
127
185
 
128
186
  run.__name__ = fn.__name__
129
187
  run.__doc__ = fn.__doc__
130
188
  return run
131
189
 
132
- return decorator
190
+ return decorate
191
+
192
+
193
+ def items(self, key: str = "items") -> list:
194
+ """The things this event named, e.g. ``frame.items()``.
195
+
196
+ Use it when you emitted a list: ``cm.emit("compare", items=[a, b])``.
197
+ Empty for the opening moment.
198
+ """
199
+ if self.event is None:
200
+ return []
201
+ return list(self.event.data.get(key, ()))
202
+
133
203
 
134
204
  @dataclass(frozen=True)
135
205
  class Frame:
File without changes