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
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