Saltar al contenido

Path2D: la ruta como objeto reutilizable

Sacar la geometría del estado del contexto y guardarla en objetos que se construyen una vez, se combinan entre sí y se dibujan miles de veces.

Construir y dibujar

JS
const ruta = new Path2D();ruta.moveTo(0, -30);ruta.lineTo(26, 15);ruta.lineTo(-26, 15);ruta.closePath();

Tiene los mismos métodos de construcción que el contexto: moveTo, lineTo, arc, arcTo, bezierCurveTo, quadraticCurveTo, rect, roundRect, ellipse, closePath. Lo que no tiene es nada de estilo ni de dibujo: no hay fillStyle, no hay stroke().

Para pintarla se le pasa al contexto:

JS
ctx.fillStyle = '#a6e3a1';ctx.fill(ruta);ctx.stroke(ruta);ctx.fill(ruta, 'evenodd');ctx.clip(ruta);ctx.isPointInPath(ruta, x, y);ctx.isPointInStroke(ruta, x, y);

Fíjate en un detalle crucial: usar un Path2D no toca la ruta actual del contexto. Puedes tener una ruta en construcción y dibujar diez Path2D en medio sin que interfieran. Eso elimina toda una familia de errores por acumulación.

Las coordenadas son locales, la posición la pone la matriz

Un Path2D guarda coordenadas fijas. Para dibujar el mismo objeto en veinte sitios, no se modifica la ruta: se mueve el sistema de coordenadas.

JS
const triangulo = new Path2D();triangulo.moveTo(0, -30); triangulo.lineTo(26, 15);triangulo.lineTo(-26, 15); triangulo.closePath();for (const t of elementos) {  ctx.save();  ctx.translate(t.x, t.y);  ctx.rotate(t.angulo);  ctx.fillStyle = t.color;  ctx.fill(triangulo);        // la MISMA geometria, sin reconstruir  ctx.restore();}

Ese patrón encaja perfectamente con la disciplina de espacios locales: la ruta describe la forma en su origen, y la matriz la coloca.

En bucles muy densos, el par save/restore empieza a pesar, y la alternativa es setTransform con una matriz calculada:

JS
const dpr = window.devicePixelRatio || 1;for (const t of elementos) {  ctx.setTransform(dpr * Math.cos(t.angulo), dpr * Math.sin(t.angulo),                   -dpr * Math.sin(t.angulo), dpr * Math.cos(t.angulo),                   t.x * dpr, t.y * dpr);  ctx.fillStyle = t.color;  ctx.fill(triangulo);}ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

Combinar rutas

addPath añade el contenido de otra ruta, opcionalmente transformado por una matriz:

JS
const rueda = new Path2D();rueda.arc(0, 0, 18, 0, Math.PI * 2);const chasis = new Path2D();chasis.roundRect(-60, -22, 120, 34, 8);const coche = new Path2D();coche.addPath(chasis);coche.addPath(rueda, new DOMMatrix().translate(-34, 14));coche.addPath(rueda, new DOMMatrix().translate(34, 14));ctx.fillStyle = '#89b4fa';ctx.fill(coche);

Ese ensamblaje ocurre una vez, al inicializar. A partir de ahí, dibujar un coche completo es un solo fill con una sola ruta, en lugar de tres construcciones y tres rellenos.

El constructor de copia también existe:

JS
const copia = new Path2D(otraRuta);   // clona

Modificar la copia no afecta al original, lo que permite tener una biblioteca de formas base y derivar variantes.

Lo que no existe es forma de leer, modificar o borrar el contenido de un Path2D. Solo se puede añadir. Si necesitas geometría editable, la fuente de verdad tiene que ser tu propio modelo de datos y el Path2D se reconstruye cuando cambia.

Hit testing sin contexto activo

isPointInPath con un Path2D es la forma correcta de hacer hit testing en canvas, y sustituye tanto al cálculo geométrico a mano como a la técnica del canvas oculto en la mayoría de los casos.

JS
function figuraEn(ctx, escena, px, py) {  // De atras hacia delante: lo ultimo pintado esta encima  for (let i = escena.length - 1; i >= 0; i--) {    const f = escena[i];    ctx.save();    ctx.setTransform(1, 0, 0, 1, 0, 0);   // consultar en coordenadas del buffer    ctx.translate(f.x, f.y);    ctx.rotate(f.angulo);    const dentro = ctx.isPointInPath(f.ruta, px, py);    ctx.restore();    if (dentro) return f;  }  return null;}

El detalle que hace correcta esa función es que isPointInPath interpreta el punto en el espacio de usuario vigente, así que hay que establecer exactamente la misma matriz con la que se dibujó. Consultar con una matriz distinta da resultados incorrectos sin ningún aviso.

Para el trazo existe el equivalente, que tiene en cuenta el lineWidth actual:

JS
ctx.lineWidth = 12;ctx.isPointInStroke(ruta, px, py);   // dentro de la banda del trazo

Es lo que permite seleccionar una línea fina pinchando cerca de ella: subes temporalmente el grosor a un valor de tolerancia y consultas.

Una biblioteca de formas

El patrón que emerge en un proyecto serio es un módulo de formas construidas una vez al arrancar:

JS
export const FORMAS = (() => {  const marcador = new Path2D();  marcador.moveTo(0, 0);  marcador.bezierCurveTo(-12, -14, -14, -24, -8, -30);  marcador.arc(0, -32, 8, Math.PI, 0);  marcador.bezierCurveTo(14, -24, 12, -14, 0, 0);  marcador.closePath();  const estrella = new Path2D();  for (let i = 0; i < 10; i++) {    const r = i % 2 ? 7 : 16;    const a = -Math.PI / 2 + (i * Math.PI) / 5;    const x = r * Math.cos(a), y = r * Math.sin(a);    i === 0 ? estrella.moveTo(x, y) : estrella.lineTo(x, y);  }  estrella.closePath();  const cuadrado = new Path2D();  cuadrado.roundRect(-12, -12, 24, 24, 5);  return { marcador, estrella, cuadrado };})();

A partir de ahí, dibujar cincuenta mil marcadores en un mapa son cincuenta mil transformaciones y cincuenta mil rellenos, sin ninguna construcción de geometría. Es la diferencia entre un mapa que responde al zoom y uno que no.