Skip to main content

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}