// ============================================================================= // forma :: la geometría, en el ancho de banda mínimo // ----------------------------------------------------------------------------- // kurbo trabaja en f64 en todos lados, y con razón: es una biblioteca de // geometría, y aplanar curvas en f32 acumula error visible. Pero acá la // geometría ya está resuelta —el layout corrió, los radios se calcularon— y lo // único que falta es CRUZAR. En ese tramo f64 es pagar el doble por precisión // que muere en el rasterizador: el destino son píxeles enteros de una pantalla // de 1280 columnas, no un plano continuo. // // Por eso todo viaja en f32. A 500 widgets el plan midió 11-114 KB por // fotograma; en f64 serían 22-228 KB por la misma imagen. La conversión de // vuelta a f64 al reproducir es exacta (f32 → f64 nunca pierde), así que el // único error posible es el del redondeo al empaquetar, que a escala de // pantalla está muy por debajo del píxel. // // Los tres casos de forma espejan los de `llimphi_cpu::Forma`, y por el mismo // motivo: el compositor dibuja rectángulos y rectángulos redondeados en el 90% // de los nodos, y aplanarlos a curvas sería pagar segmentos por nada. // ============================================================================= use alloc::vec::Vec; use kurbo::{BezPath, PathEl, Point, Rect, RoundedRect, RoundedRectRadii, Shape}; use serde::{Deserialize, Serialize}; /// Tolerancia de aplanado, igual que la de `llimphi-cpu` y la que vello /// codifica de fábrica. Más fino no se nota y cuesta segmentos. const TOLERANCIA: f64 = 0.1; /// Un segmento de camino. Espeja `kurbo::PathEl` en f32. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub enum SegWire { /// `PathEl::MoveTo`. MoverA([f32; 2]), /// `PathEl::LineTo`. LineaA([f32; 2]), /// `PathEl::QuadTo` — control, fin. CuadA([f32; 2], [f32; 2]), /// `PathEl::CurveTo` — control 1, control 2, fin. CurvaA([f32; 2], [f32; 2], [f32; 2]), /// `PathEl::ClosePath`. Cerrar, } /// Una forma lista para viajar. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum FormaWire { /// Rectángulo recto `[x0, y0, x1, y1]` — 16 bytes y el camino barato del /// otro lado (`vello_cpu` tiene `fill_rect` dedicado). Rect([f32; 4]), /// Rectángulo redondeado: el rect + los cuatro radios en orden /// `[sup-izq, sup-der, inf-der, inf-izq]`. Redondeado { rect: [f32; 4], radios: [f32; 4], }, /// Cualquier otra cosa, aplanada a curvas. Camino(Vec), } impl FormaWire { /// Graba una forma cualquiera quedándose con la representación más barata. /// /// Pregunta por `as_rect`/`as_rounded_rect` antes de aplanar, igual que /// `llimphi_cpu::Forma::de`. Es la misma decisión y conviene que lo sea: /// si los dos lados clasifican distinto, el mismo dibujo pesaría distinto /// según por dónde pasó. pub fn de(forma: &impl Shape) -> Self { if let Some(r) = forma.as_rect() { Self::Rect(rect_a_wire(r)) } else if let Some(rr) = forma.as_rounded_rect() { let radios = rr.radii(); Self::Redondeado { rect: rect_a_wire(rr.rect()), radios: [ radios.top_left as f32, radios.top_right as f32, radios.bottom_right as f32, radios.bottom_left as f32, ], } } else { Self::Camino(camino_a_wire(&forma.into_path(TOLERANCIA))) } } /// Reconstruye la forma del lado nativo. /// /// Devuelve el `Rect` aparte cuando lo es, para que el reproductor pueda /// elegir la primitiva rápida sin re-inspeccionar el camino. pub fn a_kurbo(&self) -> FormaKurbo { match self { Self::Rect(r) => FormaKurbo::Rect(wire_a_rect(*r)), Self::Redondeado { rect, radios } => FormaKurbo::Redondeado(RoundedRect::from_rect( wire_a_rect(*rect), RoundedRectRadii::new( radios[0] as f64, radios[1] as f64, radios[2] as f64, radios[3] as f64, ), )), Self::Camino(segs) => FormaKurbo::Camino(wire_a_camino(segs)), } } /// Cuántos segmentos ocupa. Sólo para medir escenas — un `Rect` cuenta 1. pub fn segmentos(&self) -> usize { match self { Self::Rect(_) | Self::Redondeado { .. } => 1, Self::Camino(s) => s.len(), } } } /// El resultado de reconstruir: los tipos de kurbo, sin perder cuál era. #[derive(Clone, Debug)] pub enum FormaKurbo { Rect(Rect), Redondeado(RoundedRect), Camino(BezPath), } impl FormaKurbo { /// La forma como camino, para donde sólo se acepta `BezPath`. pub fn a_camino(&self) -> BezPath { match self { Self::Rect(r) => r.into_path(TOLERANCIA), Self::Redondeado(rr) => rr.into_path(TOLERANCIA), Self::Camino(c) => c.clone(), } } } fn rect_a_wire(r: Rect) -> [f32; 4] { [r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32] } fn wire_a_rect(w: [f32; 4]) -> Rect { Rect::new(w[0] as f64, w[1] as f64, w[2] as f64, w[3] as f64) } fn punto_a_wire(p: Point) -> [f32; 2] { [p.x as f32, p.y as f32] } fn wire_a_punto(w: [f32; 2]) -> Point { Point::new(w[0] as f64, w[1] as f64) } fn camino_a_wire(camino: &BezPath) -> Vec { camino .elements() .iter() .map(|el| match el { PathEl::MoveTo(p) => SegWire::MoverA(punto_a_wire(*p)), PathEl::LineTo(p) => SegWire::LineaA(punto_a_wire(*p)), PathEl::QuadTo(c, p) => SegWire::CuadA(punto_a_wire(*c), punto_a_wire(*p)), PathEl::CurveTo(c1, c2, p) => SegWire::CurvaA( punto_a_wire(*c1), punto_a_wire(*c2), punto_a_wire(*p), ), PathEl::ClosePath => SegWire::Cerrar, }) .collect() } fn wire_a_camino(segs: &[SegWire]) -> BezPath { let mut camino = BezPath::new(); for s in segs { match s { SegWire::MoverA(p) => camino.move_to(wire_a_punto(*p)), SegWire::LineaA(p) => camino.line_to(wire_a_punto(*p)), SegWire::CuadA(c, p) => camino.quad_to(wire_a_punto(*c), wire_a_punto(*p)), SegWire::CurvaA(c1, c2, p) => camino.curve_to( wire_a_punto(*c1), wire_a_punto(*c2), wire_a_punto(*p), ), SegWire::Cerrar => camino.close_path(), } } camino } /// Empaqueta un `Affine` (6 f64) en 6 f32. pub fn afin_a_wire(a: kurbo::Affine) -> [f32; 6] { let c = a.as_coeffs(); [ c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32, ] } /// Reconstruye un `Affine` desde el wire. pub fn wire_a_afin(w: [f32; 6]) -> kurbo::Affine { kurbo::Affine::new([ w[0] as f64, w[1] as f64, w[2] as f64, w[3] as f64, w[4] as f64, w[5] as f64, ]) } /// Dónde va algo: un punto cuando alcanza, la matriz entera cuando hace falta. /// /// ----------------------------------------------------------------------------- /// Por qué dos variantes en vez de una /// ----------------------------------------------------------------------------- /// Porque las dos opciones simples estaban mal, cada una a su manera. /// /// Guardar sólo el **punto** es lo barato (8 B) y es lo que casi siempre /// alcanza: el compositor posiciona el texto con `transform_acumulada * /// translate(esquina)`, y esa acumulada es una traslación pura en el caso /// normal (scroll, offset del padre). Pero *puede* traer rotación o escala de un /// ancestro animado, y ahí un punto pierde la información **sin que nada avise**: /// el texto de un subárbol rotado se pintaría derecho. /// /// Guardar siempre la **matriz** es correcto y cuesta 24 B. Medido sobre 500 /// etiquetas: el fotograma pasa de 45,8 a 53,6 KB (+17%) para pagar un caso que /// casi nunca ocurre. /// /// La salida es mirar el afín y elegir. Si la parte lineal es la identidad, es /// una traslación y viaja como punto; si no, viaja entera. El caso raro sigue /// siendo exacto y el caso común sigue siendo barato — no se degrada nada en /// silencio, que era el único requisito no negociable. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub enum UbicacionWire { /// Traslación pura — el caso normal. Punto([f32; 2]), /// Afín completo, cuando hay rotación, escala o sesgo. Afin([f32; 6]), } impl UbicacionWire { /// Empaqueta un afín quedándose con la representación más barata que sea /// exacta. pub fn de(a: kurbo::Affine) -> Self { let c = a.as_coeffs(); if c[0] == 1.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] == 1.0 { Self::Punto([c[4] as f32, c[5] as f32]) } else { Self::Afin(afin_a_wire(a)) } } /// El afín del lado nativo. pub fn a_kurbo(self) -> kurbo::Affine { match self { Self::Punto(p) => kurbo::Affine::translate((p[0] as f64, p[1] as f64)), Self::Afin(m) => wire_a_afin(m), } } /// Compone por izquierda, igual que `Affine::mul`. Vuelve a elegir la /// representación: trasladar una traslación sigue siendo una traslación, y /// perder eso engordaría el cable en cada `append`. pub fn componer(self, t: kurbo::Affine) -> Self { Self::de(t * self.a_kurbo()) } /// La traslación resultante, para tests y para posicionar sin transformar. pub fn traslacion(self) -> (f32, f32) { match self { Self::Punto(p) => (p[0], p[1]), Self::Afin(m) => (m[4], m[5]), } } }