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.
- {codimate-0.1.3 → codimate-0.1.5}/Cargo.lock +1 -1
- {codimate-0.1.3 → codimate-0.1.5}/PKG-INFO +17 -7
- {codimate-0.1.3 → codimate-0.1.5}/README.md +10 -6
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-py/Cargo.toml +1 -1
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-reconcile/src/lib.rs +203 -2
- {codimate-0.1.3 → codimate-0.1.5}/pyproject.toml +11 -2
- {codimate-0.1.3 → codimate-0.1.5}/python/codimate/__init__.py +5 -3
- {codimate-0.1.3 → codimate-0.1.5}/python/codimate/layout.py +214 -0
- {codimate-0.1.3 → codimate-0.1.5}/python/codimate/scene.py +30 -0
- {codimate-0.1.3 → codimate-0.1.5}/python/codimate/trace.py +79 -9
- {codimate-0.1.3 → codimate-0.1.5}/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/animation.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/parallel.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/playable.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/sequence.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-animation/tests/stagger.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/builder.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/easing.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/glyph.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/manim_palette.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/path.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/circle.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/connection.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/mod.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/path.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/primitive.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/pulse.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/rect.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/text.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/scene/transform.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/src/value.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/anchors.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/animated.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/circle.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/connection.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/easing.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/manim_palette.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/path.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/ports.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/pulse.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/rect.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/scene.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-core/tests/tween.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/tests/export.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-export/tests/png.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/build.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-fonts/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-glyph/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-glyph/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-layout/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-layout/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-layout/tests/layout.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-math/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-math/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-py/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-reconcile/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/Cargo.toml +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/src/command.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/src/lib.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/src/raster.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/tests/raster.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/crates/codimate-render/tests/render.rs +0 -0
- {codimate-0.1.3 → codimate-0.1.5}/python/codimate/explain.py +0 -0
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codimate
|
|
3
|
-
Version: 0.1.
|
|
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
|
[](https://pypi.org/project/codimate/)
|
|
16
22
|
[](https://pypi.org/project/codimate/)
|
|
17
23
|
|
|
24
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/),
|
|
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
|
[](https://pypi.org/project/codimate/)
|
|
4
4
|
[](https://pypi.org/project/codimate/)
|
|
5
5
|
|
|
6
|
+

|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/),
|
|
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
|
|
@@ -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;
|
|
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.
|
|
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,
|
|
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
|
-
|
|
103
|
-
"""Turn a normal, state-mutating function into a Trace.
|
|
102
|
+
_MISSING = object()
|
|
104
103
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|