wandres.dev
FORMAS Y RUTAS · El modelo de path

El modelo de path: la ruta actual y sus subrutas

Entender la ruta actual como estado oculto del contexto, cómo se construye con moveTo y lineTo, qué son las subrutas y qué hace exactamente closePath.

⏱ 17 min

Casi todo lo que dibuja el canvas pasa por un único objeto invisible: la ruta actual. No tiene nombre, no se puede inspeccionar y no aparece en ninguna propiedad del contexto, pero está ahí, acumulando segmentos entre llamada y llamada, y es la causa del error más silencioso y más caro que se comete con esta API. Entender su ciclo de vida es entender la mitad del canvas.

🎯 Al terminar esta lección sabrás
  • Describir la ruta actual como estado del contexto y su ciclo de construcción y consumo.
  • Distinguir subruta de ruta y decir qué operación abre cada una.
  • Explicar la diferencia exacta entre closePath y volver al punto inicial con lineTo.
  • Predecir qué dibuja una secuencia de llamadas sin ejecutarla.

Construir y consumir

El modelo tiene dos fases separadas y esa separación es deliberada.

Primero construyes: beginPath descarta la ruta que hubiera y empieza una nueva; moveTo sitúa el lápiz sin trazar; lineTo, arc, bezierCurveTo y compañía añaden segmentos.

Después consumes: fill rellena la ruta, stroke traza su contorno, clip la convierte en región de recorte, isPointInPath pregunta si un punto está dentro.

Lo que sorprende es que consumir no destruye la ruta. Después de un fill, la ruta sigue exactamente igual, y puedes hacer stroke a continuación para obtener relleno y contorno de la misma forma. Eso es útil y deseado:

ctx.beginPath();
ctx.moveTo(60, 20);
ctx.lineTo(110, 100);
ctx.lineTo(10, 100);
ctx.closePath();

ctx.fillStyle = '#89b4fa';
ctx.fill();              // rellena
ctx.strokeStyle = '#1e1e2e';
ctx.lineWidth = 4;
ctx.stroke();            // traza la MISMA ruta, sin reconstruirla

Ese es el patrón correcto para una forma con borde. Reconstruir la ruta entre el relleno y el trazado es trabajo duplicado y además puede producir diferencias de redondeo.

Subrutas

Una ruta puede contener varias subrutas, que son secuencias de segmentos conectados. Cada moveTo cierra la subruta en curso y abre una nueva.

ctx.beginPath();
ctx.moveTo(20, 20); ctx.lineTo(80, 20);    // subruta 1
ctx.moveTo(20, 50); ctx.lineTo(80, 50);    // subruta 2
ctx.moveTo(20, 80); ctx.lineTo(80, 80);    // subruta 3
ctx.stroke();                               // traza las tres de una vez

Eso son tres líneas independientes en una sola ruta y un solo stroke. Es más rápido que tres rutas con tres trazados, porque cada llamada a stroke tiene un coste fijo de preparación que aquí se paga una vez.

Las subrutas también son la base de las formas con agujeros. Un anillo es una ruta con dos subrutas circulares, y qué queda relleno lo decide la regla de relleno, que tiene su propia lección.

Hay una peculiaridad que conviene conocer: algunos métodos añaden implícitamente un moveTo. rect, roundRect, ellipse y arc en ciertas condiciones abren su propia subruta. Y hay una asimetría: rect deja el punto actual en su esquina inicial y cierra la subruta, mientras que arc conecta con una línea recta desde el punto actual si ya había uno.

closePath no es lo que parece

closePath cierra la subruta actual añadiendo un segmento recto desde el punto actual hasta el punto inicial de esa subruta, y marca la subruta como cerrada. Esa segunda parte es la que importa y la que se ignora.

Comparemos:

// A: cerrar con closePath
ctx.beginPath();
ctx.moveTo(30, 30); ctx.lineTo(90, 30); ctx.lineTo(60, 85);
ctx.closePath();
ctx.lineWidth = 12; ctx.strokeStyle = '#a6e3a1';
ctx.stroke();

// B: "cerrar" volviendo al inicio con lineTo
ctx.beginPath();
ctx.moveTo(160, 30); ctx.lineTo(220, 30); ctx.lineTo(190, 85);
ctx.lineTo(160, 30);
ctx.stroke();

Los dos triángulos tienen la misma geometría, pero el vértice superior izquierdo se ve distinto. En el caso A hay una unión de línea, con el remate que dicte lineJoin. En el caso B hay dos extremos de línea que se solapan, con el remate que dicte lineCap, y con lineCap plano —el valor por defecto— aparece una muesca en la esquina.

Con grosores finos la diferencia es imperceptible; con grosores gruesos es escandalosa. Es la causa habitual de “mis polígonos tienen una esquina rota”.

Para el relleno, en cambio, la diferencia no existe: fill cierra implícitamente todas las subrutas abiertas antes de rellenar, porque una región sin cerrar no tendría sentido.

Un tercer matiz: después de closePath, el punto actual vuelve al inicio de la subruta cerrada, y el siguiente lineTo continúa desde ahí en la misma subruta. No abre una nueva. Si quieres una subruta nueva después de cerrar, necesitas un moveTo.

La ruta que olvidas es la que te cuesta la mitad de los fotogramas

El error más caro del canvas no produce ningún fallo visual inmediato, y por eso sobrevive en producción durante años. Consiste en olvidar beginPath dentro de un bucle. Como la ruta actual persiste entre llamadas, cada iteración añade su forma a las anteriores, y cada fill o stroke repinta todo lo acumulado desde el principio. En la iteración mil estás rellenando mil formas. El coste total no es lineal sino cuadrático: dibujar N formas cuesta del orden de N al cuadrado. Y aquí está lo pérfido: si todas las formas usan el mismo color, el resultado visual es correcto. No hay ningún síntoma salvo que la aplicación va lenta y va empeorando conforme crece la escena, que es justo el tipo de degradación que se atribuye a “los datos” o “el navegador”. El síntoma delator, cuando existe, es que un cambio de fillStyle a mitad de bucle repinta de golpe todas las formas anteriores con el color nuevo, y eso produce el bug clásico de “todas las barras salen del color de la última”. Cuando veas eso, no busques en tu lógica de colores: te falta un beginPath. Y la disciplina que lo evita es tonta y funciona: beginPath es la primera línea de cualquier bloque que dibuje una forma, sin excepción, aunque parezca redundante. Cuesta una llamada barata y te ahorra una clase entera de bugs.

Leer una secuencia sin ejecutarla

La habilidad práctica que hay que desarrollar es seguir mentalmente el estado de la ruta. Estas son las reglas completas, y con ellas se puede predecir cualquier secuencia.

Llamada Efecto sobre la ruta Punto actual después
beginPath() Descarta todo Indefinido
moveTo(x, y) Cierra la subruta en curso, abre una nueva (x, y)
lineTo(x, y) Añade segmento recto; si no había punto actual, actúa como moveTo (x, y)
closePath() Cierra la subruta con un segmento al inicio Inicio de la subruta
rect(...) Añade una subruta cerrada completa y abre otra nueva vacía Esquina inicial
arc(...) Conecta con línea recta desde el punto actual, si lo hay Fin del arco
fill() / stroke() No modifica la ruta Sin cambios

La fila de lineTo sin punto actual es una salvaguarda de la especificación que evita errores, pero apoyarse en ella hace el código ilegible.

Un ejercicio de lectura:

ctx.beginPath();
ctx.rect(20, 20, 60, 40);
ctx.lineTo(120, 100);
ctx.stroke();

¿Qué dibuja? El rect añade un rectángulo cerrado y deja la ruta con una subruta nueva y vacía posicionada en (20, 20). El lineTo añade a esa subruta vacía un segmento desde (20, 20) hasta (120, 100). El resultado es un rectángulo más una diagonal que sale de su esquina superior izquierda. Casi nadie lo predice a la primera.

Una utilidad para polígonos

Con lo anterior ya se puede escribir la función de propósito general que acaba en todos los proyectos:

/** Traza un poligono a partir de una lista de puntos. */
function poligono(ctx, puntos, cerrar = true) {
  if (puntos.length === 0) return;
  ctx.beginPath();
  ctx.moveTo(puntos[0].x, puntos[0].y);
  for (let i = 1; i < puntos.length; i++) {
    ctx.lineTo(puntos[i].x, puntos[i].y);
  }
  if (cerrar) ctx.closePath();
}

/** Poligono regular de n lados centrado en (cx, cy). */
function regular(ctx, cx, cy, radio, n, giro = -Math.PI / 2) {
  const puntos = Array.from({ length: n }, (_, i) => {
    const a = giro + (i * 2 * Math.PI) / n;
    return { x: cx + radio * Math.cos(a), y: cy + radio * Math.sin(a) };
  });
  poligono(ctx, puntos);
}

// Uso
regular(ctx, 120, 120, 80, 6);
ctx.fillStyle = '#cba6f7'; ctx.fill();
ctx.strokeStyle = '#11111b'; ctx.lineWidth = 3; ctx.stroke();

Fíjate en el giro por defecto de menos noventa grados: sin él, el primer vértice cae a la derecha y los polígonos de lados impares salen apoyados en un vértice en lugar de en un lado, que es lo que nadie espera.

⚔️ Reto práctico

Escribe una función estrella(ctx, cx, cy, radioExterior, radioInterior, puntas) que trace una estrella alternando entre los dos radios. Comprueba después qué diferencia hay entre terminarla con closePath y terminarla con un lineTo al primer punto, usando lineWidth = 10. La punta que se rompe te enseña la lección mejor que ningún texto.