wandres.dev
TRAZO Y RELLENO · Estilos de línea y pintura

La regla de relleno: nonzero frente a evenodd

Entender los dos algoritmos que deciden qué es interior en una forma con contornos cruzados, y usar el sentido del recorrido como herramienta de diseño.

⏱ 15 min

Cuando una ruta se cruza consigo misma o contiene contornos anidados, “el interior” deja de ser evidente y hace falta un algoritmo que lo defina. El canvas ofrece los dos que existen en gráficos vectoriales, y la diferencia entre ellos no es un detalle: decide si un anillo tiene agujero, si una estrella de cinco puntas está hueca por dentro y si un recorte hace lo que esperas.

🎯 Al terminar esta lección sabrás
  • Describir los algoritmos nonzero y evenodd en términos del rayo y los cruces.
  • Predecir el relleno de una forma con contornos anidados según la regla y el sentido.
  • Usar el sentido del recorrido como mecanismo para crear agujeros.
  • Aplicar la regla en fill, clip e isPointInPath de forma coherente.

Los dos algoritmos

Ambos parten de la misma idea: para saber si un punto está dentro, se lanza un rayo desde ese punto hacia el infinito en cualquier dirección y se examinan los cruces con el contorno.

evenodd cuenta los cruces. Si el número es impar, el punto está dentro; si es par, fuera. Es el algoritmo más simple y no le importa en qué dirección se cruza el contorno.

nonzero lleva un contador con signo. Empieza en cero; cada cruce de un contorno que va en un sentido suma uno, y cada cruce en el sentido contrario resta uno. Si el total es distinto de cero, el punto está dentro. Es el algoritmo por defecto del canvas y de SVG.

La consecuencia es que nonzero es sensible a la orientación de los contornos y evenodd no.

ctx.fill();              // nonzero, por defecto
ctx.fill('evenodd');
ctx.fill(ruta, 'evenodd');
ctx.clip('evenodd');
ctx.isPointInPath(x, y, 'evenodd');

El anillo, caso por caso

Dos círculos concéntricos, el exterior de radio 80 y el interior de radio 40. Un punto en la zona interior cruza dos contornos hacia fuera.

Con evenodd: dos cruces, número par, el punto está fuera. Hay agujero, siempre, independientemente del sentido.

Con nonzero depende de los sentidos. Si ambos círculos van en el mismo sentido, los dos cruces suman en la misma dirección y el contador vale 2, distinto de cero: no hay agujero. Si van en sentidos opuestos, uno suma y otro resta, el contador vale 0: sí hay agujero.

// A: mismo sentido + nonzero -> disco lleno
ctx.beginPath();
ctx.arc(90, 100, 70, 0, Math.PI * 2, false);
ctx.moveTo(130, 100);
ctx.arc(90, 100, 35, 0, Math.PI * 2, false);
ctx.fillStyle = '#89b4fa'; ctx.fill();

// B: sentidos opuestos + nonzero -> anillo
ctx.beginPath();
ctx.arc(260, 100, 70, 0, Math.PI * 2, false);
ctx.moveTo(295, 100);
ctx.arc(260, 100, 35, 0, Math.PI * 2, true);   // invertido
ctx.fillStyle = '#a6e3a1'; ctx.fill();

// C: mismo sentido + evenodd -> anillo tambien
ctx.beginPath();
ctx.arc(430, 100, 70, 0, Math.PI * 2, false);
ctx.moveTo(465, 100);
ctx.arc(430, 100, 35, 0, Math.PI * 2, false);
ctx.fillStyle = '#f9e2af'; ctx.fill('evenodd');

Las tres formas tienen exactamente la misma geometría y producen tres resultados distintos. Eso es todo lo que hay que entender de las reglas de relleno.

La estrella de cinco puntas

El ejemplo canónico del cruce consigo misma es el pentagrama trazado de un tirón, saltando vértices.

function pentagrama(ctx, cx, cy, r) {
  ctx.beginPath();
  for (let i = 0; i < 5; i++) {
    const a = -Math.PI / 2 + (i * 4 * Math.PI) / 5;   // saltar de 2 en 2
    const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.closePath();
}

pentagrama(ctx, 100, 110, 80);
ctx.fillStyle = '#cba6f7'; ctx.fill();            // nonzero: estrella maciza

pentagrama(ctx, 300, 110, 80);
ctx.fillStyle = '#f38ba8'; ctx.fill('evenodd');   // evenodd: pentagono hueco

Con nonzero el pentágono central queda relleno porque todos los cruces van en el mismo sentido y el contador vale 2. Con evenodd queda hueco porque hay dos cruces. Es exactamente la diferencia entre la estrella maciza de una bandera y el pentagrama esotérico.

El sentido como herramienta

Una vez entendido, el sentido del recorrido se convierte en un mecanismo de diseño. La técnica se llama coloquialmente regla del contorno inverso y es la que usan los formatos de fuentes: los contornos exteriores de un glifo van en un sentido y los agujeros —el hueco de una o, los dos de una B— van en el contrario.

En el canvas hay tres formas de invertir un contorno:

// 1. arc y ellipse: el ultimo parametro
ctx.arc(cx, cy, r, 0, Math.PI * 2, true);

// 2. rect: dimensiones negativas
ctx.rect(x + w, y + h, -w, -h);

// 3. poligono a mano: recorrer los puntos al reves
for (let i = puntos.length - 1; i >= 0; i--) { /* lineTo */ }

Con eso se construye una máscara de cualquier complejidad usando la regla por defecto:

/** Un panel con varios agujeros rectangulares. */
function panelPerforado(ctx, x, y, w, h, agujeros) {
  ctx.beginPath();
  ctx.rect(x, y, w, h);                                   // horario
  for (const a of agujeros) {
    ctx.rect(a.x + a.w, a.y + a.h, -a.w, -a.h);           // antihorario
  }
  ctx.fillStyle = 'rgba(17,17,27,0.85)';
  ctx.fill();
}

// Efecto de foco: oscurecer todo menos unas zonas
panelPerforado(ctx, 0, 0, 500, 300, [
  { x: 60, y: 60, w: 120, h: 80 },
  { x: 260, y: 120, w: 160, h: 100 },
]);

Ese patrón —oscurecer la pantalla dejando huecos— es la base de los tours guiados y de los efectos de resaltado, y se resuelve sin recortes ni composición: solo con el sentido del recorrido.

La regla de relleno no se guarda en el estado, y eso rompe la simetría de la API

Aquí hay una asimetría de diseño de la API que provoca bugs sutiles y que conviene tener presente. Casi todo en el canvas es estado: fillStyle, lineWidth, globalAlpha, la matriz, el recorte. Todo eso se asigna una vez y persiste hasta que lo cambies, y save/restore lo gestionan. La regla de relleno no. Es un argumento de cada llamada, sin ningún estado asociado, y por tanto hay que pasarla explícitamente en cada fill, cada clip y cada isPointInPath. La consecuencia práctica es que es facilísimo pasarla en el fill y olvidarla en el isPointInPath, con lo que el hit testing dice que un punto está dentro de un agujero que visualmente está vacío. Es un bug con un síntoma desconcertante —“al hacer clic en el centro del anillo, selecciona el anillo”— cuya causa está a veinte líneas de distancia. La segunda consecuencia es más sutil todavía: clip() sin argumento usa nonzero, así que un recorte construido para funcionar con evenodd recorta al revés. Y hay una tercera, que aparece al portar código desde SVG: en SVG la regla es una propiedad, fill-rule, que se hereda y se puede poner en un grupo. Al traducir un SVG a canvas, esa propiedad heredada se pierde silenciosamente y las formas con agujeros salen macizas. La defensa es la disciplina de escribir una función envolvente por forma que aplique la regla correcta en las tres operaciones, en lugar de llamar al contexto directamente desde varios sitios.

function anillo(ctx, cx, cy, re, ri) {
  const p = new Path2D();
  p.arc(cx, cy, re, 0, Math.PI * 2);
  p.moveTo(cx + ri, cy);
  p.arc(cx, cy, ri, 0, Math.PI * 2);
  return {
    pintar: () => ctx.fill(p, 'evenodd'),
    contiene: (x, y) => ctx.isPointInPath(p, x, y, 'evenodd'),
    recortar: () => ctx.clip(p, 'evenodd'),
  };
}

Cuál usar

En la práctica el criterio es este.

Usa evenodd cuando la forma venga de datos o de un origen que no controlas, porque no depende del sentido y por tanto es predecible. Es lo que quieres para trazados importados, para geometría generada y para cualquier cosa donde no puedas garantizar la orientación.

Usa nonzero cuando construyas la forma tú y quieras control fino: te permite decidir por contorno si suma o resta, lo que hace posible formas que evenodd no puede expresar, como un contorno que se solapa consigo mismo y sigue siendo macizo.

Y una nota sobre el trazado: stroke no tiene regla de relleno. Traza todos los contornos independientemente de su orientación y de si se cruzan. Un anillo trazado se ve como dos círculos con línea, tanto si el relleno tendría agujero como si no.