codimate 0.1.2__tar.gz → 0.1.4__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.2 → codimate-0.1.4}/Cargo.lock +12 -1
  2. {codimate-0.1.2 → codimate-0.1.4}/PKG-INFO +3 -1
  3. {codimate-0.1.2 → codimate-0.1.4}/README.md +2 -0
  4. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/mod.rs +1 -1
  5. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/primitive.rs +64 -0
  6. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-py/Cargo.toml +1 -1
  7. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-reconcile/Cargo.toml +6 -0
  8. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-reconcile/src/lib.rs +432 -4
  9. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/src/command.rs +125 -0
  10. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/src/raster.rs +30 -0
  11. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/tests/raster.rs +71 -0
  12. {codimate-0.1.2 → codimate-0.1.4}/pyproject.toml +1 -1
  13. {codimate-0.1.2 → codimate-0.1.4}/python/codimate/__init__.py +2 -2
  14. {codimate-0.1.2 → codimate-0.1.4}/python/codimate/scene.py +50 -0
  15. {codimate-0.1.2 → codimate-0.1.4}/Cargo.toml +0 -0
  16. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/Cargo.toml +0 -0
  17. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/src/lib.rs +0 -0
  18. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/animation.rs +0 -0
  19. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/parallel.rs +0 -0
  20. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/playable.rs +0 -0
  21. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/sequence.rs +0 -0
  22. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/stagger.rs +0 -0
  23. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/Cargo.toml +0 -0
  24. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/builder.rs +0 -0
  25. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/easing.rs +0 -0
  26. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/glyph.rs +0 -0
  27. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/lib.rs +0 -0
  28. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/manim_palette.rs +0 -0
  29. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/path.rs +0 -0
  30. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/circle.rs +0 -0
  31. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/connection.rs +0 -0
  32. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/path.rs +0 -0
  33. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/pulse.rs +0 -0
  34. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/rect.rs +0 -0
  35. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/text.rs +0 -0
  36. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/transform.rs +0 -0
  37. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/value.rs +0 -0
  38. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/anchors.rs +0 -0
  39. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/animated.rs +0 -0
  40. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/circle.rs +0 -0
  41. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/connection.rs +0 -0
  42. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/easing.rs +0 -0
  43. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/manim_palette.rs +0 -0
  44. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/path.rs +0 -0
  45. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/ports.rs +0 -0
  46. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/pulse.rs +0 -0
  47. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/rect.rs +0 -0
  48. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/scene.rs +0 -0
  49. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
  50. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/tween.rs +0 -0
  51. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/Cargo.toml +0 -0
  52. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/src/lib.rs +0 -0
  53. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/tests/export.rs +0 -0
  54. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/tests/png.rs +0 -0
  55. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/Cargo.toml +0 -0
  56. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/build.rs +0 -0
  57. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
  58. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
  59. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
  60. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/src/lib.rs +0 -0
  61. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-glyph/Cargo.toml +0 -0
  62. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-glyph/src/lib.rs +0 -0
  63. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-layout/Cargo.toml +0 -0
  64. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-layout/src/lib.rs +0 -0
  65. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-layout/tests/layout.rs +0 -0
  66. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-math/Cargo.toml +0 -0
  67. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-math/src/lib.rs +0 -0
  68. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-py/src/lib.rs +0 -0
  69. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/Cargo.toml +0 -0
  70. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/src/lib.rs +0 -0
  71. {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/tests/render.rs +0 -0
  72. {codimate-0.1.2 → codimate-0.1.4}/python/codimate/explain.py +0 -0
  73. {codimate-0.1.2 → codimate-0.1.4}/python/codimate/layout.py +0 -0
  74. {codimate-0.1.2 → codimate-0.1.4}/python/codimate/trace.py +0 -0
@@ -217,7 +217,7 @@ dependencies = [
217
217
 
218
218
  [[package]]
219
219
  name = "codimate-py"
220
- version = "0.1.2"
220
+ version = "0.1.4"
221
221
  dependencies = [
222
222
  "codimate-animation",
223
223
  "codimate-core",
@@ -236,6 +236,8 @@ dependencies = [
236
236
  "codimate-core",
237
237
  "codimate-math",
238
238
  "codimate-render",
239
+ "jpeg-decoder",
240
+ "png",
239
241
  ]
240
242
 
241
243
  [[package]]
@@ -586,6 +588,15 @@ version = "1.0.18"
586
588
  source = "registry+https://github.com/rust-lang/crates.io-index"
587
589
  checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
588
590
 
591
+ [[package]]
592
+ name = "jpeg-decoder"
593
+ version = "0.3.2"
594
+ source = "registry+https://github.com/rust-lang/crates.io-index"
595
+ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
596
+ dependencies = [
597
+ "rayon",
598
+ ]
599
+
589
600
  [[package]]
590
601
  name = "jpeg-encoder"
591
602
  version = "0.6.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codimate
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Topic :: Multimedia :: Video
@@ -182,7 +182,9 @@ scene.circle(name, r=, at=)
182
182
  scene.text(name, content, size=, at=)
183
183
  scene.polygon(name, points) # cm.ngon, cm.star
184
184
  scene.curve(name, points, w=) # smooth, through the points
185
+ scene.arc(name, r=, sweep=(0, 120)) # an arc, a dial, a pie slice
185
186
  scene.svg(name, "logo.svg", size=) # vector art, as real geometry
187
+ scene.image(name, "photo.jpg", size=) # a picture: PNG or JPEG
186
188
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
187
189
  scene.formula(name, r"\frac{a}{b}", size=, at=) # LaTeX, needs `typst`
188
190
  cm.measure(text, size) -> (w, h) # to size a box around text
@@ -170,7 +170,9 @@ scene.circle(name, r=, at=)
170
170
  scene.text(name, content, size=, at=)
171
171
  scene.polygon(name, points) # cm.ngon, cm.star
172
172
  scene.curve(name, points, w=) # smooth, through the points
173
+ scene.arc(name, r=, sweep=(0, 120)) # an arc, a dial, a pie slice
173
174
  scene.svg(name, "logo.svg", size=) # vector art, as real geometry
175
+ scene.image(name, "photo.jpg", size=) # a picture: PNG or JPEG
174
176
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
175
177
  scene.formula(name, r"\frac{a}{b}", size=, at=) # LaTeX, needs `typst`
176
178
  cm.measure(text, size) -> (w, h) # to size a box around text
@@ -27,7 +27,7 @@ pub use connection::{connection, Connection};
27
27
  pub use path::{path_node, ConcretePath, PathNode};
28
28
  pub use primitive::{
29
29
  primitive_circle, primitive_path, primitive_rect, primitive_text, ConcreteGeometry,
30
- ConcretePrimitive, Geometry, Primitive, Transformable,
30
+ ConcretePrimitive, Geometry, Pixels, Primitive, Transformable,
31
31
  };
32
32
  pub use pulse::{pulse_on, Pulse};
33
33
  pub use rect::{rect, ConcreteRect, Rect};
@@ -34,6 +34,36 @@ pub enum Geometry {
34
34
  font_size: Animated<f32>,
35
35
  align: TextAlign,
36
36
  },
37
+ /// A decoded raster picture, drawn into a box (ADR 0013).
38
+ ///
39
+ /// The pixels are shared rather than owned: a 1,200-frame render resolves
40
+ /// this once per frame, and copying a megabyte each time would be the
41
+ /// whole cost of the feature.
42
+ Image {
43
+ pixels: std::sync::Arc<Pixels>,
44
+ width: Animated<f32>,
45
+ height: Animated<f32>,
46
+ },
47
+ }
48
+
49
+ /// Decoded pixels: premultiplied RGBA, ready to blit.
50
+ ///
51
+ /// Core does no decoding — it would need a codec and an opinion about file
52
+ /// formats, and neither belongs in the layer that owns no I/O. Something
53
+ /// upstream hands these over already decoded.
54
+ #[derive(Clone, PartialEq)]
55
+ pub struct Pixels {
56
+ pub width: u32,
57
+ pub height: u32,
58
+ pub rgba: Vec<u8>,
59
+ }
60
+
61
+ // Dimensions only. The derived version would print several megabytes of
62
+ // channel bytes the first time anyone put a Scene in a debug log.
63
+ impl std::fmt::Debug for Pixels {
64
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65
+ write!(f, "Pixels({}x{})", self.width, self.height)
66
+ }
37
67
  }
38
68
 
39
69
  /// A `Geometry` resolved at a specific `t` — plain shape data, still local-space.
@@ -54,6 +84,11 @@ pub enum ConcreteGeometry {
54
84
  font_size: f32,
55
85
  align: TextAlign,
56
86
  },
87
+ Image {
88
+ pixels: std::sync::Arc<Pixels>,
89
+ width: f32,
90
+ height: f32,
91
+ },
57
92
  }
58
93
 
59
94
  impl Geometry {
@@ -110,6 +145,7 @@ impl Geometry {
110
145
  }
111
146
  }
112
147
  Geometry::Text { .. } => Vec2::new(0.0, 0.0),
148
+ Geometry::Image { .. } => Vec2::new(0.0, 0.0),
113
149
  }
114
150
  }
115
151
 
@@ -134,10 +170,34 @@ impl Geometry {
134
170
  font_size: font_size.resolve(t),
135
171
  align: *align,
136
172
  },
173
+ Geometry::Image {
174
+ pixels,
175
+ width,
176
+ height,
177
+ } => ConcreteGeometry::Image {
178
+ pixels: pixels.clone(),
179
+ width: width.resolve(t),
180
+ height: height.resolve(t),
181
+ },
137
182
  }
138
183
  }
139
184
 
140
185
  pub(crate) fn ease_with(self, curve: super::TimeCurve) -> Self {
186
+ // An image eases by its box; the pixels are the same pixels whatever
187
+ // the time is.
188
+ if let Geometry::Image {
189
+ pixels,
190
+ width,
191
+ height,
192
+ } = self
193
+ {
194
+ let width_curve = curve.clone();
195
+ return Geometry::Image {
196
+ pixels,
197
+ width: Animated::new(move |t| width.resolve(width_curve(t))),
198
+ height: Animated::new(move |t| height.resolve(curve(t))),
199
+ };
200
+ }
141
201
  match self {
142
202
  Geometry::Circle { radius } => Geometry::Circle {
143
203
  radius: Animated::new(move |t| radius.resolve(curve(t))),
@@ -164,6 +224,9 @@ impl Geometry {
164
224
  align,
165
225
  }
166
226
  }
227
+ // Handled above, before the match, because it needs to move its
228
+ // Arc rather than borrow it.
229
+ Geometry::Image { .. } => unreachable!("eased before the match"),
167
230
  }
168
231
  }
169
232
 
@@ -239,6 +302,7 @@ impl Geometry {
239
302
  Geometry::Rect { .. } => "Rect",
240
303
  Geometry::Path { .. } => "Path",
241
304
  Geometry::Text { .. } => "Text",
305
+ Geometry::Image { .. } => "Image",
242
306
  }
243
307
  }
244
308
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codimate-py"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -8,3 +8,9 @@ codimate-core = { path = "../codimate-core" }
8
8
  codimate-animation = { path = "../codimate-animation" }
9
9
  codimate-math = { path = "../codimate-math" }
10
10
  codimate-render = { path = "../codimate-render" }
11
+
12
+ # Decoding an imported picture (ADR 0013). `png` is already in the workspace;
13
+ # `jpeg-decoder` is the one dependency added purely for a feature, because
14
+ # photographs are JPEG and `jpeg-encoder` next door only writes them.
15
+ png = "0.18"
16
+ jpeg-decoder = "0.3"
@@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, OnceLock};
17
17
  use codimate_animation::Playable;
18
18
  use codimate_core::{
19
19
  scene::AnchorKind, scene::Transformable, tween, Animated, Color, ConcreteScene, Geometry,
20
- IntoAnimated, Path, Primitive, Scene, Segment, Style, TextAlign, Vec2,
20
+ IntoAnimated, Path, Pixels, Primitive, Scene, Segment, Style, TextAlign, Vec2,
21
21
  };
22
22
 
23
23
  /// Why a Scene could not be built.
@@ -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; 8] = [
89
- "rect", "circle", "text", "line", "formula", "polygon", "curve", "svg",
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" => {
@@ -285,7 +381,10 @@ impl Shape {
285
381
  // A formula is many glyph outlines and an imported SVG is many
286
382
  // paths, so neither can be one Geometry. `primitives()` expands
287
383
  // them; these arms are never reached.
288
- "formula" | "svg" => Geometry::rect(0.0.into_animated(), 0.0.into_animated()),
384
+ // `primitives()` handles these three and returns before asking
385
+ // for a Geometry: a formula and an SVG are many, and an image can
386
+ // fail to decode, which this signature cannot report.
387
+ "formula" | "svg" | "image" => Geometry::rect(0.0.into_animated(), 0.0.into_animated()),
289
388
 
290
389
  // Square corners stay a real Rect — the common case keeps the
291
390
  // cheaper primitive and the renderer's own rectangle path.
@@ -754,6 +853,110 @@ pub fn formula_glyphs(latex: &str) -> Result<Arc<Vec<Path>>> {
754
853
  Ok(shared)
755
854
  }
756
855
 
856
+ /// Decode, premultiply and cache a picture — once per path, for the life of
857
+ /// the process.
858
+ ///
859
+ /// Cached for the reason the formula glyphs and SVG artwork are: a 1,200-frame
860
+ /// render must not decode the same megabyte 1,200 times. As with those, it
861
+ /// also means editing the file mid-session and re-rendering shows the old
862
+ /// picture.
863
+ fn image_pixels(file: &str) -> Result<Arc<Pixels>> {
864
+ static CACHE: OnceLock<Mutex<HashMap<String, Arc<Pixels>>>> = OnceLock::new();
865
+ let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
866
+
867
+ if let Some(hit) = cache.lock().unwrap().get(file) {
868
+ return Ok(hit.clone());
869
+ }
870
+
871
+ let bytes = std::fs::read(file)
872
+ .map_err(|e| Error(format!("could not read the image {file:?}: {e}")))?;
873
+
874
+ // By content, not by extension: a `.png` that is really a JPEG is a
875
+ // mistake worth surviving, and the magic numbers are unambiguous.
876
+ let decoded = if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
877
+ decode_png(&bytes)
878
+ } else if bytes.starts_with(&[0xFF, 0xD8]) {
879
+ decode_jpeg(&bytes)
880
+ } else {
881
+ return Err(Error(format!(
882
+ "{file:?} is not a PNG or a JPEG — those are the two Codimate reads"
883
+ )));
884
+ }
885
+ .map_err(|m| Error(format!("could not read the image {file:?}: {m}")))?;
886
+
887
+ let shared = Arc::new(decoded);
888
+ cache
889
+ .lock()
890
+ .unwrap()
891
+ .insert(file.to_string(), shared.clone());
892
+ Ok(shared)
893
+ }
894
+
895
+ /// Straight RGBA to premultiplied, which is what tiny-skia blits.
896
+ fn premultiplied(width: u32, height: u32, rgba: Vec<u8>) -> Pixels {
897
+ let mut rgba = rgba;
898
+ for px in rgba.chunks_exact_mut(4) {
899
+ let a = px[3] as u32;
900
+ if a == 255 {
901
+ continue;
902
+ }
903
+ for c in 0..3 {
904
+ px[c] = ((px[c] as u32 * a + 127) / 255) as u8;
905
+ }
906
+ }
907
+ Pixels {
908
+ width,
909
+ height,
910
+ rgba,
911
+ }
912
+ }
913
+
914
+ fn decode_png(bytes: &[u8]) -> std::result::Result<Pixels, String> {
915
+ let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
916
+ // Normalise greyscale, palette and 16-bit down to 8-bit colour, and expand
917
+ // transparency into a real alpha channel, so the rest of this sees one
918
+ // layout instead of six.
919
+ decoder.set_transformations(
920
+ png::Transformations::normalize_to_color8() | png::Transformations::ALPHA,
921
+ );
922
+ let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
923
+ let mut buffer = vec![0; reader.output_buffer_size().unwrap_or(0)];
924
+ let info = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
925
+ buffer.truncate(info.buffer_size());
926
+
927
+ let rgba = match info.color_type {
928
+ png::ColorType::Rgba => buffer,
929
+ png::ColorType::GrayscaleAlpha => buffer
930
+ .chunks_exact(2)
931
+ .flat_map(|p| [p[0], p[0], p[0], p[1]])
932
+ .collect(),
933
+ other => return Err(format!("unsupported PNG colour type {other:?}")),
934
+ };
935
+ Ok(premultiplied(info.width, info.height, rgba))
936
+ }
937
+
938
+ fn decode_jpeg(bytes: &[u8]) -> std::result::Result<Pixels, String> {
939
+ let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(bytes));
940
+ let pixels = decoder.decode().map_err(|e| e.to_string())?;
941
+ let info = decoder.info().ok_or("no JPEG header")?;
942
+
943
+ // JPEG has no alpha, so every pixel is opaque and premultiplying is a
944
+ // no-op — the conversion is only about layout.
945
+ let rgba: Vec<u8> = match info.pixel_format {
946
+ jpeg_decoder::PixelFormat::RGB24 => pixels
947
+ .chunks_exact(3)
948
+ .flat_map(|p| [p[0], p[1], p[2], 255])
949
+ .collect(),
950
+ jpeg_decoder::PixelFormat::L8 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
951
+ other => return Err(format!("unsupported JPEG pixel format {other:?}")),
952
+ };
953
+ Ok(Pixels {
954
+ width: info.width as u32,
955
+ height: info.height as u32,
956
+ rgba,
957
+ })
958
+ }
959
+
757
960
  /// An imported SVG, ready to draw.
758
961
  ///
759
962
  /// Paths are centred on the artwork's own bounding box and scaled so its
@@ -800,6 +1003,28 @@ fn fit_scale(art: (f32, f32), fit: (f32, f32)) -> f32 {
800
1003
  by_w.min(by_h)
801
1004
  }
802
1005
 
1006
+ /// The size a picture is actually drawn at, fitted inside the box the author
1007
+ /// named with its aspect kept — the same rule `scene.svg` follows, so the two
1008
+ /// imports size the same way.
1009
+ fn fitted_box(pixels: &Pixels, fit: (f32, f32)) -> (f32, f32) {
1010
+ let natural = (pixels.width.max(1) as f32, pixels.height.max(1) as f32);
1011
+ if fit.0 <= 0.0 && fit.1 <= 0.0 {
1012
+ return natural;
1013
+ }
1014
+ let by_w = if fit.0 > 0.0 {
1015
+ fit.0 / natural.0
1016
+ } else {
1017
+ f32::MAX
1018
+ };
1019
+ let by_h = if fit.1 > 0.0 {
1020
+ fit.1 / natural.1
1021
+ } else {
1022
+ f32::MAX
1023
+ };
1024
+ let scale = by_w.min(by_h);
1025
+ (natural.0 * scale, natural.1 * scale)
1026
+ }
1027
+
803
1028
  /// Read, parse and normalise an SVG file — once per path, for the life of the
804
1029
  /// process.
805
1030
  ///
@@ -1047,6 +1272,26 @@ fn primitives(before: &Shape, after: &Shape, rules: &[Rule]) -> Result<Vec<Primi
1047
1272
  ));
1048
1273
  }
1049
1274
 
1275
+ // One Geometry, not an expansion — an image is a single thing. It is here
1276
+ // rather than in `geometry()` only because decoding can fail, and that
1277
+ // signature has no way to say so.
1278
+ if before.kind == "image" {
1279
+ let pixels = image_pixels(&before.text)?;
1280
+ let from = fitted_box(&pixels, (before.w, before.h));
1281
+ let to = fitted_box(&pixels, (after.w, after.h));
1282
+ return Ok(vec![Primitive::new(Geometry::Image {
1283
+ pixels,
1284
+ width: tween(from.0, to.0),
1285
+ height: tween(from.1, to.1),
1286
+ })
1287
+ .pos(path)
1288
+ .scale_xy(scale)
1289
+ .rotate(spin)
1290
+ .pivot(pivot)
1291
+ .style(style)
1292
+ .opacity(opacity)]);
1293
+ }
1294
+
1050
1295
  if before.kind == "svg" {
1051
1296
  // Same shape as a formula: one Item, many outlines, revealed left to
1052
1297
  // right. The difference is that the artwork brought its own colours
@@ -1234,6 +1479,9 @@ fn bounds(shape: &Shape) -> (f32, f32, f32, f32) {
1234
1479
  // The fitted box, not the box asked for: a wide drawing in a square
1235
1480
  // `size` does not fill the square, and framing the square would leave
1236
1481
  // the camera looking at empty air above and below.
1482
+ "image" => image_pixels(&shape.text)
1483
+ .map(|p| fitted_box(&p, (shape.w, shape.h)))
1484
+ .unwrap_or((0.0, 0.0)),
1237
1485
  "svg" => svg_art(&shape.text)
1238
1486
  .map(|art| {
1239
1487
  let s = fit_scale(art.size, (shape.w, shape.h));
@@ -1256,6 +1504,9 @@ fn bounds(shape: &Shape) -> (f32, f32, f32, f32) {
1256
1504
  ys.iter().cloned().fold(f32::MIN, f32::max),
1257
1505
  );
1258
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),
1259
1510
  "line" => {
1260
1511
  let (x0, x1) = (shape.x.min(shape.x2), shape.x.max(shape.x2));
1261
1512
  let (y0, y1) = (shape.y.min(shape.y2), shape.y.max(shape.y2));
@@ -1701,6 +1952,183 @@ mod tests {
1701
1952
  assert_ne!(start, mid, "a swap must not snap at t=0");
1702
1953
  }
1703
1954
 
1955
+ /// An SVG on disk, because `svg_art` reads a path rather than a string —
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
+
2059
+ fn svg_on_disk(name: &str, body: &str) -> String {
2060
+ let path = std::env::temp_dir().join(format!("codimate-test-{name}.svg"));
2061
+ std::fs::write(&path, body).expect("temp file");
2062
+ path.to_string_lossy().into_owned()
2063
+ }
2064
+
2065
+ fn svg_shape(file: &str, colour: &str, fit: (f32, f32)) -> Shape {
2066
+ Shape {
2067
+ item: "art".into(),
2068
+ kind: "svg".into(),
2069
+ x: 0.0,
2070
+ y: 0.0,
2071
+ w: fit.0,
2072
+ h: fit.1,
2073
+ text: file.into(),
2074
+ color: colour.into(),
2075
+ r: 1.0,
2076
+ opacity: 1.0,
2077
+ ..Default::default()
2078
+ }
2079
+ }
2080
+
2081
+ const TWO_SHAPES: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
2082
+ <rect x="0" y="0" width="40" height="40" fill="#ff0000"/>
2083
+ <rect x="60" y="0" width="40" height="40" fill="#0000ff"/>
2084
+ </svg>"##;
2085
+
2086
+ /// The sentinel that makes an import look like the file it came from
2087
+ /// (ADR 0014). An empty `color` means "as authored"; anything else
2088
+ /// flattens the drawing. If `style()` ever stopped honouring the empty
2089
+ /// string, every import would silently go white.
2090
+ #[test]
2091
+ fn an_empty_colour_keeps_the_artwork_own_fills() {
2092
+ let file = svg_on_disk("authored", TWO_SHAPES);
2093
+
2094
+ let as_drawn = svg_shape(&file, "", (100.0, 100.0));
2095
+ let parts = primitives(&as_drawn, &as_drawn, &[]).expect("should expand");
2096
+ assert_eq!(parts.len(), 2, "one primitive per path in the file");
2097
+
2098
+ let fills: Vec<Color> = parts.iter().map(|p| p.resolve(0.0).style.fill).collect();
2099
+ assert!(
2100
+ fills.iter().any(|c| c.r > 0.9 && c.b < 0.1)
2101
+ && fills.iter().any(|c| c.b > 0.9 && c.r < 0.1),
2102
+ "the red and the blue should both survive, got {fills:?}"
2103
+ );
2104
+
2105
+ // And naming a colour overrides every path at once.
2106
+ let flattened = svg_shape(&file, "white", (100.0, 100.0));
2107
+ let parts = primitives(&flattened, &flattened, &[]).expect("should expand");
2108
+ for part in &parts {
2109
+ let fill = part.resolve(0.0).style.fill;
2110
+ assert!(fill.r > 0.9 && fill.g > 0.9 && fill.b > 0.9, "{fill:?}");
2111
+ }
2112
+ }
2113
+
2114
+ /// `size` is a box the drawing fits inside with its aspect kept, so a wide
2115
+ /// file in a square box does not fill the square — and `focus()` frames
2116
+ /// what is actually drawn rather than the box that was asked for.
2117
+ #[test]
2118
+ fn an_import_fits_its_box_without_distorting() {
2119
+ let file = svg_on_disk("fit", TWO_SHAPES);
2120
+ let wide = svg_shape(&file, "", (200.0, 200.0));
2121
+
2122
+ let (x0, y0, x1, y1) = bounds(&wide);
2123
+ let (w, h) = (x1 - x0, y1 - y0);
2124
+ assert!(
2125
+ (w - 200.0).abs() < 0.5,
2126
+ "the long side fills the box, got {w}"
2127
+ );
2128
+ // The artwork is 100x40 of drawn content, so the short side must not.
2129
+ assert!(h < w * 0.6, "aspect kept: {w} by {h}");
2130
+ }
2131
+
1704
2132
  fn curve_of(points: &[(f32, f32)], closed: bool) -> Shape {
1705
2133
  let flat: Vec<f32> = points.iter().flat_map(|(x, y)| [*x, *y]).collect();
1706
2134
  let xs: Vec<f32> = points.iter().map(|p| p.0).collect();
@@ -28,6 +28,18 @@ pub enum RenderCommand {
28
28
  height: f32,
29
29
  fill: Color,
30
30
  },
31
+ /// A decoded picture and the affine that places it.
32
+ ///
33
+ /// Every other command is geometry that can be transformed point by point.
34
+ /// A picture cannot, so the transform travels with it, as
35
+ /// `[sx, ky, kx, sy, tx, ty]` mapping the image's own pixel space to the
36
+ /// frame. `opacity` rides here rather than in a fill, because an image has
37
+ /// no fill colour to carry it.
38
+ Image {
39
+ pixels: std::sync::Arc<codimate_core::Pixels>,
40
+ transform: [f32; 6],
41
+ opacity: f32,
42
+ },
31
43
  Path {
32
44
  segments: Vec<Segment>,
33
45
  closed: bool,
@@ -127,6 +139,42 @@ fn render_primitive_commands(primitive: &ConcretePrimitive) -> Vec<RenderCommand
127
139
  stroke_color: stroke,
128
140
  }]
129
141
  }
142
+ ConcreteGeometry::Image {
143
+ pixels,
144
+ width,
145
+ height,
146
+ } => {
147
+ // The same mapping `transform_point` performs, written as an
148
+ // affine because a picture is blitted rather than walked. Doing it
149
+ // by corner points would place the box but lose the rotation,
150
+ // which is what an earlier version of this did.
151
+ let t = &primitive.transform;
152
+ let (sin, cos) = t.rotation_deg.to_radians().sin_cos();
153
+ let (a, b) = (t.scale.x * cos, t.scale.x * sin);
154
+ let (c, d) = (-t.scale.y * sin, t.scale.y * cos);
155
+ let e = t.pos.x + t.pivot.x - t.pivot.x * a - t.pivot.y * c;
156
+ let f = t.pos.y + t.pivot.y - t.pivot.x * b - t.pivot.y * d;
157
+
158
+ // Composed with the map from the image's pixels to its box, which
159
+ // is centred on the primitive's origin like every other geometry.
160
+ let (w, h) = (*width, *height);
161
+ let (px, py) = (
162
+ w / pixels.width.max(1) as f32,
163
+ h / pixels.height.max(1) as f32,
164
+ );
165
+ vec![RenderCommand::Image {
166
+ pixels: pixels.clone(),
167
+ transform: [
168
+ a * px,
169
+ b * px,
170
+ c * py,
171
+ d * py,
172
+ e - a * w / 2.0 - c * h / 2.0,
173
+ f - b * w / 2.0 - d * h / 2.0,
174
+ ],
175
+ opacity: t.opacity,
176
+ }]
177
+ }
130
178
  ConcreteGeometry::Text {
131
179
  text,
132
180
  font_size,
@@ -245,3 +293,80 @@ pub trait Renderer {
245
293
 
246
294
  fn render(&mut self, frame: &LayoutFrame) -> Result<(), Self::Error>;
247
295
  }
296
+
297
+ #[cfg(test)]
298
+ mod image_tests {
299
+ use super::*;
300
+ use codimate_core::{ConcretePrimitive, ConcreteTransform, Pixels, Style, Vec2};
301
+ use std::sync::Arc;
302
+
303
+ fn one_red_pixel() -> Arc<Pixels> {
304
+ Arc::new(Pixels {
305
+ width: 1,
306
+ height: 1,
307
+ rgba: vec![255, 0, 0, 255],
308
+ })
309
+ }
310
+
311
+ fn placed(rotation_deg: f32, scale: Vec2) -> ConcretePrimitive {
312
+ ConcretePrimitive {
313
+ transform: ConcreteTransform {
314
+ pos: Vec2::new(100.0, 50.0),
315
+ scale,
316
+ rotation_deg,
317
+ pivot: Vec2::new(0.0, 0.0),
318
+ opacity: 1.0,
319
+ },
320
+ style: Style::new(),
321
+ geometry: ConcreteGeometry::Image {
322
+ pixels: one_red_pixel(),
323
+ width: 20.0,
324
+ height: 10.0,
325
+ },
326
+ }
327
+ }
328
+
329
+ fn affine_of(primitive: &ConcretePrimitive) -> [f32; 6] {
330
+ match render_primitive_commands(primitive).remove(0) {
331
+ RenderCommand::Image { transform, .. } => transform,
332
+ other => panic!("expected an Image command, got {other:?}"),
333
+ }
334
+ }
335
+
336
+ /// The regression this exists for. Every other command is geometry and is
337
+ /// transformed point by point; the first version of the image command
338
+ /// placed the picture by transforming its centre and emitting an
339
+ /// axis-aligned box, which put it in exactly the right place and silently
340
+ /// threw the rotation away. Nothing caught it but looking at a picture.
341
+ #[test]
342
+ fn a_turned_image_carries_its_rotation() {
343
+ let [_, ky, kx, _, _, _] = affine_of(&placed(25.0, Vec2::new(1.0, 1.0)));
344
+ assert!(
345
+ ky.abs() > 0.01 && kx.abs() > 0.01,
346
+ "a rotated image must have skew terms, got kx={kx} ky={ky}"
347
+ );
348
+
349
+ let [_, ky, kx, _, _, _] = affine_of(&placed(0.0, Vec2::new(1.0, 1.0)));
350
+ assert!(
351
+ ky.abs() < 1e-6 && kx.abs() < 1e-6,
352
+ "an unrotated image must not, got kx={kx} ky={ky}"
353
+ );
354
+ }
355
+
356
+ /// The affine maps the image's own pixel space, so a one-pixel source
357
+ /// drawn into a 20x10 box scales by exactly that.
358
+ #[test]
359
+ fn the_affine_maps_pixels_to_the_box() {
360
+ let [sx, _, _, sy, tx, ty] = affine_of(&placed(0.0, Vec2::new(1.0, 1.0)));
361
+ assert_eq!((sx, sy), (20.0, 10.0), "one source pixel fills the box");
362
+ // Centred on the primitive's position, like every other geometry.
363
+ assert_eq!((tx, ty), (100.0 - 10.0, 50.0 - 5.0));
364
+ }
365
+
366
+ /// `.grow()` reaches an image, unlike `.turn()` before this was fixed.
367
+ #[test]
368
+ fn scale_reaches_an_image() {
369
+ let [sx, _, _, sy, _, _] = affine_of(&placed(0.0, Vec2::new(2.0, 0.5)));
370
+ assert_eq!((sx, sy), (40.0, 5.0));
371
+ }
372
+ }
@@ -88,6 +88,36 @@ fn rasterize_commands(viewport: Viewport, commands: &[RenderCommand], pixel_scal
88
88
  pixmap.fill_path(&path, &paint, FillRule::Winding, transform, None);
89
89
  }
90
90
  }
91
+ RenderCommand::Image {
92
+ pixels,
93
+ transform: affine,
94
+ opacity,
95
+ } => {
96
+ if let Some(view) = tiny_skia::PixmapRef::from_bytes(
97
+ &pixels.rgba,
98
+ pixels.width.max(1),
99
+ pixels.height.max(1),
100
+ ) {
101
+ let placed = transform.pre_concat(tiny_skia::Transform::from_row(
102
+ affine[0], affine[1], affine[2], affine[3], affine[4], affine[5],
103
+ ));
104
+ pixmap.draw_pixmap(
105
+ 0,
106
+ 0,
107
+ view,
108
+ &tiny_skia::PixmapPaint {
109
+ opacity: opacity.clamp(0.0, 1.0),
110
+ // Bilinear: an image is nearly always drawn at some
111
+ // size other than its own, and nearest neighbour on
112
+ // a photograph is visibly wrong.
113
+ quality: tiny_skia::FilterQuality::Bilinear,
114
+ ..Default::default()
115
+ },
116
+ placed,
117
+ None,
118
+ );
119
+ }
120
+ }
91
121
  RenderCommand::Rect {
92
122
  x,
93
123
  y,
@@ -4,6 +4,7 @@ use codimate_core::{circle_path, Color, Segment, TextAlign, Vec2};
4
4
  use codimate_fonts::FontRegistry;
5
5
  use codimate_layout::Viewport;
6
6
  use codimate_render::{rasterize, RenderCommand, RenderFrame};
7
+ use std::sync::Arc;
7
8
 
8
9
  fn red_circle_frame() -> RenderFrame {
9
10
  RenderFrame {
@@ -334,3 +335,73 @@ fn rasterize_path_cubic_circle() {
334
335
  // Far corner is black (background)
335
336
  assert_eq!(img.pixel(0, 0), (0, 0, 0, 255));
336
337
  }
338
+
339
+ /// A picture actually reaching the pixels — the end of the path that ADR 0013
340
+ /// added and that nothing else here covers.
341
+ fn image_frame(transform: [f32; 6], opacity: f32) -> RenderFrame {
342
+ // One solid red pixel, stretched by the affine into whatever box it names.
343
+ let pixels = Arc::new(codimate_core::Pixels {
344
+ width: 1,
345
+ height: 1,
346
+ rgba: vec![255, 0, 0, 255],
347
+ });
348
+ RenderFrame {
349
+ name: "image-test".to_string(),
350
+ elapsed_seconds: 0.0,
351
+ viewport: Viewport::new(100.0, 100.0),
352
+ commands: vec![RenderCommand::Image {
353
+ pixels,
354
+ transform,
355
+ opacity,
356
+ }],
357
+ }
358
+ }
359
+
360
+ #[test]
361
+ fn rasterize_image_fills_the_box_it_is_given() {
362
+ // Twenty by twenty, placed at (40, 40).
363
+ let img = rasterize(&image_frame([20.0, 0.0, 0.0, 20.0, 40.0, 40.0], 1.0));
364
+
365
+ assert_eq!(img.pixel(50, 50), (255, 0, 0, 255), "inside the box");
366
+ assert_eq!(img.pixel(5, 5), (0, 0, 0, 255), "outside it");
367
+ assert_eq!(img.pixel(90, 90), (0, 0, 0, 255), "and past the far edge");
368
+ }
369
+
370
+ /// The regression the command-level test guards from the other side: a turned
371
+ /// image must actually land turned. A square drawn axis-aligned has black
372
+ /// corners inside its bounding box only when it is rotated.
373
+ #[test]
374
+ fn rasterize_image_honours_rotation() {
375
+ let (sin, cos) = 45f32.to_radians().sin_cos();
376
+ let (w, h) = (30.0, 30.0);
377
+ // The same composition `render_primitive_commands` builds: rotate, then
378
+ // offset so the box is centred on (50, 50).
379
+ let (a, b, c, d) = (cos * w, sin * w, -sin * h, cos * h);
380
+ let turned = rasterize(&image_frame(
381
+ [a, b, c, d, 50.0 - (a + c) / 2.0, 50.0 - (b + d) / 2.0],
382
+ 1.0,
383
+ ));
384
+ let square = rasterize(&image_frame([w, 0.0, 0.0, h, 35.0, 35.0], 1.0));
385
+
386
+ // Centre is red either way; the corner of the upright square is red and
387
+ // the corner of the diamond is not, because it rotated away.
388
+ assert_eq!(turned.pixel(50, 50), (255, 0, 0, 255));
389
+ assert_eq!(square.pixel(50, 50), (255, 0, 0, 255));
390
+ assert_eq!(
391
+ square.pixel(37, 37),
392
+ (255, 0, 0, 255),
393
+ "upright fills its corner"
394
+ );
395
+ assert_eq!(turned.pixel(37, 37), (0, 0, 0, 255), "turned does not");
396
+ }
397
+
398
+ #[test]
399
+ fn rasterize_image_honours_opacity() {
400
+ let half = rasterize(&image_frame([20.0, 0.0, 0.0, 20.0, 40.0, 40.0], 0.5));
401
+ let (r, g, b, a) = half.pixel(50, 50);
402
+ assert!(
403
+ (100..=160).contains(&r) && g == 0 && b == 0 && a == 255,
404
+ "half-opaque red over black should be mid red, got {:?}",
405
+ (r, g, b, a)
406
+ );
407
+ }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "codimate"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Turn a running algorithm into an explainer video"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -20,8 +20,8 @@ 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`,
24
- `text`, `line`, `formula`, `svg`
23
+ - `Scene` — one picture: `rect`, `circle`, `arc`, `polygon`, `curve`, `arrow`,
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
27
27
  - `Handle` — what a shape call returns: `.fill()`, `.round()`, `.turn()`,
@@ -290,6 +290,56 @@ 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
+
323
+ def image(self, key: Hashable, file, *, size=None, at=None) -> "Handle":
324
+ """A picture — a photo, a screenshot, a figure — drawn into the frame.
325
+
326
+ scene.image("paper", "figures/attention.png", size=(520, 300))
327
+ scene.image("shot", "screen.png", size=400).on(opacity=0.4)
328
+
329
+ PNG and JPEG, read by content rather than by extension. `size` is a box
330
+ it fits inside, one number or `(w, h)`, aspect always kept; leave it out
331
+ and the picture is drawn at its own pixel size.
332
+
333
+ It moves, scales, turns and fades like anything else, and `focus()`
334
+ frames it. For a logo or a diagram prefer `svg`, which arrives as
335
+ geometry you can recolour and draw on — an image is pixels, so it can
336
+ only be placed.
337
+ """
338
+ box = (size, size) if isinstance(size, (int, float)) else size
339
+ w, h = (0.0, 0.0) if box is None else (float(box[0]), float(box[1]))
340
+ x, y = self._where(at, 0.0)
341
+ return self._place(key, "image", x=x, y=y, text=str(file), w=w, h=h)
342
+
293
343
  def svg(self, key: Hashable, file, *, size=120.0, at=None) -> "Handle":
294
344
  """Vector art from a file, drawn as real geometry.
295
345
 
File without changes