tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1// =============================================================================
2// llimphi-wire-escena :: el IR de ESCENA que cruza la frontera
3// -----------------------------------------------------------------------------
4// W4 del PLAN-LLIMPHI es la pregunta «¿dónde rasteriza una app de wawa?», y
5// W4.1 la contestó con números. Este crate es esa respuesta hecha tipos.
6//
7// Las tres arquitecturas que el plan midió, a 500 widgets:
8//
9// Kernel puro (wire-view) 685 fps pero sólo ~15-18 de 41 entries del DSL
10// IR de escena 77 fps con los 69 widgets ← ésta
11// Jaula pura 18 fps con 476 KB de peso por app
12//
13// El «IR de escena» es esto: la app corre `view` y `layout` dentro de la jaula
14// —barato— y emite una LISTA PLANA de formas + pinceles + texto. El kernel la
15// rasteriza nativo. Lo caro (aplanar curvas, generar strips, rasterizar) es el
16// 79-93% del fotograma y cae entero del lado nativo; la app sólo arma
17// geometría, que aguanta el 30,3x del intérprete sin despeinarse.
18//
19// **La diferencia con `llimphi-wire-view`** —el otro IR del repo, y hay que
20// saber cuál usar— es de nivel de abstracción, no de calidad:
21//
22// llimphi-wire-view árbol de CAJAS el host corre layout Y pinta
23// llimphi-wire-escena lista de TRAZOS el host sólo rasteriza
24//
25// wire-view es más chico en el cable y deja al host decidir el aspecto, pero
26// sólo expresa lo que su vocabulario nombra: no hay gradientes, sombras, clip,
27// z-index ni paint custom, así que nodegraph, table, terminal y text-editor
28// quedan afuera por construcción. wire-escena no tiene ese techo porque no
29// habla de widgets: habla de trazos, y todo widget termina en trazos.
30//
31// ## Lo único que NO es un trazo: el texto
32//
33// Y es a propósito. Ver el encabezado de [`texto`] — es la decisión que W4.1
34// midió como 3,3x. Resumen: si la app shapeara y mandara glifos, parley pagaría
35// el 30,3x del intérprete y se comería el 72% del fotograma. Mandando la cadena,
36// el kernel shapea nativo y el texto cuesta 1,09 ms en vez de 32,94.
37//
38// ## Qué no está acá
39//
40// El **encoder desde el compositor** (que `paint` sepa escribir en una
41// `EscenaWire` en vez de en una `vello::Scene`) y el **reproductor** hacia
42// `llimphi_cpu::Escena`. Son los dos extremos del cable; éste es el cable.
43// =============================================================================
44
45#![cfg_attr(not(any(feature = "std", test)), no_std)]
46
47extern crate alloc;
48
49use alloc::vec::Vec;
50
51use kurbo::{Affine, Shape};
52use peniko::{Brush, BrushRef, Color, Fill, Mix};
53use serde::{Deserialize, Serialize};
54
55pub mod forma;
56pub mod pincel;
57pub mod texto;
58
59pub use forma::{afin_a_wire, wire_a_afin, FormaKurbo, FormaWire, SegWire, UbicacionWire};
60pub use pincel::{
61 blob_de, color_a_wire, huella_imagen, imagen_de_blob, wire_a_color, BlobImagen,
62 ClaseDegradeWire, ExtiendeWire, ParadaWire, PincelWire, Rgba,
63};
64pub use texto::{
65 AlineacionWire, EstiloRangoWire, EstiloTextoWire, MedidaTexto, OrdenTexto, PeticionMedida,
66 RangoWire, Texto,
67};
68
69/// Regla de relleno. Espeja `peniko::Fill`.
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub enum ReglaWire {
72 #[default]
73 DistintoDeCero,
74 ParImpar,
75}
76
77impl ReglaWire {
78 fn de(f: Fill) -> Self {
79 match f {
80 Fill::NonZero => Self::DistintoDeCero,
81 Fill::EvenOdd => Self::ParImpar,
82 }
83 }
84
85 /// La regla del lado nativo.
86 pub fn a_peniko(self) -> Fill {
87 match self {
88 Self::DistintoDeCero => Fill::NonZero,
89 Self::ParImpar => Fill::EvenOdd,
90 }
91 }
92}
93
94/// Modo de mezcla, en los dos bytes que `peniko` ya usa internamente.
95///
96/// `Mix` y `Compose` son `#[repr(u8)]` con discriminantes explícitos y estables
97/// (están fijados por la spec de compositing del W3C, no por peniko), así que
98/// viajar como pareja de bytes no es un truco frágil: es su representación real.
99/// La reconstrucción es un match exhaustivo —16 y 14 variantes— y un byte fuera
100/// de rango cae a `Normal`/`SrcOver`, que es lo que un lector viejo debe hacer
101/// con un modo que todavía no conoce.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub struct MezclaWire {
104 pub mezcla: u8,
105 pub compone: u8,
106}
107
108impl Default for MezclaWire {
109 fn default() -> Self {
110 Self {
111 mezcla: peniko::Mix::Normal as u8,
112 compone: peniko::Compose::SrcOver as u8,
113 }
114 }
115}
116
117impl MezclaWire {
118 fn de(bm: peniko::BlendMode) -> Self {
119 Self {
120 mezcla: bm.mix as u8,
121 compone: bm.compose as u8,
122 }
123 }
124
125 /// El modo de mezcla del lado nativo.
126 pub fn a_peniko(self) -> peniko::BlendMode {
127 use peniko::{Compose, Mix};
128 let mix = match self.mezcla {
129 0 => Mix::Normal,
130 1 => Mix::Multiply,
131 2 => Mix::Screen,
132 3 => Mix::Overlay,
133 4 => Mix::Darken,
134 5 => Mix::Lighten,
135 6 => Mix::ColorDodge,
136 7 => Mix::ColorBurn,
137 8 => Mix::HardLight,
138 9 => Mix::SoftLight,
139 10 => Mix::Difference,
140 11 => Mix::Exclusion,
141 12 => Mix::Hue,
142 13 => Mix::Saturation,
143 14 => Mix::Color,
144 15 => Mix::Luminosity,
145 _ => Mix::Normal,
146 };
147 let compose = match self.compone {
148 0 => Compose::Clear,
149 1 => Compose::Copy,
150 2 => Compose::Dest,
151 3 => Compose::SrcOver,
152 4 => Compose::DestOver,
153 5 => Compose::SrcIn,
154 6 => Compose::DestIn,
155 7 => Compose::SrcOut,
156 8 => Compose::DestOut,
157 9 => Compose::SrcAtop,
158 10 => Compose::DestAtop,
159 11 => Compose::Xor,
160 12 => Compose::Plus,
161 13 => Compose::PlusLighter,
162 _ => Compose::SrcOver,
163 };
164 peniko::BlendMode { mix, compose }
165 }
166}
167
168/// Una orden de dibujo lista para viajar.
169///
170/// Espeja `llimphi_cpu::Orden` una a una, con dos diferencias que son el crate
171/// entero: los tipos son serializables y en f32, y `Glifos` se reemplaza por
172/// [`OrdenWire::Texto`] — que lleva la cadena, no los glifos.
173#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
174pub enum OrdenWire {
175 Rellenar {
176 regla: ReglaWire,
177 xf: [f32; 6],
178 pincel: PincelWire,
179 xf_pincel: Option<[f32; 6]>,
180 forma: FormaWire,
181 },
182 Trazar {
183 /// Ancho de la línea. El resto de `kurbo::Stroke` (joins, caps, dash)
184 /// no viaja todavía: el compositor traza contornos de caja y de foco,
185 /// donde los defaults alcanzan. Sumarlo es agregar campos, no rediseñar.
186 ancho: f32,
187 xf: [f32; 6],
188 pincel: PincelWire,
189 xf_pincel: Option<[f32; 6]>,
190 forma: FormaWire,
191 },
192 AbrirCapa {
193 regla: ReglaWire,
194 mezcla: MezclaWire,
195 alfa: f32,
196 xf: [f32; 6],
197 recorte: FormaWire,
198 },
199 AbrirMascaraLuminancia {
200 regla: ReglaWire,
201 alfa: f32,
202 xf: [f32; 6],
203 recorte: FormaWire,
204 },
205 CerrarCapa,
206 /// Imagen por huella — los píxeles no viajan por acá (ver [`pincel`]).
207 Imagen {
208 huella: u64,
209 xf: [f32; 6],
210 },
211 RectDifuso {
212 xf: [f32; 6],
213 rect: [f32; 4],
214 color: Rgba,
215 radio: f32,
216 desvio: f32,
217 },
218 /// **El punto de W4**: texto como texto, sin shapear.
219 Texto(OrdenTexto),
220}
221
222/// La escena serializable: una lista plana de órdenes, en orden de emisión.
223///
224/// Es a la vez el formato del cable y una GRABADORA con las firmas de
225/// `vello::Scene`, igual que `llimphi_cpu::Escena`. Que las tres compartan
226/// firma es lo que deja elegir el backend con un alias de tipo (el truco de
227/// W3.b) en vez de con tres caminos de código paralelos.
228#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
229pub struct EscenaWire {
230 ordenes: Vec<OrdenWire>,
231 /// Los estilos de texto distintos que usa esta escena, internados.
232 ///
233 /// Una pantalla se pinta con un puñado de estilos y cientos de etiquetas;
234 /// ver el porqué medido en [`OrdenTexto`]. La deduplicación es un barrido
235 /// lineal a propósito: con una tabla de menos de treinta entradas comparar
236 /// es más barato que hashear, y evita meter `hashbrown` en un crate que va
237 /// a correr adentro de una jaula WASM donde cada byte de código pesa.
238 estilos: Vec<EstiloTextoWire>,
239 /// Órdenes que el IR no supo expresar.
240 ///
241 /// Existe porque un IR que descarta en silencio miente por omisión: la
242 /// escena llega «bien» y la pantalla sale distinta, sin nada que lo diga.
243 /// Con el contador, el que reproduce puede avisar.
244 descartadas: u32,
245 /// **Los píxeles de las imágenes que esta escena estrena**, por huella.
246 ///
247 /// Las órdenes viajan por huella ([`OrdenWire::Imagen`]) y los píxeles por
248 /// acá, **una sola vez**: dentro de una escena se deduplica sola, y entre
249 /// escenas lo hace quien envía, que es el único que sabe qué le mandó ya al
250 /// anfitrión (ver `Jaula::escena_para_enviar` en `llimphi-jaula`).
251 ///
252 /// Sin esto el reproductor no tenía de dónde sacar los píxeles y **no
253 /// dibujaba ninguna imagen** — las contaba en `imagenes_sin_blob` y seguía
254 /// de largo. En el explorador enjaulado eso era toda miniatura y toda previa
255 /// en negro.
256 blobs: Vec<BlobImagen>,
257 /// Blobs que **no se mandaron por pasarse de** [`TOPE_BLOB`]. Un cuadro que
258 /// no entra en el marco es un cable muerto; una imagen que no se ve, con su
259 /// contador, es un defecto que se puede nombrar.
260 blobs_enormes: u32,
261}
262
263/// Tope de un blob de imagen, en bytes.
264///
265/// El marco del cable tiene techo (`llimphi_cable::MAX_MARCO`, 64 MiB) y una
266/// escena lleva varias imágenes: una foto de 8000×6000 en RGBA son 192 MB y se
267/// llevaría puesto el cuadro entero — o sea la pantalla, no la imagen. Con el
268/// tope, lo que no entra no se manda y se **cuenta**.
269pub const TOPE_BLOB: usize = 16 * 1024 * 1024;
270
271impl EscenaWire {
272 /// Una escena vacía.
273 pub fn new() -> Self {
274 Self::default()
275 }
276
277 /// Descarta lo grabado conservando la capacidad. Se llama entre fotogramas.
278 pub fn reset(&mut self) {
279 self.ordenes.clear();
280 self.estilos.clear();
281 self.descartadas = 0;
282 }
283
284 /// La tabla de estilos de texto internados. La indexa `OrdenTexto::estilo`.
285 pub fn estilos(&self) -> &[EstiloTextoWire] {
286 &self.estilos
287 }
288
289 /// El estilo de una orden de texto, resuelto.
290 ///
291 /// Devuelve `None` si el índice no existe — un IR corrupto o de una versión
292 /// que no conocemos. El reproductor decide: saltear el texto o usar el
293 /// default. Lo que no puede es entrar en pánico dentro del kernel.
294 pub fn estilo(&self, idx: u16) -> Option<&EstiloTextoWire> {
295 self.estilos.get(idx as usize)
296 }
297
298 /// Interna un estilo y devuelve su índice.
299 fn internar(&mut self, estilo: &EstiloTextoWire) -> u16 {
300 if let Some(i) = self.estilos.iter().position(|e| e == estilo) {
301 return i as u16;
302 }
303 // Con más de 65.535 estilos distintos en una sola escena algo está muy
304 // mal río arriba; se reusa el último en vez de romper el fotograma.
305 if self.estilos.len() >= u16::MAX as usize {
306 self.descartadas += 1;
307 return (self.estilos.len() - 1) as u16;
308 }
309 self.estilos.push(estilo.clone());
310 (self.estilos.len() - 1) as u16
311 }
312
313 /// Las órdenes grabadas, en orden.
314 pub fn ordenes(&self) -> &[OrdenWire] {
315 &self.ordenes
316 }
317
318 /// Cuántas órdenes tiene.
319 pub fn len(&self) -> usize {
320 self.ordenes.len()
321 }
322
323 /// ¿No se grabó nada?
324 pub fn is_empty(&self) -> bool {
325 self.ordenes.is_empty()
326 }
327
328 /// Cuántas órdenes se descartaron por no ser expresables.
329 pub fn descartadas(&self) -> u32 {
330 self.descartadas
331 }
332
333 /// Cuántos segmentos de camino acumula. Para medir el peso de una escena
334 /// sin serializarla.
335 pub fn segmentos(&self) -> usize {
336 self.ordenes
337 .iter()
338 .map(|o| match o {
339 OrdenWire::Rellenar { forma, .. } | OrdenWire::Trazar { forma, .. } => {
340 forma.segmentos()
341 }
342 OrdenWire::AbrirCapa { recorte, .. }
343 | OrdenWire::AbrirMascaraLuminancia { recorte, .. } => recorte.segmentos(),
344 _ => 0,
345 })
346 .sum()
347 }
348
349 /// Cuántas órdenes de texto lleva. La estadística que importa para W4.1.
350 pub fn textos(&self) -> usize {
351 self.ordenes
352 .iter()
353 .filter(|o| matches!(o, OrdenWire::Texto(_)))
354 .count()
355 }
356
357 // -------------------------------------------------------------------------
358 // Las firmas de `vello::Scene`, para que el compositor no note la
359 // diferencia. Ver `llimphi_cpu::Escena`, que hace lo mismo del otro lado.
360 // -------------------------------------------------------------------------
361
362 /// Rellena una forma. Espeja `vello::Scene::fill`.
363 pub fn fill<'b>(
364 &mut self,
365 style: Fill,
366 transform: Affine,
367 brush: impl Into<BrushRef<'b>>,
368 brush_transform: Option<Affine>,
369 shape: &impl Shape,
370 ) {
371 let brush = brush.into().to_owned();
372 // **Un relleno CON pincel de imagen también pierde los píxeles**, y por
373 // el mismo camino que `draw_image`: `PincelWire::de` se queda con la
374 // huella y tira el buffer. Si no se graba acá, un relleno con textura
375 // sale en negro aunque la imagen suelta se vea.
376 if let peniko::Brush::Image(img) = &brush {
377 let huella = huella_imagen(img);
378 self.recordar_blob(huella, img);
379 }
380 let pincel = PincelWire::de(&brush);
381 self.ordenes.push(OrdenWire::Rellenar {
382 regla: ReglaWire::de(style),
383 xf: afin_a_wire(transform),
384 pincel,
385 xf_pincel: brush_transform.map(afin_a_wire),
386 forma: FormaWire::de(shape),
387 });
388 }
389
390 /// Traza el contorno de una forma. Espeja `vello::Scene::stroke`.
391 pub fn stroke<'b>(
392 &mut self,
393 style: &kurbo::Stroke,
394 transform: Affine,
395 brush: impl Into<BrushRef<'b>>,
396 brush_transform: Option<Affine>,
397 shape: &impl Shape,
398 ) {
399 let pincel = PincelWire::de(&brush.into().to_owned());
400 self.ordenes.push(OrdenWire::Trazar {
401 ancho: style.width as f32,
402 xf: afin_a_wire(transform),
403 pincel,
404 xf_pincel: brush_transform.map(afin_a_wire),
405 forma: FormaWire::de(shape),
406 });
407 }
408
409 /// Abre una capa recortada. Espeja `vello::Scene::push_layer`.
410 pub fn push_layer(
411 &mut self,
412 clip_style: Fill,
413 blend: impl Into<peniko::BlendMode>,
414 alpha: f32,
415 transform: Affine,
416 clip: &impl Shape,
417 ) {
418 self.ordenes.push(OrdenWire::AbrirCapa {
419 regla: ReglaWire::de(clip_style),
420 mezcla: MezclaWire::de(blend.into()),
421 alfa: alpha.clamp(0.0, 1.0),
422 xf: afin_a_wire(transform),
423 recorte: FormaWire::de(clip),
424 });
425 }
426
427 /// Abre una capa que enmascara por luminancia.
428 pub fn push_luminance_mask_layer(
429 &mut self,
430 clip_style: Fill,
431 alpha: f32,
432 transform: Affine,
433 clip: &impl Shape,
434 ) {
435 self.ordenes.push(OrdenWire::AbrirMascaraLuminancia {
436 regla: ReglaWire::de(clip_style),
437 alfa: alpha.clamp(0.0, 1.0),
438 xf: afin_a_wire(transform),
439 recorte: FormaWire::de(clip),
440 });
441 }
442
443 /// Cierra la última capa abierta.
444 pub fn pop_layer(&mut self) {
445 self.ordenes.push(OrdenWire::CerrarCapa);
446 }
447
448 /// Dibuja una imagen: la **orden** va por huella y los **píxeles** por la
449 /// tabla de blobs, una sola vez. Ver [`Self::blobs`].
450 pub fn draw_image(&mut self, image: &peniko::ImageBrush, transform: Affine) {
451 let huella = huella_imagen(image);
452 self.recordar_blob(huella, image);
453 self.ordenes.push(OrdenWire::Imagen {
454 huella,
455 xf: afin_a_wire(transform),
456 });
457 }
458
459 /// Guarda los píxeles de `image` si esta escena todavía no los trae.
460 ///
461 /// El barrido es lineal por lo mismo que el de estilos: una pantalla usa un
462 /// puñado de imágenes distintas y comparar u64 es más barato que hashear en
463 /// un crate que corre adentro de una jaula WASM.
464 fn recordar_blob(&mut self, huella: u64, image: &peniko::ImageBrush) {
465 if self.blobs.iter().any(|b| b.huella == huella) {
466 return;
467 }
468 let b = pincel::blob_de(image);
469 if b.datos.len() > TOPE_BLOB {
470 self.blobs_enormes += 1;
471 return;
472 }
473 self.blobs.push(b);
474 }
475
476 /// Los píxeles que esta escena trae, por huella.
477 pub fn blobs(&self) -> &[BlobImagen] {
478 &self.blobs
479 }
480
481 /// Cuántos blobs se dejaron afuera por pasarse de [`TOPE_BLOB`].
482 pub fn blobs_enormes(&self) -> u32 {
483 self.blobs_enormes
484 }
485
486 /// **Deja sólo los blobs que `conservar` acepte.**
487 ///
488 /// Lo usa quien envía para no remandar lo que el anfitrión ya tiene: la
489 /// escena se arma de cero en cada fotograma y no sabe nada de la conexión,
490 /// así que la memoria de «esto ya lo mandé» vive del lado del que manda.
491 pub fn retener_blobs(&mut self, mut conservar: impl FnMut(u64) -> bool) {
492 self.blobs.retain(|b| conservar(b.huella));
493 }
494
495 /// Dibuja un rectángulo redondeado desenfocado — la sombra.
496 pub fn draw_blurred_rounded_rect(
497 &mut self,
498 transform: Affine,
499 rect: kurbo::Rect,
500 brush: Color,
501 radius: f64,
502 std_dev: f64,
503 ) {
504 self.ordenes.push(OrdenWire::RectDifuso {
505 xf: afin_a_wire(transform),
506 rect: [
507 rect.x0 as f32,
508 rect.y0 as f32,
509 rect.x1 as f32,
510 rect.y1 as f32,
511 ],
512 color: color_a_wire(brush),
513 radio: radius as f32,
514 desvio: std_dev as f32,
515 });
516 }
517
518 /// **La orden que justifica el crate**: texto sin shapear.
519 ///
520 /// No espeja `vello::Scene::draw_glyphs` porque no puede: aquella recibe
521 /// glifos ya resueltos, y el punto entero de W4.1 es no resolverlos de este
522 /// lado.
523 pub fn texto(&mut self, texto: Texto) {
524 if texto.cadena.is_empty() {
525 return;
526 }
527 let estilo = self.internar(&texto.estilo);
528 self.ordenes.push(OrdenWire::Texto(OrdenTexto {
529 cadena: texto.cadena,
530 estilo,
531 color: texto.color,
532 ancho_max: texto.ancho_max,
533 xf: texto.xf,
534 hint: texto.hint,
535 }));
536 }
537
538 /// Incorpora una escena hija, opcionalmente transformada.
539 /// Espeja `vello::Scene::append`.
540 ///
541 /// Los índices de estilo de la hija se **remapean** contra la tabla de la
542 /// madre. Es el precio de internar, y no es opcional: pegar las órdenes tal
543 /// cual haría que cada texto de la hija apuntara a un estilo ajeno, con lo
544 /// que un subárbol cacheado se repintaría con la tipografía de otro. El
545 /// remapeo es una tabla de traducción por escena hija, no por orden.
546 pub fn append(&mut self, otra: &Self, transform: Option<Affine>) {
547 self.descartadas += otra.descartadas;
548
549 let mapa: Vec<u16> = otra
550 .estilos
551 .iter()
552 .map(|e| self.internar(e))
553 .collect();
554
555 for o in &otra.ordenes {
556 let mut o = match transform {
557 None => o.clone(),
558 Some(t) => trasladar(o, t),
559 };
560 if let OrdenWire::Texto(t) = &mut o {
561 t.estilo = mapa.get(t.estilo as usize).copied().unwrap_or(0);
562 }
563 self.ordenes.push(o);
564 }
565 }
566
567 /// Anota que una orden no se pudo expresar. Lo llama el encoder cuando se
568 /// topa con algo que el IR todavía no nombra.
569 pub fn descartar(&mut self) {
570 self.descartadas += 1;
571 }
572
573 /// Empuja una orden ya armada, sin pasar por los constructores.
574 ///
575 /// Salida de emergencia para el encoder —que a veces ya tiene la
576 /// `OrdenWire` hecha— y para los tests que necesitan fabricar una escena
577 /// inválida a propósito. **No interna estilos**: una orden de texto empujada
578 /// así lleva el índice que le pusieron, exista o no en la tabla. Ésa es
579 /// justamente la propiedad que hace falta para probar que el reproductor no
580 /// confía en el cable.
581 pub fn empujar_crudo(&mut self, orden: OrdenWire) {
582 self.ordenes.push(orden);
583 }
584}
585
586/// Aplica una transformación a una orden ya grabada.
587///
588/// Se compone POR IZQUIERDA (`t * xf`), igual que en `llimphi_cpu`: la de la
589/// madre se aplica DESPUÉS de la de la hija. La del pincel se deja como está —
590/// es relativa a la forma, no al mundo.
591fn trasladar(orden: &OrdenWire, t: Affine) -> OrdenWire {
592 let mut o = orden.clone();
593 let componer = |xf: &mut [f32; 6]| *xf = afin_a_wire(t * wire_a_afin(*xf));
594 match &mut o {
595 OrdenWire::Rellenar { xf, .. }
596 | OrdenWire::Trazar { xf, .. }
597 | OrdenWire::AbrirCapa { xf, .. }
598 | OrdenWire::AbrirMascaraLuminancia { xf, .. }
599 | OrdenWire::Imagen { xf, .. }
600 | OrdenWire::RectDifuso { xf, .. } => componer(xf),
601 // El texto se compone por la MISMA matriz que todo lo demás; lo único
602 // distinto es que su ubicación elige representación (ver
603 // `UbicacionWire`), así que componer puede devolverla más barata.
604 OrdenWire::Texto(t_orden) => t_orden.xf = t_orden.xf.componer(t),
605 OrdenWire::CerrarCapa => {}
606 }
607 o
608}
609
610/// Modo de mezcla normal, el default. Atajo para el encoder.
611pub fn mezcla_normal() -> MezclaWire {
612 MezclaWire::de(peniko::BlendMode::from(Mix::Normal))
613}
614
615/// Un pincel sólido, el atajo más usado.
616pub fn solido(c: Color) -> Brush {
617 Brush::Solid(c)
618}
619
620#[cfg(test)]
621mod pruebas_blobs {
622 use super::*;
623
624 /// Una imagen chiquita, del formato que usan las miniaturas del explorador.
625 fn imagen(w: u32, h: u32, relleno: u8) -> peniko::ImageBrush {
626 peniko::ImageBrush::new(peniko::ImageData {
627 data: peniko::Blob::from(alloc::vec![relleno; (w * h * 4) as usize]),
628 format: peniko::ImageFormat::Rgba8,
629 alpha_type: peniko::ImageAlphaType::Alpha,
630 width: w,
631 height: h,
632 })
633 }
634
635 /// **Los píxeles cruzan.** Era lo único que faltaba: la orden viajaba por
636 /// huella y del otro lado no había de dónde sacar la imagen, así que el
637 /// reproductor la contaba y NO la dibujaba — toda miniatura en negro.
638 #[test]
639 fn los_pixeles_de_una_imagen_sobreviven_el_cable() {
640 let img = imagen(4, 4, 0xAB);
641 let mut e = EscenaWire::new();
642 e.draw_image(&img, Affine::IDENTITY);
643 assert_eq!(e.blobs().len(), 1, "la escena tiene que traer el blob");
644
645 let bytes = postcard::to_allocvec(&e).expect("serializa");
646 let ida: EscenaWire = postcard::from_bytes(&bytes).expect("deserializa");
647 let b = &ida.blobs()[0];
648 assert_eq!(b.huella, huella_imagen(&img));
649 assert_eq!((b.ancho, b.alto), (4, 4));
650 assert_eq!(b.datos.len(), 4 * 4 * 4);
651 // Y se reconstruye igual del otro lado: si el formato o el alfa se
652 // perdieran, la imagen saldría con los canales cambiados en vez de no
653 // salir — un defecto mucho más difícil de ver.
654 let vuelta = imagen_de_blob(b);
655 assert_eq!(huella_imagen(&vuelta), huella_imagen(&img));
656 }
657
658 /// **La misma imagen dos veces pesa una.** Una grilla de miniaturas dibuja
659 /// la misma textura en muchas órdenes; mandarla por orden sería multiplicar
660 /// el cuadro por la cantidad de celdas.
661 #[test]
662 fn la_misma_imagen_no_se_guarda_dos_veces() {
663 let img = imagen(4, 4, 7);
664 let mut e = EscenaWire::new();
665 e.draw_image(&img, Affine::IDENTITY);
666 e.draw_image(&img, Affine::translate((10.0, 0.0)));
667 assert_eq!(e.blobs().len(), 1);
668 assert_eq!(e.ordenes().len(), 2, "las ÓRDENES sí son dos");
669 }
670
671 /// **Y entre cuadros la dedup la hace quien envía.** `retener_blobs` es lo
672 /// que deja mandar los píxeles una sola vez por conexión: sin esto, cada
673 /// fotograma remandaría todas las miniaturas visibles.
674 #[test]
675 fn retener_blobs_deja_pasar_solo_lo_que_no_se_mando() {
676 let mut e = EscenaWire::new();
677 e.draw_image(&imagen(2, 2, 1), Affine::IDENTITY);
678 e.draw_image(&imagen(2, 2, 2), Affine::IDENTITY);
679 assert_eq!(e.blobs().len(), 2);
680 let primera = e.blobs()[0].huella;
681 e.retener_blobs(|h| h != primera);
682 assert_eq!(e.blobs().len(), 1);
683 assert_ne!(e.blobs()[0].huella, primera);
684 }
685
686 /// **Lo que no entra en el marco se CUENTA, no se manda.** Un cuadro que se
687 /// pasa del tope del cable no pierde una imagen: pierde la pantalla.
688 #[test]
689 fn un_blob_enorme_no_viaja_y_queda_contado() {
690 // 2048×2048 RGBA = 16 MiB, justo arriba del tope.
691 let grande = imagen(2048, 2049, 0);
692 let mut e = EscenaWire::new();
693 e.draw_image(&grande, Affine::IDENTITY);
694 assert_eq!(e.blobs().len(), 0, "no viaja");
695 assert_eq!(e.blobs_enormes(), 1, "pero se dice");
696 assert_eq!(e.ordenes().len(), 1, "la orden va igual: el hueco se ve");
697 }
698}
699
700#[cfg(test)]
701mod pruebas;