emblema_entity/lib.rs
1//! Entity and Contents layer: backend-agnostic description of what to draw.
2//! An Entity carries a transform, blend, clip depth, contents, and geometry.
3//!
4//! What is built here is coverage, and only coverage. The canvas above already
5//! records into a batch directly and does not route through this layer, so
6//! anything else would be shaped around a guess about a caller that does not
7//! exist yet. Coverage is the exception because it is not a guess: the canvas
8//! computes it today in four scattered places -- bounds expanded by a stroke,
9//! a layer's outward reach, what a mask blur covers, and the device bounds a
10//! save layer is sized to -- and every one of them is this same composition
11//! done by hand. Writing it once, with the rules stated and tested, is
12//! worthwhile whether or not the rest of the layer is ever built.
13//!
14//! The composition, in order: geometry bounds in the entity's own coordinates,
15//! carried through the transform into device coordinates, grown by however far
16//! the contents reach outward, and finally narrowed by the clip. The order
17//! matters at both ends. A filter reaches in device pixels rather than in the
18//! entity's units, so growing before the transform would scale the reach along
19//! with the shape. And the clip narrows last because it is the only step that
20//! can make coverage empty for a reason other than the shape being empty.
21
22use emblema_geometry::Rect;
23use emblema_hal::BlendMode;
24use glam::{Affine2, Vec2};
25
26/// Where an entity lands, or that it lands nowhere, or that it lands wherever
27/// it is allowed to.
28///
29/// Three cases rather than a rectangle, because two of them are not rectangles.
30/// Nothing drawn has no bounds to report and must not be confused with a
31/// zero-area rectangle at the origin, which is a real place. And `drawPaint`
32/// covers whatever the clip admits, which is unbounded until a clip says
33/// otherwise -- representing that as a very large rectangle would be a number
34/// chosen by whoever wrote it, and would be wrong at some scale.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum Coverage {
37 /// Nothing is drawn.
38 Empty,
39 /// Everything inside this rectangle, in device coordinates, may be.
40 Bounded(Rect),
41 /// Whatever the clip admits. Only a clip can bound this.
42 Unbounded,
43}
44
45impl Coverage {
46 /// The rectangle this covers, given what the clip admits.
47 ///
48 /// `None` for nothing drawn. An unbounded coverage resolves to the clip,
49 /// which is what makes an unclipped `drawPaint` the one case that cannot
50 /// answer -- the caller has to supply the target's bounds as the clip.
51 pub fn resolve(self, clip: Option<Rect>) -> Option<Rect> {
52 match self {
53 Self::Empty => None,
54 Self::Bounded(rect) => match clip {
55 Some(clip) => intersect(rect, clip),
56 None => Some(rect),
57 },
58 Self::Unbounded => clip,
59 }
60 }
61
62 /// Narrow by what a clip admits.
63 ///
64 /// An unbounded coverage becomes the clip, which is the whole reason the
65 /// unbounded case can be carried around at all rather than being resolved
66 /// where it arises.
67 pub fn clipped(self, clip: Rect) -> Self {
68 match self {
69 Self::Empty => Self::Empty,
70 Self::Bounded(rect) => match intersect(rect, clip) {
71 Some(rect) => Self::Bounded(rect),
72 None => Self::Empty,
73 },
74 Self::Unbounded => Self::Bounded(clip),
75 }
76 }
77
78 /// Grow outward by this much in each direction, in device pixels.
79 ///
80 /// Growing nothing leaves nothing: a filter that reaches outward from a
81 /// shape that was never drawn still draws nothing, and a blur of an empty
82 /// region is empty rather than a soft patch of nowhere.
83 pub fn grown(self, reach: Vec2) -> Self {
84 match self {
85 Self::Bounded(rect) if reach.is_finite() => {
86 Self::Bounded(Rect::new(rect.min - reach.abs(), rect.max + reach.abs()))
87 }
88 other => other,
89 }
90 }
91
92 /// The smallest coverage containing both.
93 pub fn union(self, other: Self) -> Self {
94 match (self, other) {
95 (Self::Unbounded, _) | (_, Self::Unbounded) => Self::Unbounded,
96 (Self::Empty, x) | (x, Self::Empty) => x,
97 (Self::Bounded(a), Self::Bounded(b)) => {
98 Self::Bounded(Rect::new(a.min.min(b.min), a.max.max(b.max)))
99 }
100 }
101 }
102}
103
104/// The region an entity's geometry occupies, in the entity's own coordinates.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub enum Geometry {
107 /// Nothing.
108 Empty,
109 /// A shape with these bounds. Already grown by a stroke if there is one:
110 /// how far a stroke reaches past its path is the stroker's rule, not this
111 /// layer's, and re-deriving it here would be a second copy to disagree.
112 Bounded(Rect),
113 /// Fills whatever the clip admits, which is what `drawPaint` means.
114 Unbounded,
115}
116
117impl Geometry {
118 /// Coverage in device coordinates.
119 ///
120 /// The bounding box of the four transformed corners, which is a superset of
121 /// the transformed shape whenever the transform rotates or shears -- a
122 /// square turned an eighth of a turn needs a box half again its area. A
123 /// superset is the safe direction: coverage decides what may be skipped and
124 /// how large an offscreen target is, and both are wrong in a way that shows
125 /// only if the answer is too small.
126 pub fn transformed(self, transform: Affine2) -> Coverage {
127 match self {
128 Self::Empty => Coverage::Empty,
129 Self::Unbounded => Coverage::Unbounded,
130 Self::Bounded(rect) if rect.is_empty() => Coverage::Empty,
131 Self::Bounded(rect) => {
132 let corners = [
133 Vec2::new(rect.min.x, rect.min.y),
134 Vec2::new(rect.max.x, rect.min.y),
135 Vec2::new(rect.max.x, rect.max.y),
136 Vec2::new(rect.min.x, rect.max.y),
137 ]
138 .map(|corner| transform.transform_point2(corner));
139 // A transform carrying a corner to infinity or to no number at
140 // all describes no region. Reporting the bounding box of those
141 // corners would be a rectangle of infinities, which every later
142 // intersection would silently accept.
143 if !corners.iter().all(|corner| corner.is_finite()) {
144 return Coverage::Empty;
145 }
146 let mut bounds = Rect::empty();
147 for corner in corners {
148 bounds.union_point(corner);
149 }
150 Coverage::Bounded(bounds)
151 }
152 }
153 }
154}
155
156/// What an entity draws with, so far as coverage is concerned.
157///
158/// A material decides color and is invisible to this calculation; what matters
159/// here is only how far the result lands from the geometry that produced it. A
160/// blur carries color outward, a dilation does, an erosion does not, and a
161/// solid color does not -- so this is a distance rather than a paint.
162#[derive(Debug, Clone, Copy, PartialEq, Default)]
163pub struct Contents {
164 /// How far color lands outside the geometry, in device pixels, per axis.
165 pub reach: Vec2,
166}
167
168impl Contents {
169 /// Contents that stay inside their geometry, which is most of them.
170 pub fn tight() -> Self {
171 Self { reach: Vec2::ZERO }
172 }
173
174 /// Contents reaching this far outward, in device pixels.
175 pub fn reaching(reach: Vec2) -> Self {
176 Self { reach }
177 }
178}
179
180/// One thing to draw, described without reference to a device.
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct Entity {
183 /// Carries the geometry into device coordinates.
184 pub transform: Affine2,
185 pub blend: BlendMode,
186 /// Which clip generation this is inside, matching the stencil the renderer
187 /// keeps. Not used by coverage: a clip narrows what is drawn, and the
188 /// rectangle that clip admits is passed to [`Self::coverage`] separately,
189 /// because a depth is a name for a clip and not its shape.
190 pub clip_depth: u32,
191 pub contents: Contents,
192 pub geometry: Geometry,
193}
194
195impl Default for Entity {
196 fn default() -> Self {
197 Self {
198 transform: Affine2::IDENTITY,
199 blend: BlendMode::SrcOver,
200 clip_depth: 0,
201 contents: Contents::tight(),
202 geometry: Geometry::Empty,
203 }
204 }
205}
206
207impl Entity {
208 /// Where this lands, given what the clip admits.
209 ///
210 /// `None` for a clip means unclipped, which leaves an unbounded entity
211 /// unbounded -- see [`Coverage::resolve`] for why that is not answered
212 /// with a large rectangle.
213 pub fn coverage(&self, clip: Option<Rect>) -> Coverage {
214 let covered = self
215 .geometry
216 .transformed(self.transform)
217 .grown(self.contents.reach);
218 match clip {
219 Some(clip) => covered.clipped(clip),
220 None => covered,
221 }
222 }
223}
224
225/// The overlap of two rectangles, or `None` where they do not meet.
226fn intersect(a: Rect, b: Rect) -> Option<Rect> {
227 let min = a.min.max(b.min);
228 let max = a.max.min(b.max);
229 if min.x > max.x || min.y > max.y {
230 None
231 } else {
232 Some(Rect::new(min, max))
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use core::f32::consts::FRAC_PI_4;
240
241 fn rect(min_x: f32, min_y: f32, max_x: f32, max_y: f32) -> Rect {
242 Rect::new(Vec2::new(min_x, min_y), Vec2::new(max_x, max_y))
243 }
244
245 fn bounded(geometry: Rect) -> Entity {
246 Entity {
247 geometry: Geometry::Bounded(geometry),
248 ..Entity::default()
249 }
250 }
251
252 #[test]
253 fn a_transform_that_turns_a_shape_needs_a_larger_box_to_hold_it() {
254 // The bounding box of the transformed corners rather than the
255 // transformed bounding box, which are the same thing only while the
256 // transform keeps the axes. A two-by-two square turned an eighth of a
257 // turn stands on a corner and spans two root two, which is the number
258 // that says the corners were transformed rather than the extents.
259 let square = bounded(rect(-1.0, -1.0, 1.0, 1.0));
260 let upright = square.coverage(None);
261 assert_eq!(upright, Coverage::Bounded(rect(-1.0, -1.0, 1.0, 1.0)));
262
263 let turned = Entity {
264 transform: Affine2::from_angle(FRAC_PI_4),
265 ..square
266 }
267 .coverage(None);
268 let Coverage::Bounded(bounds) = turned else {
269 panic!("a turned square still covers something, got {turned:?}");
270 };
271 let half = core::f32::consts::SQRT_2;
272 assert!(
273 (bounds.max.x - half).abs() < 1e-5 && (bounds.max.y - half).abs() < 1e-5,
274 "an eighth turn should reach root two, got {bounds:?}"
275 );
276 // And it is a superset rather than the shape: the corners of this box
277 // are outside the turned square, which is the direction coverage is
278 // allowed to be wrong in.
279 assert!(bounds.width() > 2.0, "the box grew, got {}", bounds.width());
280 }
281
282 #[test]
283 fn a_filter_reaches_in_device_pixels_rather_than_in_the_shapes_own_units() {
284 // The order the composition is written in, and the reason it is not
285 // the other one. A filter's radius is a distance on the target, so the
286 // growth has to happen after the transform; growing first would send
287 // the reach through the transform along with the shape, and a shape
288 // drawn at ten times the scale would blur ten times as far.
289 let scaled = Entity {
290 transform: Affine2::from_scale(Vec2::splat(10.0)),
291 contents: Contents::reaching(Vec2::splat(4.0)),
292 ..bounded(rect(0.0, 0.0, 1.0, 1.0))
293 };
294 assert_eq!(
295 scaled.coverage(None),
296 Coverage::Bounded(rect(-4.0, -4.0, 14.0, 14.0)),
297 "the unit square scaled to ten, grown by four device pixels"
298 );
299 }
300
301 #[test]
302 fn the_clip_narrows_after_the_filter_has_reached() {
303 // Also an order, and also not the other one. What a clip admits is
304 // decided about the finished picture, so a blur reaches out from the
305 // shape and the clip then cuts the result. Clipping the geometry first
306 // and growing afterwards would let the blur back out past the clip --
307 // the same coverage a caller uses to size an offscreen target, which
308 // would then be too large by the reach on every side.
309 let entity = Entity {
310 contents: Contents::reaching(Vec2::splat(5.0)),
311 ..bounded(rect(0.0, 0.0, 10.0, 10.0))
312 };
313 let clip = rect(0.0, 0.0, 8.0, 8.0);
314 assert_eq!(
315 entity.coverage(Some(clip)),
316 Coverage::Bounded(rect(0.0, 0.0, 8.0, 8.0)),
317 "grown to -5..15 and then cut to the clip"
318 );
319 // Growing after clipping would have reached to thirteen.
320 let wrong = Coverage::Bounded(clip).grown(Vec2::splat(5.0));
321 assert_ne!(
322 entity.coverage(Some(clip)),
323 wrong,
324 "the two orders must not agree, or this test proves nothing"
325 );
326 }
327
328 #[test]
329 fn nothing_drawn_stays_nothing_however_it_is_filtered() {
330 // A blur of an empty region is empty rather than a soft patch of
331 // nowhere, and an empty rectangle is not a zero-area one at the origin
332 // -- which is a real place and has to survive.
333 let nothing = Entity::default();
334 assert_eq!(nothing.coverage(None), Coverage::Empty);
335 assert_eq!(
336 nothing.coverage(Some(rect(0.0, 0.0, 10.0, 10.0))),
337 Coverage::Empty
338 );
339 assert_eq!(
340 Coverage::Empty.grown(Vec2::splat(9.0)),
341 Coverage::Empty,
342 "growing nothing leaves nothing"
343 );
344
345 // Empty geometry, spelled as an inverted rectangle, is the same answer.
346 assert_eq!(
347 bounded(rect(10.0, 10.0, 0.0, 0.0)).coverage(None),
348 Coverage::Empty
349 );
350
351 // A transform that collapses the plane leaves a point, which is a place
352 // rather than nothing: something is drawn there, and a caller culling
353 // against an empty answer would skip a draw that marks the target.
354 let collapsed = Entity {
355 transform: Affine2::from_scale(Vec2::ZERO),
356 ..bounded(rect(0.0, 0.0, 10.0, 10.0))
357 };
358 assert_eq!(
359 collapsed.coverage(None),
360 Coverage::Bounded(rect(0.0, 0.0, 0.0, 0.0)),
361 "a collapsed shape is a point, not an absence"
362 );
363 }
364
365 #[test]
366 fn a_transform_to_nowhere_covers_nothing() {
367 // A corner carried to infinity or to no number at all describes no
368 // region. The bounding box of those corners is a rectangle of
369 // infinities, which every later intersection accepts without
370 // complaint -- so it is refused where it arises rather than passed on.
371 for bad in [f32::INFINITY, f32::NAN] {
372 let entity = Entity {
373 transform: Affine2::from_scale(Vec2::splat(bad)),
374 ..bounded(rect(1.0, 1.0, 2.0, 2.0))
375 };
376 assert_eq!(
377 entity.coverage(None),
378 Coverage::Empty,
379 "a transform by {bad} covers nothing"
380 );
381 }
382 // A reach that is not a length is ignored rather than propagated, on
383 // the same grounds.
384 assert_eq!(
385 Coverage::Bounded(rect(0.0, 0.0, 1.0, 1.0)).grown(Vec2::splat(f32::NAN)),
386 Coverage::Bounded(rect(0.0, 0.0, 1.0, 1.0))
387 );
388 }
389
390 #[test]
391 fn unbounded_coverage_is_only_ever_bounded_by_a_clip() {
392 // `drawPaint` covers whatever the clip admits. Representing that as a
393 // very large rectangle would be a number somebody chose, and wrong at
394 // some scale, so it stays a case of its own until a clip resolves it.
395 let paint = Entity {
396 geometry: Geometry::Unbounded,
397 ..Entity::default()
398 };
399 assert_eq!(paint.coverage(None), Coverage::Unbounded);
400 assert_eq!(
401 paint.coverage(Some(rect(2.0, 2.0, 6.0, 6.0))),
402 Coverage::Bounded(rect(2.0, 2.0, 6.0, 6.0))
403 );
404 // A transform does not bound it either: everywhere transformed is
405 // still everywhere.
406 assert_eq!(
407 Entity {
408 transform: Affine2::from_scale(Vec2::splat(0.5)),
409 ..paint
410 }
411 .coverage(None),
412 Coverage::Unbounded
413 );
414 // Unclipped, it is the one coverage that cannot answer with a
415 // rectangle, and says so rather than inventing one.
416 assert_eq!(Coverage::Unbounded.resolve(None), None);
417 assert_eq!(
418 Coverage::Unbounded.resolve(Some(rect(0.0, 0.0, 3.0, 3.0))),
419 Some(rect(0.0, 0.0, 3.0, 3.0))
420 );
421 }
422
423 #[test]
424 fn a_clip_that_misses_leaves_nothing() {
425 let entity = bounded(rect(0.0, 0.0, 4.0, 4.0));
426 assert_eq!(
427 entity.coverage(Some(rect(9.0, 9.0, 12.0, 12.0))),
428 Coverage::Empty
429 );
430 // Touching at a corner is a meeting rather than a miss: the shared
431 // point is covered by both, and a zero-area overlap is still a place.
432 assert_eq!(
433 entity.coverage(Some(rect(4.0, 4.0, 8.0, 8.0))),
434 Coverage::Bounded(rect(4.0, 4.0, 4.0, 4.0))
435 );
436 }
437
438 #[test]
439 fn union_takes_the_widest_claim() {
440 let a = Coverage::Bounded(rect(0.0, 0.0, 2.0, 2.0));
441 let b = Coverage::Bounded(rect(5.0, 1.0, 6.0, 9.0));
442 assert_eq!(a.union(b), Coverage::Bounded(rect(0.0, 0.0, 6.0, 9.0)));
443 // Empty is the identity, so a group of one draw covers what that draw
444 // covers rather than that draw and the origin.
445 assert_eq!(a.union(Coverage::Empty), a);
446 assert_eq!(Coverage::Empty.union(a), a);
447 // And unbounded absorbs, in either order.
448 assert_eq!(a.union(Coverage::Unbounded), Coverage::Unbounded);
449 assert_eq!(Coverage::Unbounded.union(a), Coverage::Unbounded);
450 }
451
452 #[test]
453 fn a_reach_grows_outward_whichever_sign_it_is_given() {
454 // A radius is a distance. A caller who computed one by subtraction and
455 // got it backwards should get the same region, not an inside-out one
456 // that then reads as empty.
457 let covered = Coverage::Bounded(rect(0.0, 0.0, 4.0, 4.0));
458 assert_eq!(
459 covered.grown(Vec2::new(-2.0, -2.0)),
460 covered.grown(Vec2::new(2.0, 2.0))
461 );
462 assert_eq!(
463 covered.grown(Vec2::new(2.0, 0.0)),
464 Coverage::Bounded(rect(-2.0, 0.0, 6.0, 4.0)),
465 "per axis, so a one-dimensional blur grows one dimension"
466 );
467 }
468}