aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Transformaciones 2D (`translate`, `scale`, `rotate`, `skew`) como matrices afines.
//!
//! La escena guarda una por lote de primitivas (no una por instancia): en la GPU el vértice la aplica al quad, así que
//! un subárbol rotado cuesta lo mismo que uno sin rotar, y sin transformación no se paga nada.

use crate::gpu::scene::{MonochromeSprite, Quad, Shadow, Underline};

/// `x' = a·x + c·y + tx`, `y' = b·x + d·y + ty`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Affine {
    pub a: f32,
    pub b: f32,
    pub c: f32,
    pub d: f32,
    pub tx: f32,
    pub ty: f32,
}

impl Affine {
    pub const IDENTITY: Affine = Affine { a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 0.0, ty: 0.0 };

    pub const fn translate(x: f32, y: f32) -> Self {
        Self { tx: x, ty: y, ..Self::IDENTITY }
    }

    pub const fn scale(x: f32, y: f32) -> Self {
        Self { a: x, d: y, ..Self::IDENTITY }
    }

    /// Giro en sentido horario (el eje Y de la pantalla apunta hacia abajo), en grados.
    pub fn rotate(degrees: f32) -> Self {
        let (sin, cos) = degrees.to_radians().sin_cos();
        Self { a: cos, b: sin, c: -sin, d: cos, tx: 0.0, ty: 0.0 }
    }

    /// Inclinación en grados sobre cada eje (`skew-x-6`).
    pub fn skew(x_degrees: f32, y_degrees: f32) -> Self {
        Self { c: x_degrees.to_radians().tan(), b: y_degrees.to_radians().tan(), ..Self::IDENTITY }
    }

    /// Primero `self` y después `next`.
    pub fn then(self, next: Affine) -> Affine {
        Affine {
            a: next.a * self.a + next.c * self.b,
            b: next.b * self.a + next.d * self.b,
            c: next.a * self.c + next.c * self.d,
            d: next.b * self.c + next.d * self.d,
            tx: next.a * self.tx + next.c * self.ty + next.tx,
            ty: next.b * self.tx + next.d * self.ty + next.ty,
        }
    }

    /// La misma transformación, pero girando/escalando alrededor de `origin` (`transform-origin`).
    pub fn about(self, origin: (f32, f32)) -> Affine {
        Affine::translate(-origin.0, -origin.1).then(self).then(Affine::translate(origin.0, origin.1))
    }

    pub fn apply(&self, (x, y): (f32, f32)) -> (f32, f32) {
        (self.a * x + self.c * y + self.tx, self.b * x + self.d * y + self.ty)
    }

    pub fn is_identity(&self) -> bool {
        *self == Self::IDENTITY
    }

    /// Sin giro ni inclinación: solo traslada y escala, y todo se puede calcular sobre rectángulos.
    pub fn is_axis_aligned(&self) -> bool {
        self.b == 0.0 && self.c == 0.0
    }

    /// Cuánto cambia el área: `√|det|`. Sirve para mantener el suavizado de los bordes en 1 px.
    pub fn scale_factor(&self) -> f32 {
        (self.a * self.d - self.b * self.c).abs().sqrt()
    }

    /// Rectángulo `[x, y, ancho, alto]` mínimo que contiene al transformado.
    pub fn bounds_of(&self, [x, y, w, h]: [f32; 4]) -> [f32; 4] {
        let corners = [(x, y), (x + w, y), (x, y + h), (x + w, y + h)].map(|corner| self.apply(corner));
        let (mut min, mut max) = (corners[0], corners[0]);
        for (cx, cy) in corners {
            (min.0, min.1, max.0, max.1) = (min.0.min(cx), min.1.min(cy), max.0.max(cx), max.1.max(cy));
        }
        [min.0, min.1, max.0 - min.0, max.1 - min.1]
    }

    fn minor_scale(&self) -> f32 {
        self.a.abs().min(self.d.abs())
    }

    // ---- Solo para transformaciones sin giro: lo usa el rasterizador por CPU ----

    pub fn quad(&self, quad: &Quad) -> Quad {
        let s = self.minor_scale();
        let [top, right, bottom, left] = quad.border_widths;
        Quad {
            bounds: self.bounds_of(quad.bounds),
            clip: self.bounds_of(quad.clip),
            corner_radii: quad.corner_radii.map(|radius| radius * s),
            border_widths: [top * self.d.abs(), right * self.a.abs(), bottom * self.d.abs(), left * self.a.abs()],
            ..*quad
        }
    }

    pub fn shadow(&self, shadow: &Shadow) -> Shadow {
        let s = self.minor_scale();
        Shadow {
            bounds: self.bounds_of(shadow.bounds),
            clip: self.bounds_of(shadow.clip),
            corner_radii: shadow.corner_radii.map(|radius| radius * s),
            blur: shadow.blur * s,
            ..*shadow
        }
    }

    pub fn underline(&self, underline: &Underline) -> Underline {
        Underline {
            bounds: self.bounds_of(underline.bounds),
            clip: self.bounds_of(underline.clip),
            thickness: underline.thickness * self.d.abs(),
            ..*underline
        }
    }

    pub fn sprite(&self, sprite: &MonochromeSprite) -> MonochromeSprite {
        MonochromeSprite { bounds: self.bounds_of(sprite.bounds), clip: self.bounds_of(sprite.clip), ..*sprite }
    }
}

impl Default for Affine {
    fn default() -> Self {
        Self::IDENTITY
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn near(actual: (f32, f32), expected: (f32, f32)) {
        assert!(
            (actual.0 - expected.0).abs() < 1e-4 && (actual.1 - expected.1).abs() < 1e-4,
            "{actual:?} ≈ {expected:?}"
        );
    }

    #[test]
    fn el_orden_es_primero_uno_y_luego_el_otro() {
        let t = Affine::scale(2.0, 2.0).then(Affine::translate(10.0, 0.0));
        near(t.apply((1.0, 1.0)), (12.0, 2.0));
        let t = Affine::translate(10.0, 0.0).then(Affine::scale(2.0, 2.0));
        near(t.apply((1.0, 1.0)), (22.0, 2.0));
    }

    #[test]
    fn girar_noventa_grados_lleva_el_eje_x_hacia_abajo() {
        near(Affine::rotate(90.0).apply((1.0, 0.0)), (0.0, 1.0));
    }

    #[test]
    fn el_origen_mantiene_su_sitio() {
        let about = Affine::scale(2.0, 2.0).about((50.0, 50.0));
        near(about.apply((50.0, 50.0)), (50.0, 50.0));
        near(about.apply((60.0, 50.0)), (70.0, 50.0));
        let rotate = Affine::rotate(180.0).about((10.0, 10.0));
        near(rotate.apply((0.0, 0.0)), (20.0, 20.0));
    }

    #[test]
    fn el_rectangulo_envolvente_de_un_giro_de_45_es_mayor() {
        let [x, y, w, h] = Affine::rotate(45.0).about((50.0, 50.0)).bounds_of([0.0, 0.0, 100.0, 100.0]);
        assert!((w - 141.42).abs() < 0.01 && (h - 141.42).abs() < 0.01);
        assert!((x - (50.0 - 70.71)).abs() < 0.01 && (y - (50.0 - 70.71)).abs() < 0.01);
    }

    #[test]
    fn la_identidad_y_el_factor_de_escala() {
        assert!(Affine::IDENTITY.is_identity() && Affine::IDENTITY.is_axis_aligned());
        assert!(!Affine::rotate(10.0).is_axis_aligned());
        assert!((Affine::scale(2.0, 0.5).scale_factor() - 1.0).abs() < 1e-6);
        assert!((Affine::rotate(33.0).scale_factor() - 1.0).abs() < 1e-5);
    }

    #[test]
    fn una_figura_escalada_cambia_su_geometria_proporcionalmente() {
        use crate::core::{Bounds, Corners, Edges, Point, Rgba, Size, px};
        let bounds = Bounds::new(Point::new(px(10.0), px(10.0)), Size::new(px(20.0), px(10.0)));
        let quad = Quad::new(bounds, bounds, Rgba::TRANSPARENT)
            .corners(Corners::all(px(4.0)))
            .border(Edges::all(px(2.0)), Rgba::TRANSPARENT);
        let scaled = Affine::scale(2.0, 2.0).about((0.0, 0.0)).quad(&quad);
        assert_eq!(scaled.bounds, [20.0, 20.0, 40.0, 20.0]);
        assert_eq!(scaled.corner_radii, [8.0; 4]);
        assert_eq!(scaled.border_widths, [4.0; 4]);
    }
}