gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! A rectangle turned about its centre: what a fixture or a pixel covers on a
//! canvas, for drawing its outline and for hitting it with the pointer or a
//! rubber band.

use egui::emath::{Rot2, TSTransform};
use egui::{Pos2, Rect, Vec2, vec2};

/// A rectangle `2 · half` big, centred on `centre` and turned by `angle`
/// radians, clockwise with y pointing down.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct RotatedRect {
    pub(crate) centre: Pos2,
    pub(crate) half: Vec2,
    pub(crate) angle: f32,
}

impl RotatedRect {
    /// The corners, clockwise from the one at the rectangle's own top left.
    pub(crate) fn corners(&self) -> [Pos2; 4] {
        let rotation = Rot2::from_angle(self.angle);
        [
            vec2(-self.half.x, -self.half.y),
            vec2(self.half.x, -self.half.y),
            vec2(self.half.x, self.half.y),
            vec2(-self.half.x, self.half.y),
        ]
        .map(|corner| self.centre + rotation * corner)
    }

    pub(crate) fn contains(&self, point: Pos2) -> bool {
        let local = Rot2::from_angle(-self.angle) * (point - self.centre);
        local.x.abs() <= self.half.x && local.y.abs() <= self.half.y
    }

    /// Whether the rectangle and the axis-aligned `aabb` overlap: a corner of
    /// one lies inside the other, or their edges cross.
    pub(crate) fn intersects(&self, aabb: Rect) -> bool {
        let corners = self.corners();
        let aabb_corners = [
            aabb.left_top(),
            aabb.right_top(),
            aabb.right_bottom(),
            aabb.left_bottom(),
        ];
        if corners.iter().any(|corner| aabb.contains(*corner))
            || aabb_corners.iter().any(|corner| self.contains(*corner))
        {
            return true;
        }
        let edges = |points: [Pos2; 4]| (0..4).map(move |i| (points[i], points[(i + 1) % 4]));
        edges(corners)
            .any(|(p1, p2)| edges(aabb_corners).any(|(q1, q2)| segments_intersect(p1, p2, q1, q2)))
    }

    /// The axis-aligned box around it.
    pub(crate) fn bounds(&self) -> Rect {
        Rect::from_points(&self.corners())
    }

    /// The same rectangle seen through `transform`, from scene units to the
    /// screen, say.
    pub(crate) fn transformed(&self, transform: TSTransform) -> Self {
        Self {
            centre: transform * self.centre,
            half: self.half * transform.scaling,
            angle: self.angle,
        }
    }

    /// The point `t` of the way along its length axis, through its middle:
    /// 0 is the middle of its leading edge, 1 of its trailing one.
    pub(crate) fn along_axis(&self, t: f32) -> Pos2 {
        self.centre + Rot2::from_angle(self.angle) * vec2((2.0 * t - 1.0) * self.half.x, 0.0)
    }
}

/// Whether the segments `p1`–`p2` and `q1`–`q2` cross. Parallel ones never
/// do: overlapping parallel edges have a corner inside the other shape.
fn segments_intersect(p1: Pos2, p2: Pos2, q1: Pos2, q2: Pos2) -> bool {
    let cross = |a: Vec2, b: Vec2| a.x * b.y - a.y * b.x;
    let r = p2 - p1;
    let s = q2 - q1;
    let r_cross_s = cross(r, s);
    if r_cross_s.abs() < 1e-6 {
        return false;
    }
    let q_minus_p = q1 - p1;
    let t = cross(q_minus_p, s) / r_cross_s;
    let u = cross(q_minus_p, r) / r_cross_s;
    (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)
}

#[cfg(test)]
mod tests {
    use super::*;
    use egui::pos2;
    use std::f32::consts::FRAC_PI_4;

    fn diamond() -> RotatedRect {
        // a 20 × 20 square turned by 45°, reaching ~14.1 from its centre
        RotatedRect {
            centre: pos2(0.0, 0.0),
            half: vec2(10.0, 10.0),
            angle: FRAC_PI_4,
        }
    }

    #[test]
    fn a_turned_rectangle_contains_what_it_covers() {
        assert!(diamond().contains(pos2(13.0, 0.0)));
        assert!(
            !diamond().contains(pos2(9.0, 9.0)),
            "beside the turned edge"
        );
    }

    #[test]
    fn a_box_that_only_crosses_the_edges_intersects() {
        // neither holds a corner of the other: a thin band across the middle
        let band = Rect::from_min_max(pos2(-20.0, -1.0), pos2(20.0, 1.0));
        assert!(diamond().intersects(band));
        let beside = Rect::from_min_max(pos2(9.0, 9.0), pos2(20.0, 20.0));
        assert!(!diamond().intersects(beside));
    }

    #[test]
    fn its_axis_runs_from_edge_to_edge() {
        let bar = RotatedRect {
            centre: pos2(50.0, 0.0),
            half: vec2(50.0, 2.0),
            angle: 0.0,
        };
        assert_eq!(bar.along_axis(0.0), pos2(0.0, 0.0));
        assert_eq!(bar.along_axis(1.0), pos2(100.0, 0.0));
    }

    #[test]
    fn a_transform_carries_the_whole_rectangle() {
        let screen = diamond().transformed(TSTransform::new(vec2(5.0, 5.0), 2.0));
        assert_eq!(screen.centre, pos2(5.0, 5.0));
        assert_eq!(screen.half, vec2(20.0, 20.0));
        let corners = diamond()
            .corners()
            .map(|corner| corner * 2.0 + vec2(5.0, 5.0));
        for (a, b) in screen.corners().iter().zip(corners) {
            assert!((*a - b).length() < 1e-4);
        }
    }
}