gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-wire-escena/src/lib.rs Rust · 701 lines · 25.5 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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;