tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-compositor/src/render/rango.rs Rust · 630 líneas · 31.0 KB Historia Quién tocó qué crudo

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

1use super::*;
2
3#[allow(clippy::too_many_arguments)]
4pub fn paint_range<Msg>(
5    scene: &mut Lienzo,
6    mounted: &Mounted<Msg>,
7    computed: &ComputedLayout,
8    typesetter: &mut llimphi_text::Typesetter,
9    hover_idx: Option<usize>,
10    drop_hover_idx: Option<usize>,
11    start: usize,
12    end: usize,
13    base_xf: Affine,
14) {
15    // Stack de las capas `push_layer` activas. Vello requiere pop_layer en
16    // orden LIFO estricto, así que mantenemos un único stack común y popeamos
17    // en el orden inverso al push. Cada entrada es `(subtree_end, máscara?)`:
18    // la mayoría son `None` (clip y/o alpha — la capa sólo se cierra con
19    // `pop_layer`); las de `mask-image` llevan `Some((imagen, rect, xf))` y al
20    // cerrar aplican la luminancia de la máscara sobre el subárbol ya pintado
21    // (ver `paint_mask_close`) antes del `pop_layer` de la capa de aislamiento.
22    // Dos entradas con el mismo `subtree_end` (p. ej. alpha + mask + clip sobre
23    // el mismo nodo) se cierran en el orden inverso al push.
24    // Payload de máscara: (capa 0, capas extra `(img, op)`, rect border-box, xf,
25    // placement compartido). `paint_mask_close` lo consume al cerrar.
26    type MaskClose = (
27        Image,
28        Vec<(Image, MaskCompose)>,
29        KurboRect,
30        Affine,
31        Option<MaskPlacement>,
32    );
33    // Tercer campo: `es_clip` — cuenta SOLO las capas de clip para el cap de
34    // profundidad (ver abajo). Alpha/blend/mask no cuentan (raras y someras).
35    let mut layer_stack: Vec<(usize, Option<MaskClose>, bool)> = Vec::new();
36    // Profundidad de CLIPS anidados vigente. Vello colapsa la ESCENA ENTERA
37    // (100% transparente, sin error) cuando el stack de blend/clip supera su
38    // límite (~6 capas en Iris Xe/Vulkan; medido con vim_drawer_probe
39    // PROBE_CLIPS: +2 sobre el árbol del drawer pinta, +4 vacía). Cap duro:
40    // los clips MÁS PROFUNDOS que MAX_CLIP_DEPTH no se pushean — un recorte
41    // cosmético que casi nunca recorta nada a esa altura; perderlo es
42    // invisible al lado de perder la escena completa (el bug del "cuerpo
43    // fantasma" del drawer de pata, 2026-07-15).
44    const MAX_CLIP_DEPTH: usize = 3;
45    // **El techo de capas TOTALES**, y es la deuda que la nota de arriba dejó
46    // escrita: lo que hace colapsar a vello es la profundidad del stack
47    // COMPLETO —clips + alpha + blend + mask + la capa de cada imagen—, pero el
48    // cap sólo miraba los clips. Un menú contextual al fondo del árbol del cajón
49    // de pata apila su velo y su panel sobre una cadena que ya venía cargada, y
50    // el resultado, medido el 2026-09-15, es la firma exacta del colapso: la
51    // escena de esa surface sale **100 % vacía** mientras las otras cinco
52    // superficies pintan bien, y vuelve sola al cerrar el menú.
53    // Perder una capa de alfa a esta profundidad es un degradé que no se ve;
54    // perder la escena es el cajón congelado. Misma decisión que los clips.
55    // **Regulable en caliente** ([`super::medicion::max_capas`]): el umbral real
56    // de esta GPU se mide barriendo valores en metal, y cada valor a compilar
57    // son treinta minutos. Medido el 2026-09-15 sobre el cajón de pata con el
58    // menú contextual abierto: los árboles que llegan a `prof` 3 y 4 pintan, el
59    // de 251 nodos que llega a 5 sale **100 % vacío**. O sea que el techo de
60    // esta Iris Xe está entre 4 y 5, y no en ~6 como estimó la nota de julio.
61    let max_layer_depth = super::medicion::max_capas();
62    let mut clip_depth: usize = 0;
63    // Sonda de capas (`LLIMPHI_DEBUG_CAPAS=1`). El cap de arriba cuenta SÓLO los
64    // clips, pero lo que hace colapsar a vello es la profundidad **total** del
65    // stack — clips + alpha + blend + mask + la capa que abre cada imagen. O sea
66    // que nadie sabía la profundidad real de una escena, y sin ese número no se
67    // puede decir si una "ventana que desaparece sin log" está contra el techo o
68    // le sobra margen. Esto lo mide y lo dice.
69    let sonda = sonda_capas_activa();
70    // Profundidad máxima alcanzada = `layer_stack.len()` (clips + alpha + blend +
71    // mask) más la capa efímera de la imagen, e imágenes dibujadas en la escena.
72    let mut prof_max: usize = 0;
73    let mut imagenes: usize = 0;
74    // Nodos que TRAEN una imagen, se haya dibujado o no. La diferencia contra
75    // `imagenes` separa «el árbol no la tiene» de «la tiene y la guarda de
76    // dimensiones la descartó» — dos defectos en dos crates distintos.
77    let mut con_imagen: usize = 0;
78    // Stack de transformaciones afines de subtree. Cada entrada guarda el
79    // `subtree_end` y la `cur_xf` previa para restaurarla al salir del
80    // subárbol. `cur_xf` es el producto acumulado de todos los `transform`
81    // de los ancestros activos — se multiplica en cada draw call. Cuando
82    // ningún nodo transforma, queda en `base_xf` y el paint es idéntico
83    // al previo (cero regresión).
84    let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
85    let mut cur_xf = base_xf;
86    // Stack de scrollports para `position: sticky`: cada nodo que recorta
87    // (`clip==true`) es el scrollport de su subárbol. Un nodo sticky se pega
88    // relativo al scrollport activo (o al viewport entero si no hay ninguno).
89    // Entrada = `(subtree_end, rect_absoluto)`, restaurada al salir del subárbol.
90    let mut scrollport_stack: Vec<(usize, (f32, f32, f32, f32))> = Vec::new();
91    // Salto de subárboles "over" (View::over): índices en [idx, skip_until)
92    // no se pintan en esta pasada — los pinta `paint_over_layers` después
93    // del pase GPU. Los cierres de capas/xf pendientes se resuelven solos
94    // al retomar (los `while` de abajo drenan todo lo que quedó con
95    // `end ≤ idx`); dentro del rango salteado no se pushea nada.
96    let mut skip_until = 0usize;
97    for idx in start..end {
98        if idx < skip_until {
99            continue;
100        }
101        let node = &mounted.nodes[idx];
102        // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end). Si la
103        // capa es una máscara, aplicamos su luminancia ANTES del pop.
104        while let Some(&(end, _, _)) = layer_stack.last() {
105            if idx >= end {
106                let (_, mask, es_clip) = layer_stack.pop().unwrap();
107                if let Some((img, extra, rect, xf, placement)) = &mask {
108                    paint_mask_close(scene, img, extra, *rect, *xf, *placement);
109                }
110                if es_clip {
111                    clip_depth = clip_depth.saturating_sub(1);
112                }
113                scene.pop_layer();
114            } else {
115                break;
116            }
117        }
118        // Restaurá la transformación al salir de subárboles transformados.
119        while let Some(&(end, prev)) = xf_stack.last() {
120            if idx >= end {
121                cur_xf = prev;
122                xf_stack.pop();
123            } else {
124                break;
125            }
126        }
127        // Salí de un scrollport al pasar su subárbol.
128        while let Some(&(end, _)) = scrollport_stack.last() {
129            if idx >= end {
130                scrollport_stack.pop();
131            } else {
132                break;
133            }
134        }
135        // Subárbol "over" (View::over): no pinta en la pasada base. SÍ
136        // pinta cuando esta llamada ARRANCA en él (idx == start) — así lo
137        // invoca `paint_over_layers`, que reusa este mismo camino para la
138        // pasada final.
139        if node.over_layer && idx != start {
140            skip_until = node.subtree_end;
141            continue;
142        }
143        let Some(r) = computed.get(node.id) else {
144            continue;
145        };
146        // Underlay (scene-split de fantasmas): el contenido VIEJO de un
147        // switch se apendea acá — en la posición z del nodo, dentro del
148        // clip/alpha de los ancestros ya abiertos, y componiendo su afín
149        // acumulada (la subescena está en coords absolutas). Va ANTES del
150        // sticky/transform/alpha PROPIOS del nodo: esos pertenecen al
151        // contenido nuevo, no al fantasma. El envoltorio (alpha del fade +
152        // afín de salida) ya viene horneado por `prepare_underlays`.
153        if let Some(under) = &node.underlay {
154            scene.append(under, Some(cur_xf));
155        }
156        // `position: sticky`: pega el nodo dentro del scrollport activo (o el
157        // viewport entero si no hay ancestro que recorte). Se resuelve ANTES del
158        // transform CSS y de abrir el clip de este nodo, para que toda su pintura
159        // caiga en la posición pegada. El offset se compone en `cur_xf` vía el
160        // mismo stack que `transform` (se restaura al salir del subárbol).
161        if let Some(insets) = node.sticky {
162            let sp = scrollport_stack
163                .last()
164                .map(|&(_, rect)| rect)
165                .unwrap_or((0.0, 0.0, f32::MAX, f32::MAX));
166            let (dx, dy) = sticky_offset(insets, r, sp);
167            if dx != 0.0 || dy != 0.0 {
168                xf_stack.push((node.subtree_end, cur_xf));
169                cur_xf *= Affine::translate((dx as f64, dy as f64));
170            }
171        }
172        // Este nodo, si recorta, es el scrollport de sus descendientes sticky.
173        if node.clip {
174            scrollport_stack.push((node.subtree_end, (r.x, r.y, r.w, r.h)));
175        }
176        // Transform CSS del nodo: se aplica alrededor del centro de su rect
177        // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se
178        // empuja ANTES del alpha/fill para que toda la pintura del subtree
179        // (incl. la capa de alpha y el clip) caiga en el espacio transformado.
180        if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) {
181            xf_stack.push((node.subtree_end, cur_xf));
182            cur_xf *= centered;
183        }
184        // `mix-blend-mode` (Fase 7.1237): abrí una capa de mezcla para el
185        // subárbol del nodo con el modo CSS resuelto. Va ANTES del alpha (es la
186        // capa más externa) para que el elemento entero —incluida su propia
187        // opacidad— se mezcle como una unidad contra el backdrop ya pintado. Al
188        // cerrarse (loop de cierre / drain final) vello compone el subárbol
189        // aislado con el blend indicado. v1: el backdrop es la escena ya
190        // pintada, no un fondo aislado (exacto con contenido opaco debajo).
191        if let Some(bm) = node.blend {
192            let rect = KurboRect::new(
193                r.x as f64,
194                r.y as f64,
195                (r.x + r.w) as f64,
196                (r.y + r.h) as f64,
197            );
198            if layer_stack.len() < max_layer_depth {
199                scene.push_layer(Fill::NonZero, bm, 1.0, cur_xf, &rect);
200                layer_stack.push((node.subtree_end, None, false));
201            }
202        }
203        // Alpha de subtree: push ANTES de cualquier paint de este nodo
204        // para que fill/text/image/painter/children entren en la misma
205        // capa y se compongan juntos al alfa indicado. Si el nodo tiene
206        // hijos, su `subtree_end > idx + 1` y la capa permanece abierta
207        // hasta que el loop alcance el primer índice fuera del subárbol.
208        // Para nodos hoja con alpha el push y el pop son consecutivos —
209        // funcionalmente equivalente a multiplicar el alpha del fill,
210        // pero permite usar el mismo API sin distinguir hoja vs rama.
211        if let Some(a) = node.alpha {
212            let rect = KurboRect::new(
213                r.x as f64,
214                r.y as f64,
215                (r.x + r.w) as f64,
216                (r.y + r.h) as f64,
217            );
218            if layer_stack.len() < max_layer_depth {
219                scene.push_layer(Fill::NonZero, Mix::Normal, a, cur_xf, &rect);
220                layer_stack.push((node.subtree_end, None, false));
221            }
222        }
223        // `mask-image` (Fase 7.1226): abrí una capa de aislamiento para el
224        // subárbol del nodo. La luminancia de la máscara se aplica al CERRARLA
225        // (en el loop de cierre / drain final, vía `paint_mask_close`), así
226        // recorta sólo a este nodo + hijos y no a los hermanos previos. Va
227        // DESPUÉS del alpha (afuera del clip-path, que se pushea al final del
228        // bloque) para envolver fill + contenido + hijos.
229        if let Some(mask_img) = node.mask_image.as_ref() {
230            let rect = KurboRect::new(
231                r.x as f64,
232                r.y as f64,
233                (r.x + r.w) as f64,
234                (r.y + r.h) as f64,
235            );
236            scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &rect);
237            layer_stack.push((
238                node.subtree_end,
239                Some((
240                    mask_img.clone(),
241                    node.mask_extra.clone(),
242                    rect,
243                    cur_xf,
244                    node.mask_placement,
245                )),
246                false,
247            ));
248        }
249        // Sombra (drop shadow): se pinta ANTES del relleno para quedar
250        // detrás. Usa el blur gaussiano nativo de vello sobre un rect
251        // redondeado offseteado + inflado por `spread`.
252        if let Some(sh) = node.shadow.as_ref() {
253            if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 {
254                let rect = KurboRect::new(
255                    (r.x as f64) + sh.dx - sh.spread,
256                    (r.y as f64) + sh.dy - sh.spread,
257                    (r.x + r.w) as f64 + sh.dx + sh.spread,
258                    (r.y + r.h) as f64 + sh.dy + sh.spread,
259                );
260                let radius = (node.radius + sh.spread).max(0.0);
261                scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur);
262            }
263        }
264        // `filter: drop-shadow(...)` (Fase 7.1234): una o más sombras Gaussianas
265        // del border-box, detrás del relleno. Misma primitiva que box-shadow; v1
266        // pinta la sombra del rect, no de la silueta alpha del subárbol. En
267        // orden de la lista (la primera declarada queda más atrás).
268        for op in node.filter.iter().rev() {
269            if let FilterOp::DropShadow(sh) = op {
270                if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 {
271                    let rect = KurboRect::new(
272                        (r.x as f64) + sh.dx - sh.spread,
273                        (r.y as f64) + sh.dy - sh.spread,
274                        (r.x + r.w) as f64 + sh.dx + sh.spread,
275                        (r.y + r.h) as f64 + sh.dy + sh.spread,
276                    );
277                    let radius = (node.radius + sh.spread).max(0.0);
278                    scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur);
279                }
280            }
281        }
282        // Prioridad de pintura: drop-hover (drag activo) > hover normal >
283        // gradiente base > fill color base. Solo aplica el override si el
284        // slot correspondiente está poblado; el siguiente cae como fallback.
285        let hover_color = if Some(idx) == drop_hover_idx {
286            node.drop_hover_fill.or(node.hover_fill).or(node.fill)
287        } else if Some(idx) == hover_idx {
288            node.hover_fill.or(node.fill)
289        } else {
290            None
291        };
292        let rr = node_rrect(
293            r.x as f64,
294            r.y as f64,
295            (r.x + r.w) as f64,
296            (r.y + r.h) as f64,
297            node.radius,
298            node.corner_radii,
299            0.0,
300        );
301        if let Some(color) = hover_color {
302            // Hover/drop gana sobre el gradiente y el fill base.
303            scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
304        } else if let Some(grad) = node.fill_gradient.as_ref() {
305            // Gradiente autoreado en `[0,1]²`, mapeado al rect vía
306            // brush_transform (incluye la transformación acumulada).
307            // O, con `fill_gradient_superficie`, en px de la superficie: un
308            // solo degradé para todos los nodos, cada uno muestra su tramo.
309            let brush_xf = if node.fill_gradient_superficie {
310                cur_xf
311            } else {
312                cur_xf
313                    * Affine::translate((r.x as f64, r.y as f64))
314                    * Affine::scale_non_uniform(r.w as f64, r.h as f64)
315            };
316            scene.fill(Fill::NonZero, cur_xf, grad, Some(brush_xf), &rr);
317        } else if let Some(color) = node.fill {
318            scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
319        }
320        // Borde (stroke) sobre el relleno, inset media línea hacia adentro.
321        if let Some(b) = node.border.as_ref() {
322            if b.width > 0.0 && b.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 {
323                let inset = b.width * 0.5;
324                let brr = node_rrect(
325                    r.x as f64,
326                    r.y as f64,
327                    (r.x + r.w) as f64,
328                    (r.y + r.h) as f64,
329                    node.radius,
330                    node.corner_radii,
331                    inset,
332                );
333                scene.stroke(&Stroke::new(b.width), cur_xf, b.color, None, &brr);
334            }
335        }
336        if let Some(image) = node.image.as_ref() {
337            if sonda {
338                con_imagen += 1;
339            }
340            // Encaje seleccionable (Bloque 12) — Contain/Cover/Fill/None.
341            // Siempre clippeamos al `node_rrect` para respetar
342            // `radius`/`corner_radii` (avatares + cards) y para que
343            // `Cover`/`None` no derramen fuera del nodo.
344            if image.image.width > 0 && image.image.height > 0 && r.w > 0.0 && r.h > 0.0 {
345                let sx = r.w as f64 / image.image.width as f64;
346                let sy = r.h as f64 / image.image.height as f64;
347                let fit = node.image_fit.unwrap_or(ImageFit::Contain);
348                let transform = match fit {
349                    ImageFit::Contain => {
350                        let s = sx.min(sy);
351                        let disp_w = image.image.width as f64 * s;
352                        let disp_h = image.image.height as f64 * s;
353                        let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
354                        let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
355                        Affine::translate((tx, ty)) * Affine::scale(s)
356                    }
357                    ImageFit::Cover => {
358                        let s = sx.max(sy);
359                        let disp_w = image.image.width as f64 * s;
360                        let disp_h = image.image.height as f64 * s;
361                        let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
362                        let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
363                        Affine::translate((tx, ty)) * Affine::scale(s)
364                    }
365                    ImageFit::Fill => {
366                        Affine::translate((r.x as f64, r.y as f64))
367                            * Affine::scale_non_uniform(sx, sy)
368                    }
369                    ImageFit::None => {
370                        let disp_w = image.image.width as f64;
371                        let disp_h = image.image.height as f64;
372                        let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
373                        let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
374                        Affine::translate((tx, ty))
375                    }
376                };
377                // La capa de recorte de la imagen **cuenta contra el mismo
378                // presupuesto** que los clips de nodo (2026-07-29). Antes se
379                // pusheaba SIEMPRE, sin mirar `clip_depth`: o sea que el cap que
380                // existe justamente para no gatillar el colapso silencioso de
381                // vello (escena entera transparente, sin error ni log — ver la
382                // nota de `MAX_CLIP_DEPTH`) lo excedía **cada nodo con imagen**,
383                // que en una grilla de miniaturas son decenas. Al tope, la imagen
384                // se dibuja sin su capa: se pierde el redondeo de esquinas y el
385                // recorte al nodo, que es cosmético; perder la escena no lo es.
386                let recortar = clip_depth < MAX_CLIP_DEPTH;
387                if sonda {
388                    imagenes += 1;
389                    prof_max = prof_max.max(layer_stack.len() + usize::from(recortar));
390                }
391                if recortar {
392                    let clip_rr = node_rrect(
393                        r.x as f64,
394                        r.y as f64,
395                        (r.x + r.w) as f64,
396                        (r.y + r.h) as f64,
397                        node.radius,
398                        node.corner_radii,
399                        0.0,
400                    );
401                    scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rr);
402                }
403                scene.draw_image(image, cur_xf * transform);
404                if recortar {
405                    scene.pop_layer();
406                }
407            }
408        }
409        if let Some(painter) = node.painter.as_ref() {
410            (painter)(
411                scene,
412                typesetter,
413                PaintRect {
414                    x: r.x,
415                    y: r.y,
416                    w: r.w,
417                    h: r.h,
418                },
419            );
420        }
421        if let Some(text) = node.text.as_ref() {
422            let has_spans = text
423                .spans
424                .as_ref()
425                .map(|s| !s.is_empty())
426                .unwrap_or(false);
427            if has_spans {
428                // RichText (Bloque 13): defaults a nivel bloque + spans
429                // sobreescriben size/weight/italic/family/color/underline/
430                // strikethrough por rango de bytes. Respeta `max_width = r.w`
431                // (wrap a párrafo) y la alignment del bloque; para Center
432                // también centramos verticalmente como en el camino uniforme.
433                let spans = text.spans.as_ref().unwrap();
434                let layout = typesetter.layout_spans(
435                    &text.content,
436                    text.size_px,
437                    text.color,
438                    text.weight,
439                    text.line_height,
440                    text.italic,
441                    text.font_family.as_deref(),
442                    text.underline,
443                    text.strikethrough,
444                    spans,
445                    Some(r.w + TEXT_WRAP_ROUNDING_SLACK),
446                    text.alignment,
447                );
448                let origin =
449                    if matches!(text.alignment, llimphi_text::Alignment::Center) {
450                        let lh = layout.height() as f64;
451                        (
452                            r.x as f64,
453                            r.y as f64 + ((r.h as f64 - lh) * 0.5).max(0.0),
454                        )
455                    } else {
456                        (r.x as f64, r.y as f64)
457                    };
458                llimphi_text::draw_layout_runs_xf(
459                    scene,
460                    &layout,
461                    cur_xf * Affine::translate(origin),
462                );
463            } else if let Some(runs) = text.runs.as_ref() {
464                // Texto multicolor (syntax highlighting): una sola pasada de
465                // shaping con color por rango, anclado arriba-izquierda. Cae
466                // por el flujo normal (clip/alpha se cierran como siempre).
467                let layout = typesetter.layout_runs(
468                    &text.content,
469                    text.size_px,
470                    text.color,
471                    runs,
472                    text.alignment,
473                    text.line_height,
474                    text.weight,
475                    text.underline,
476                    text.strikethrough,
477                    text.font_family.as_deref(),
478                );
479                // `cur_xf *` para que el texto multicolor herede la
480                // transformación del subárbol (scroll/rotación del padre), igual
481                // que el texto de color único de abajo. Sin esto se pintaba en
482                // coords de layout crudas y se desalineaba al scrollear.
483                llimphi_text::draw_layout_runs_xf(
484                    scene,
485                    &layout,
486                    cur_xf * Affine::translate((r.x as f64, r.y as f64)),
487                );
488            } else {
489                // Parley resuelve la alineación horizontal vía max_width +
490                // alignment. Para Center también centramos verticalmente; para
491                // Start/End/Justify anclamos arriba (párrafo/editor). Camino
492                // directo a `layout_clamped` para transportar `weight` y el
493                // clamp de `max_lines`/`ellipsis` del TextSpec.
494                // `white-space: nowrap`/`pre`: pintar en una sola línea (sin
495                // `max_width`), no envolver al ancho del rect — el texto
496                // desborda y lo recorta el `overflow` del contenedor si lo hay.
497                let paint_max_width =
498                    if text.no_wrap { None } else { Some(r.w + TEXT_WRAP_ROUNDING_SLACK) };
499                let layout = typesetter.layout_clamped(
500                    &text.content,
501                    text.size_px,
502                    paint_max_width,
503                    text.alignment,
504                    text.line_height,
505                    text.italic,
506                    text.font_family.as_deref(),
507                    text.weight,
508                    text.max_lines,
509                    text.ellipsis,
510                    text.underline,
511                    text.strikethrough,
512                    text.letter_spacing,
513                    text.word_spacing,
514                    text.overflow_wrap,
515                    &text.font_features,
516                    &text.font_variations,
517                    text.font_width,
518                );
519                let origin =
520                    if matches!(text.alignment, llimphi_text::Alignment::Center) {
521                        let m = llimphi_text::measurement(&layout);
522                        (
523                            r.x as f64,
524                            r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0),
525                        )
526                    } else {
527                        (r.x as f64, r.y as f64)
528                    };
529                llimphi_text::draw_layout_xf(
530                    scene,
531                    &layout,
532                    text.color,
533                    cur_xf * Affine::translate(origin),
534                );
535            }
536        }
537        if node.clip {
538            // El hit-test (más abajo) usa siempre el rect completo — el clip-path
539            // sólo afecta el pintado, una aproximación menor en su banda.
540            // Prioridad: path > polygon > elipse > inset/rect. `pushed` queda
541            // false sólo si un path() no parsea (no se abre capa → no se cierra)
542            // o si el CAP de profundidad de clips está alcanzado (ver arriba:
543            // más hondo que MAX_CLIP_DEPTH vello colapsa la escena entera; un
544            // clip profundo sin pushear es cosmético, la escena viva no).
545            let mut pushed = clip_depth < MAX_CLIP_DEPTH;
546            if pushed {
547            // Caja de referencia (clip-path geometry-box, Fase 7.1225): encoge
548            // el rect del nodo por `clip_ref_inset` ANTES de resolver la forma,
549            // así circle/ellipse/polygon/path y sus % se miden contra esa caja.
550            let [rit, rir, rib, ril] = node.clip_ref_inset.unwrap_or([0.0; 4]);
551            let (bx, by) = ((r.x + ril) as f64, (r.y + rit) as f64);
552            let (bw, bh) = ((r.w - ril - rir).max(0.0) as f64, (r.h - rit - rib).max(0.0) as f64);
553            if let Some((evenodd, d)) = &node.clip_path_svg {
554                // `clip-path: path()` — parsea el SVG y lo traslada al origen
555                // de la caja de referencia (user units px). from_svg falla → no
556                // recorta.
557                match llimphi_text::peniko::kurbo::BezPath::from_svg(d) {
558                    Ok(mut path) => {
559                        path.apply_affine(Affine::translate((bx, by)));
560                        let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero };
561                        scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path);
562                    }
563                    Err(_) => pushed = false,
564                }
565            } else if let Some((evenodd, pts)) = &node.clip_polygon {
566                // `clip-path: polygon()` — capa con un path cerrado. Cada punto
567                // resuelve sus % contra la caja de referencia; move_to al 1º,
568                // line_to al resto, close_path.
569                let mut path = llimphi_text::peniko::kurbo::BezPath::new();
570                for (i, p) in pts.iter().enumerate() {
571                    let px = bx + p[0] as f64 + p[1] as f64 / 100.0 * bw;
572                    let py = by + p[2] as f64 + p[3] as f64 / 100.0 * bh;
573                    if i == 0 {
574                        path.move_to((px, py));
575                    } else {
576                        path.line_to((px, py));
577                    }
578                }
579                path.close_path();
580                let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero };
581                scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path);
582            } else if let Some(s) = node.clip_ellipse {
583                // `clip-path: circle()/ellipse()` — capa elíptica. Centro y
584                // radios resuelven contra la caja de referencia. El centro local
585                // alimenta tanto la posición como el cómputo de los lados
586                // (closest/farthest-side).
587                let cxl = s[0] as f64 + s[1] as f64 / 100.0 * bw;
588                let cyl = s[2] as f64 + s[3] as f64 / 100.0 * bh;
589                let cx = bx + cxl;
590                let cy = by + cyl;
591                let rx = resolve_clip_radius(&s[4..9], cxl, cyl, bw, bh, true);
592                let ry = resolve_clip_radius(&s[9..14], cxl, cyl, bw, bh, false);
593                let ellipse = Ellipse::new((cx, cy), (rx, ry), 0.0);
594                scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &ellipse);
595            } else {
596                // `clip_inset` (clip-path: inset) encoge la caja de referencia
597                // desde cada borde; `None` (overflow:hidden / geometry-box solo)
598                // recorta a la caja de referencia completa.
599                let [ct, cr, cb, cl] = node.clip_inset.unwrap_or([0.0; 4]);
600                let clip_rect = KurboRect::new(
601                    bx + cl as f64,
602                    by + ct as f64,
603                    bx + bw - cr as f64,
604                    by + bh - cb as f64,
605                );
606                scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rect);
607            }
608            }
609            if pushed {
610                layer_stack.push((node.subtree_end, None, true));
611                clip_depth += 1;
612            }
613        }
614        if sonda {
615            prof_max = prof_max.max(layer_stack.len());
616        }
617    }
618    if sonda {
619        reportar_capas(prof_max, imagenes, con_imagen, end.saturating_sub(start));
620    }
621    // Cierra capas (clip + alpha + mask) que llegaron al final sin pop
622    // intermedio. Las de máscara aplican su luminancia antes del pop.
623    while let Some((_, mask, _)) = layer_stack.pop() {
624        if let Some((img, extra, rect, xf, placement)) = &mask {
625            paint_mask_close(scene, img, extra, *rect, *xf, *placement);
626        }
627        scene.pop_layer();
628    }
629}
630