tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1// =============================================================================
2// forma :: la geometría, en el ancho de banda mínimo
3// -----------------------------------------------------------------------------
4// kurbo trabaja en f64 en todos lados, y con razón: es una biblioteca de
5// geometría, y aplanar curvas en f32 acumula error visible. Pero acá la
6// geometría ya está resuelta —el layout corrió, los radios se calcularon— y lo
7// único que falta es CRUZAR. En ese tramo f64 es pagar el doble por precisión
8// que muere en el rasterizador: el destino son píxeles enteros de una pantalla
9// de 1280 columnas, no un plano continuo.
10//
11// Por eso todo viaja en f32. A 500 widgets el plan midió 11-114 KB por
12// fotograma; en f64 serían 22-228 KB por la misma imagen. La conversión de
13// vuelta a f64 al reproducir es exacta (f32 → f64 nunca pierde), así que el
14// único error posible es el del redondeo al empaquetar, que a escala de
15// pantalla está muy por debajo del píxel.
16//
17// Los tres casos de forma espejan los de `llimphi_cpu::Forma`, y por el mismo
18// motivo: el compositor dibuja rectángulos y rectángulos redondeados en el 90%
19// de los nodos, y aplanarlos a curvas sería pagar segmentos por nada.
20// =============================================================================
21
22use alloc::vec::Vec;
23
24use kurbo::{BezPath, PathEl, Point, Rect, RoundedRect, RoundedRectRadii, Shape};
25use serde::{Deserialize, Serialize};
26
27/// Tolerancia de aplanado, igual que la de `llimphi-cpu` y la que vello
28/// codifica de fábrica. Más fino no se nota y cuesta segmentos.
29const TOLERANCIA: f64 = 0.1;
30
31/// Un segmento de camino. Espeja `kurbo::PathEl` en f32.
32#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
33pub enum SegWire {
34 /// `PathEl::MoveTo`.
35 MoverA([f32; 2]),
36 /// `PathEl::LineTo`.
37 LineaA([f32; 2]),
38 /// `PathEl::QuadTo` — control, fin.
39 CuadA([f32; 2], [f32; 2]),
40 /// `PathEl::CurveTo` — control 1, control 2, fin.
41 CurvaA([f32; 2], [f32; 2], [f32; 2]),
42 /// `PathEl::ClosePath`.
43 Cerrar,
44}
45
46/// Una forma lista para viajar.
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48pub enum FormaWire {
49 /// Rectángulo recto `[x0, y0, x1, y1]` — 16 bytes y el camino barato del
50 /// otro lado (`vello_cpu` tiene `fill_rect` dedicado).
51 Rect([f32; 4]),
52 /// Rectángulo redondeado: el rect + los cuatro radios en orden
53 /// `[sup-izq, sup-der, inf-der, inf-izq]`.
54 Redondeado {
55 rect: [f32; 4],
56 radios: [f32; 4],
57 },
58 /// Cualquier otra cosa, aplanada a curvas.
59 Camino(Vec<SegWire>),
60}
61
62impl FormaWire {
63 /// Graba una forma cualquiera quedándose con la representación más barata.
64 ///
65 /// Pregunta por `as_rect`/`as_rounded_rect` antes de aplanar, igual que
66 /// `llimphi_cpu::Forma::de`. Es la misma decisión y conviene que lo sea:
67 /// si los dos lados clasifican distinto, el mismo dibujo pesaría distinto
68 /// según por dónde pasó.
69 pub fn de(forma: &impl Shape) -> Self {
70 if let Some(r) = forma.as_rect() {
71 Self::Rect(rect_a_wire(r))
72 } else if let Some(rr) = forma.as_rounded_rect() {
73 let radios = rr.radii();
74 Self::Redondeado {
75 rect: rect_a_wire(rr.rect()),
76 radios: [
77 radios.top_left as f32,
78 radios.top_right as f32,
79 radios.bottom_right as f32,
80 radios.bottom_left as f32,
81 ],
82 }
83 } else {
84 Self::Camino(camino_a_wire(&forma.into_path(TOLERANCIA)))
85 }
86 }
87
88 /// Reconstruye la forma del lado nativo.
89 ///
90 /// Devuelve el `Rect` aparte cuando lo es, para que el reproductor pueda
91 /// elegir la primitiva rápida sin re-inspeccionar el camino.
92 pub fn a_kurbo(&self) -> FormaKurbo {
93 match self {
94 Self::Rect(r) => FormaKurbo::Rect(wire_a_rect(*r)),
95 Self::Redondeado { rect, radios } => FormaKurbo::Redondeado(RoundedRect::from_rect(
96 wire_a_rect(*rect),
97 RoundedRectRadii::new(
98 radios[0] as f64,
99 radios[1] as f64,
100 radios[2] as f64,
101 radios[3] as f64,
102 ),
103 )),
104 Self::Camino(segs) => FormaKurbo::Camino(wire_a_camino(segs)),
105 }
106 }
107
108 /// Cuántos segmentos ocupa. Sólo para medir escenas — un `Rect` cuenta 1.
109 pub fn segmentos(&self) -> usize {
110 match self {
111 Self::Rect(_) | Self::Redondeado { .. } => 1,
112 Self::Camino(s) => s.len(),
113 }
114 }
115}
116
117/// El resultado de reconstruir: los tipos de kurbo, sin perder cuál era.
118#[derive(Clone, Debug)]
119pub enum FormaKurbo {
120 Rect(Rect),
121 Redondeado(RoundedRect),
122 Camino(BezPath),
123}
124
125impl FormaKurbo {
126 /// La forma como camino, para donde sólo se acepta `BezPath`.
127 pub fn a_camino(&self) -> BezPath {
128 match self {
129 Self::Rect(r) => r.into_path(TOLERANCIA),
130 Self::Redondeado(rr) => rr.into_path(TOLERANCIA),
131 Self::Camino(c) => c.clone(),
132 }
133 }
134}
135
136fn rect_a_wire(r: Rect) -> [f32; 4] {
137 [r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32]
138}
139
140fn wire_a_rect(w: [f32; 4]) -> Rect {
141 Rect::new(w[0] as f64, w[1] as f64, w[2] as f64, w[3] as f64)
142}
143
144fn punto_a_wire(p: Point) -> [f32; 2] {
145 [p.x as f32, p.y as f32]
146}
147
148fn wire_a_punto(w: [f32; 2]) -> Point {
149 Point::new(w[0] as f64, w[1] as f64)
150}
151
152fn camino_a_wire(camino: &BezPath) -> Vec<SegWire> {
153 camino
154 .elements()
155 .iter()
156 .map(|el| match el {
157 PathEl::MoveTo(p) => SegWire::MoverA(punto_a_wire(*p)),
158 PathEl::LineTo(p) => SegWire::LineaA(punto_a_wire(*p)),
159 PathEl::QuadTo(c, p) => SegWire::CuadA(punto_a_wire(*c), punto_a_wire(*p)),
160 PathEl::CurveTo(c1, c2, p) => SegWire::CurvaA(
161 punto_a_wire(*c1),
162 punto_a_wire(*c2),
163 punto_a_wire(*p),
164 ),
165 PathEl::ClosePath => SegWire::Cerrar,
166 })
167 .collect()
168}
169
170fn wire_a_camino(segs: &[SegWire]) -> BezPath {
171 let mut camino = BezPath::new();
172 for s in segs {
173 match s {
174 SegWire::MoverA(p) => camino.move_to(wire_a_punto(*p)),
175 SegWire::LineaA(p) => camino.line_to(wire_a_punto(*p)),
176 SegWire::CuadA(c, p) => camino.quad_to(wire_a_punto(*c), wire_a_punto(*p)),
177 SegWire::CurvaA(c1, c2, p) => camino.curve_to(
178 wire_a_punto(*c1),
179 wire_a_punto(*c2),
180 wire_a_punto(*p),
181 ),
182 SegWire::Cerrar => camino.close_path(),
183 }
184 }
185 camino
186}
187
188/// Empaqueta un `Affine` (6 f64) en 6 f32.
189pub fn afin_a_wire(a: kurbo::Affine) -> [f32; 6] {
190 let c = a.as_coeffs();
191 [
192 c[0] as f32,
193 c[1] as f32,
194 c[2] as f32,
195 c[3] as f32,
196 c[4] as f32,
197 c[5] as f32,
198 ]
199}
200
201/// Reconstruye un `Affine` desde el wire.
202pub fn wire_a_afin(w: [f32; 6]) -> kurbo::Affine {
203 kurbo::Affine::new([
204 w[0] as f64,
205 w[1] as f64,
206 w[2] as f64,
207 w[3] as f64,
208 w[4] as f64,
209 w[5] as f64,
210 ])
211}
212
213/// Dónde va algo: un punto cuando alcanza, la matriz entera cuando hace falta.
214///
215/// -----------------------------------------------------------------------------
216/// Por qué dos variantes en vez de una
217/// -----------------------------------------------------------------------------
218/// Porque las dos opciones simples estaban mal, cada una a su manera.
219///
220/// Guardar sólo el **punto** es lo barato (8 B) y es lo que casi siempre
221/// alcanza: el compositor posiciona el texto con `transform_acumulada *
222/// translate(esquina)`, y esa acumulada es una traslación pura en el caso
223/// normal (scroll, offset del padre). Pero *puede* traer rotación o escala de un
224/// ancestro animado, y ahí un punto pierde la información **sin que nada avise**:
225/// el texto de un subárbol rotado se pintaría derecho.
226///
227/// Guardar siempre la **matriz** es correcto y cuesta 24 B. Medido sobre 500
228/// etiquetas: el fotograma pasa de 45,8 a 53,6 KB (+17%) para pagar un caso que
229/// casi nunca ocurre.
230///
231/// La salida es mirar el afín y elegir. Si la parte lineal es la identidad, es
232/// una traslación y viaja como punto; si no, viaja entera. El caso raro sigue
233/// siendo exacto y el caso común sigue siendo barato — no se degrada nada en
234/// silencio, que era el único requisito no negociable.
235#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
236pub enum UbicacionWire {
237 /// Traslación pura — el caso normal.
238 Punto([f32; 2]),
239 /// Afín completo, cuando hay rotación, escala o sesgo.
240 Afin([f32; 6]),
241}
242
243impl UbicacionWire {
244 /// Empaqueta un afín quedándose con la representación más barata que sea
245 /// exacta.
246 pub fn de(a: kurbo::Affine) -> Self {
247 let c = a.as_coeffs();
248 if c[0] == 1.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] == 1.0 {
249 Self::Punto([c[4] as f32, c[5] as f32])
250 } else {
251 Self::Afin(afin_a_wire(a))
252 }
253 }
254
255 /// El afín del lado nativo.
256 pub fn a_kurbo(self) -> kurbo::Affine {
257 match self {
258 Self::Punto(p) => kurbo::Affine::translate((p[0] as f64, p[1] as f64)),
259 Self::Afin(m) => wire_a_afin(m),
260 }
261 }
262
263 /// Compone por izquierda, igual que `Affine::mul`. Vuelve a elegir la
264 /// representación: trasladar una traslación sigue siendo una traslación, y
265 /// perder eso engordaría el cable en cada `append`.
266 pub fn componer(self, t: kurbo::Affine) -> Self {
267 Self::de(t * self.a_kurbo())
268 }
269
270 /// La traslación resultante, para tests y para posicionar sin transformar.
271 pub fn traslacion(self) -> (f32, f32) {
272 match self {
273 Self::Punto(p) => (p[0], p[1]),
274 Self::Afin(m) => (m[4], m[5]),
275 }
276 }
277}