Arcos: arc, arcTo y el modelo de las tangentes
Dominar los dos métodos de arco del canvas, sus modelos completamente distintos, y usarlos para círculos, sectores y esquinas redondeadas correctas.
El canvas tiene dos formas de trazar un arco y no se parecen en nada. arc describe el arco por su centro, su radio y sus ángulos, que es como lo piensa un matemático. arcTo lo describe por dos rectas y un radio, y coloca el arco tangente a ambas, que es como lo piensa quien está redondeando una esquina. Confundirlas produce resultados desconcertantes; entender cuándo toca cada una resuelve la mayoría de las formas curvas.
- Usar
arccon soltura, incluidos los ángulos, el sentido y la conexión con el punto actual. - Explicar el modelo de tangentes de
arcToy predecir dónde acaba el arco. - Construir círculos, sectores, anillos y esquinas redondeadas correctas.
- Identificar los casos degenerados de ambos métodos y qué hacen entonces.
arc: centro, radio y ángulos
ctx.arc(cx, cy, radio, anguloInicio, anguloFin, antihorario = false);
Los ángulos van en radianes, no en grados, y el cero apunta hacia la derecha, es decir, en la dirección del eje X positivo. Como el eje Y crece hacia abajo, los ángulos crecientes recorren el círculo en el sentido de las agujas del reloj visualmente.
// Circulo completo
ctx.beginPath();
ctx.arc(100, 100, 60, 0, Math.PI * 2);
ctx.fillStyle = '#f9e2af';
ctx.fill();
// Sector de 0 a 90 grados: cuarto INFERIOR derecho
ctx.beginPath();
ctx.moveTo(250, 100); // al centro, para cerrar el sector
ctx.arc(250, 100, 60, 0, Math.PI / 2);
ctx.closePath();
ctx.fillStyle = '#f38ba8';
ctx.fill();
El detalle que hay que tener siempre presente: arc conecta con una línea recta desde el punto actual hasta el inicio del arco si ya había un punto actual. Es un comportamiento útil —permite el sector del ejemplo, donde el moveTo al centro genera el radio— y es también la fuente del bug más común con arcos.
// Bug clasico: dos circulos unidos por una raya
ctx.beginPath();
ctx.arc(60, 60, 30, 0, Math.PI * 2);
ctx.arc(160, 60, 30, 0, Math.PI * 2); // conecta desde el final del primero
ctx.stroke();
// Correcto: un moveTo antes de cada arco, o rutas separadas
ctx.beginPath();
ctx.moveTo(90, 60); ctx.arc(60, 60, 30, 0, Math.PI * 2);
ctx.moveTo(190, 60); ctx.arc(160, 60, 30, 0, Math.PI * 2);
ctx.stroke();
El moveTo va al punto de inicio del arco, que con ángulo inicial cero es (cx + radio, cy). Escribirlo a mano es tedioso y por eso conviene una utilidad:
function circulo(ctx, cx, cy, r) {
ctx.moveTo(cx + r, cy);
ctx.arc(cx, cy, r, 0, Math.PI * 2);
}
El sexto parámetro invierte el sentido del recorrido. Visualmente puede dar el mismo arco, pero el sentido importa para dos cosas: la regla de relleno nonzero, que decide qué es interior según la orientación de los contornos, y la dirección en la que se dibujan los guiones de una línea discontinua.
// Anillo: exterior en un sentido, interior en el contrario
ctx.beginPath();
ctx.arc(120, 120, 80, 0, Math.PI * 2, false);
ctx.arc(120, 120, 45, 0, Math.PI * 2, true); // sentido inverso: agujero
ctx.fillStyle = '#94e2d5';
ctx.fill();
Ese anillo funciona con la regla de relleno por defecto precisamente por la inversión del sentido. Con los dos arcos en el mismo sentido, el círculo interior no sería un agujero y el resultado sería un disco lleno.
arcTo: dos rectas y un radio
ctx.arcTo(x1, y1, x2, y2, radio);
Este método no recibe un centro ni ángulos. Recibe dos puntos y un radio, y hace lo siguiente: considera la recta que va del punto actual a (x1, y1) y la que va de (x1, y1) a (x2, y2), y traza el arco de radio dado que es tangente a ambas. Si hace falta, añade primero un segmento recto desde el punto actual hasta donde empieza el arco.
El modelo mental correcto es el de doblar una esquina: (x1, y1) es el vértice que quieres redondear, (x2, y2) indica hacia dónde sigue el camino, y el radio dice cuánto redondear.
Dos consecuencias que hay que fijar:
El arco no llega a (x1, y1). Ese punto es el vértice teórico, y el arco lo esquiva. El punto actual después de la llamada queda en la tangente con el segundo segmento, que está entre (x1, y1) y (x2, y2).
El arco tampoco llega a (x2, y2). Ese punto solo define la dirección. Si quieres llegar hasta él, necesitas un lineTo después.
// Rectangulo con esquinas redondeadas usando arcTo
function rectRedondeado(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r); // esquina superior derecha
ctx.arcTo(x + w, y + h, x, y + h, r); // inferior derecha
ctx.arcTo(x, y + h, x, y, r); // inferior izquierda
ctx.arcTo(x, y, x + w, y, r); // superior izquierda
ctx.closePath();
}
rectRedondeado(ctx, 20, 20, 220, 120, 24);
ctx.fillStyle = '#a6e3a1';
ctx.fill();
Fíjate en el moveTo(x + r, y) inicial: empieza justo donde acabará el último arco, de modo que closePath no añada ningún segmento visible. Este patrón fue durante años la única forma de hacer un rectángulo redondeado, y todavía es el que hay que entender aunque ahora exista roundRect.
Los casos degenerados
Ambos métodos tienen situaciones límite con comportamiento definido, y conocerlas evita sorpresas.
arc con radio cero no lanza error: produce una subruta con un solo punto, que no se ve. Con radio negativo sí lanza un IndexSizeError. Si tus radios vienen de un cálculo, protégelos con Math.max(0, r).
arc con un rango de ángulos mayor que dos pi dibuja el círculo completo y se detiene; no da vueltas de más.
arcTo con radio cero degenera en un lineTo(x1, y1): no hay arco, hay esquina viva.
arcTo con los tres puntos colineales también degenera en una línea recta hasta (x1, y1), porque no hay esquina que redondear.
arcTo con un radio mayor del que cabe no lanza error ni recorta: coloca el arco de ese radio tangente a las dos rectas, lo que puede llevarlo muy lejos del vértice y producir una forma inesperada. Es la causa habitual de “mi rectángulo redondeado se convirtió en algo raro” cuando el radio supera la mitad del lado más corto. La defensa es limitarlo:
const r = Math.min(radio, w / 2, h / 2);
Hay un fallo con arcTo que consume tardes enteras y cuyo diagnóstico no es evidente: el primer arco de un rectángulo redondeado sale mal, o falta un trozo del borde superior. La causa es que arcTo usa el punto actual como primer punto de la primera recta, así que el resultado depende de dónde estabas antes de llamarlo. Si arrancas con moveTo(x, y) —la esquina, no el punto desplazado por el radio— la primera recta va de la esquina al mismo vértice que quieres redondear, es decir, tiene longitud casi nula, y la geometría de la tangente se vuelve degenerada. El navegador hace algo razonable pero no lo que querías. Por eso el patrón correcto empieza siempre en (x + r, y), ya dentro del borde superior. Hay un segundo efecto de lo mismo que aparece al construir formas complejas: si encadenas varios arcTo y en medio haces un lineTo, la tangente del siguiente arco cambia. Y un tercero, muy práctico: arcTo no sirve para redondear la esquina entre dos curvas, solo entre dos rectas, porque la tangente se calcula con segmentos rectos. Para redondear la unión de dos curvas hay que calcular los puntos de tangencia a mano o recurrir a Bézier. Cuando veas un radio que no responde como esperas, dibuja temporalmente las dos rectas implicadas con un trazo fino: casi siempre descubrirás que una de ellas no es la que creías.
Elegir entre los dos
La regla es directa y no tiene excepciones prácticas.
Usa arc cuando conozcas el centro: círculos, sectores, anillos, indicadores de progreso circulares, marcadores, cualquier cosa radial.
Usa arcTo cuando estés recorriendo un contorno y quieras redondear los vértices: cajas, bocadillos de diálogo, contornos poligonales suavizados, rutas con esquinas.
Y para el caso concreto del rectángulo redondeado existe hoy roundRect, que hace lo mismo con menos código y admite radios distintos por esquina. Sigue mereciendo la pena entender arcTo porque es la herramienta general: en cuanto la forma deja de ser un rectángulo, roundRect no sirve y arcTo sí.
// Bocadillo con pico y esquinas redondeadas
function bocadillo(ctx, x, y, w, h, r, pico = 14) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.lineTo(x + 42, y + h);
ctx.lineTo(x + 28, y + h + pico); // el pico
ctx.lineTo(x + 26, y + h);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}