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.
- {codimate-0.1.2 → codimate-0.1.4}/Cargo.lock +12 -1
- {codimate-0.1.2 → codimate-0.1.4}/PKG-INFO +3 -1
- {codimate-0.1.2 → codimate-0.1.4}/README.md +2 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/mod.rs +1 -1
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/primitive.rs +64 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-py/Cargo.toml +1 -1
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-reconcile/Cargo.toml +6 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-reconcile/src/lib.rs +432 -4
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/src/command.rs +125 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/src/raster.rs +30 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/tests/raster.rs +71 -0
- {codimate-0.1.2 → codimate-0.1.4}/pyproject.toml +1 -1
- {codimate-0.1.2 → codimate-0.1.4}/python/codimate/__init__.py +2 -2
- {codimate-0.1.2 → codimate-0.1.4}/python/codimate/scene.py +50 -0
- {codimate-0.1.2 → codimate-0.1.4}/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/animation.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/parallel.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/playable.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/sequence.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-animation/tests/stagger.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/builder.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/easing.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/glyph.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/manim_palette.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/path.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/circle.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/connection.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/path.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/pulse.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/rect.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/text.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/scene/transform.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/src/value.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/anchors.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/animated.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/circle.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/connection.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/easing.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/manim_palette.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/path.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/ports.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/pulse.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/rect.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/scene.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-core/tests/tween.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/tests/export.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-export/tests/png.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/build.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-fonts/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-glyph/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-glyph/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-layout/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-layout/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-layout/tests/layout.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-math/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-math/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-py/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/Cargo.toml +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/src/lib.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/crates/codimate-render/tests/render.rs +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/python/codimate/explain.py +0 -0
- {codimate-0.1.2 → codimate-0.1.4}/python/codimate/layout.py +0 -0
- {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.
|
|
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.
|
|
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
|
}
|
|
@@ -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;
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|