Bonsai

Cómo funciona

Geometría precisa sobre un lienzo infinito

Un CAD no es un programa de dibujo con más botones: es una base de datos de geometría exacta con una cámara encima. Esta página explica las tres piezas que lo hacen posible: la vista, la referencia a objetos y el motor de comandos. Las demos usan el código real de la app, cada una con su propio editor (no tocan tu dibujo).

1. Píxeles o geometría

En un programa de pintura, una línea es un montón de píxeles; si haces zoom, ves escalones. En AutoCAD una línea es { a: {x, y}, b: {x, y} } en unidades reales (milímetros, metros…) y la pantalla es solo una forma de mirarla. Por eso se puede hacer zoom sin límite, medir con cuatro decimales y que dos líneas se toquen exactamente.

Todo en esta mini app es plano: no hay coordenada Z en ningún tipo. Las entidades son cuatro (línea, círculo, arco y polilínea; un rectángulo es una polilínea cerrada) y todas se descomponen en dos primitivas, segmentos y arcos. Así las intersecciones, el hit-testing y el recorte solo tienen que saber de dos formas.

2. Dos sistemas de coordenadas

El dibujo usa Y hacia arriba, como en matemáticas. La pantalla, Y hacia abajo. La vista son tres números: qué punto del dibujo está en el centro (cx, cy) y cuántos píxeles mide una unidad (scale). Mueve el ratón y la rueda:

>_
Type a command

La vista

centro (cx, cy)60.00, 30.00
escala (px/unidad)2.500
rejillacada 10 u

El cursor

pantalla (px)—
dibujo (unidades)—

ctx.setTransform(…)

a=2.500  c=0  e=250.0
b=0  d=-2.500  f=375.0
view/viewport.ts
export function worldToScreen(v: View, size: Size, p: Point): Point {
  return {
    x: size.w / 2 + (p.x - v.cx) * v.scale,
    y: size.h / 2 - (p.y - v.cy) * v.scale,   // ← el signo voltea la Y
  };
}

La misma transformación cabe en la matriz del canvas: con ctx.setTransform(a, b, c, d, e, f) se dibuja directamente en coordenadas del dibujo. La d negativa es la que voltea la Y. El precio es que el grosor de línea también se escala: por eso el renderizador usa lineWidth = 1 / scale, que es un píxel a cualquier zoom.

3. Zoom alrededor del cursor

Si el zoom solo cambiara scale, el dibujo se acercaría al centro de la pantalla y lo que mirabas se escaparía. La gracia de AutoCAD es que el punto bajo el cursor se queda bajo el cursor. Se consigue despejando el nuevo centro:

view/viewport.ts
export function zoomAt(v, size, screen, factor) {
  const anchor = screenToWorld(v, size, screen); // lo que hay bajo el cursor
  const scale = v.scale * factor;
  return {
    scale,
    // el centro que hace que «anchor» vuelva a caer en «screen»
    cx: anchor.x - (screen.x - size.w / 2) / scale,
    cy: anchor.y + (screen.y - size.h / 2) / scale,
  };
}

Hay un test que lo comprueba con números al azar. La rejilla también se adapta: parte de 10 unidades y multiplica o divide por 5 (las líneas principales) para que nunca queden a menos de 12 píxeles.

4. Pintar solo lo necesario

Hay dos canvas superpuestos, porque cambian a ritmos muy distintos:

  • Escena: rejilla y entidades. Solo se repinta si cambia el dibujo, la vista o la selección.
  • Superposición: mira, línea elástica, vista previa, marcador de OSNAP, ventana de selección. Se repinta en cada movimiento del ratón, pero dibuja muy poco.

Ninguno se pinta en el evento: se marca como «sucio» y se pinta en el siguiente requestAnimationFrame, así diez eventos del ratón entre fotograma y fotograma cuestan un solo pintado. En la escena las entidades se agrupan por color y cada grupo es un path y un stroke(). Y solo se recorren las que caen dentro de la pantalla, cosa que responde el índice espacial (sección 6).

5. Referencia a objetos

OSNAP es lo que convierte un clic aproximado en un punto exacto: el final de una línea, el centro de un círculo, el cruce de dos objetos. findSnap recibe el cursor, la apertura (10 píxeles pasados a unidades) y las entidades cercanas, y decide en dos pasos:

  1. Puntos concretos dentro de la apertura (Endpoint, Midpoint, Quadrant, Intersection). Gana el más cercano, con una pequeña ventaja para los exactos.
  2. Si no hay ninguno, referencias «de objeto»: basta con tocar la curva. El centro de un círculo puede estar lejos del cursor, y la perpendicular depende del punto base del comando. Nearest va al final.
Líneas en el dibujo:
>_
Type a command

En cada movimiento del ratón

candidatas del índice—
referencia—
con rbush—
recorriendo todo—

Modos activos

Pasa el cursor por las líneas. 60 fps son 16 ms por fotograma.

6. El índice espacial

Con 50.000 líneas no se puede preguntar a todas si están cerca del cursor en cada movimiento del ratón. Un R-tree (rbush) guarda las cajas de las entidades en un árbol de cajas: para encontrar lo que toca un rectángulo, baja solo por las ramas que lo tocan. La demo de arriba mide las dos cosas con la misma función.

Lo interesante es cómo se mantiene al día. Cada cambio del dibujo pasa por Immer, que devuelve los patches: la lista exacta de rutas que cambiaron. Si un patch toca entities.e42, solo esa entidad se reindexa, también al deshacer y rehacer:

state/document.ts
function reindex(patches: Patch[], doc: Doc) {
  const touched = new Set<string>();
  for (const p of patches)
    if (p.path[0] === "entities") touched.add(String(p.path[1]));
  for (const id of touched) index.sync(id, doc.entities[id]); // quitar + volver a meter
}

7. OSNAP, Orto y SNAP

Tres ayudas pueden querer mover el mismo punto. effectivePoint las aplica en el orden de AutoCAD, y la vista previa usa exactamente la misma función que el clic, así que lo que ves es lo que obtienes:

state/editor.ts
function effectivePoint(screen) {
  let p = toWorld(screen);
  if (pidePunto && osnap) {                 // F3: la geometría manda
    const snap = findSnap(entitiesNear(p, apertura), { cursor: p, modes, base });
    if (snap) return { p: snap.p, snap };
  }
  if (snapRejilla) p = redondear(p, 10);    // F9
  if (pidePunto && orto && base)            // F8: horizontal o vertical desde la base
    p = |dx| >= |dy| ? { x: p.x, y: base.y } : { x: base.x, y: p.y };
  return { p, snap: null };
}

Escribir un número en vez de un punto es la distancia directa: la distancia va en la dirección del cursor, ya corregida por Orto. Es la forma más rápida de dibujar en AutoCAD: activas Orto, apuntas a la derecha y escribes 100.

8. Comandos como generadores

La línea de comandos de AutoCAD es un diálogo: el comando pregunta, tú respondes con un clic, un número o una opción, y el comando sigue. Escrito con callbacks sería una máquina de estados enrevesada. Con una función generadora se lee de arriba abajo, igual que el diálogo:

commands/draw.ts (simplificado)
*run(ctx) {
  const first = yield { type: "point", prompt: "Specify first point" };
  if (first.type !== "point") return;          // Enter o Esc: se acabó
  let last = first.p;
  while (true) {
    const a = yield {
      type: "point",
      prompt: "Specify next point or", options: ["Undo"],
      base: last,                              // línea elástica, Orto, distancia directa
      preview: (p) => [{ type: "line", a: last, b: p }],
    };
    if (a.type !== "point") return;
    ctx.doc.add([{ type: "line", a: last, b: a.p }]);
    last = a.p;
  }
}

Cada yield entrega una petición al editor; el editor la muestra, convierte lo que haga el usuario en una respuesta y se la devuelve con gen.next(respuesta). Esc es gen.return(), que ejecuta los finally: por eso PLINE guarda lo dibujado aunque canceles. Todo lo que hace un comando se agrupa en un solo paso de deshacer. Prueba a escribir comandos y mira la petición en curso:

>_
Type a command

Generador en curso

ninguno (esperando un comando)

Último yield

—

Pasos en el historial de deshacer: 0

9. Estado fuera de React

En Hojas cada celda era un átomo de Jotai y un componente. Aquí sería un error: las entidades no son componentes, son trazos de un canvas. Por eso el estado vive en dos stores de Zustand vanilla (sin React):

  • Documento: entidades y capas. Se guarda y se deshace.
  • Sesión: vista, cursor, selección, comando en curso. Ni se guarda ni se deshace.

El lienzo se suscribe con store.subscribe y marca qué capa repintar; los componentes de React (paletas, barra de estado) usan useStore(store, selector) y solo se repintan si cambia su trozo. Mover el ratón cambia el cursor 60 veces por segundo y no repinta nada de React salvo el texto de coordenadas.

state/document.ts
function apply(recipe, label) {
  const [next, patches, inverse] = produceWithPatches(get().doc, recipe);
  reindex(patches, next);
  history.push({ label, patches, inverse });   // deshacer = applyPatches(doc, inverse)
  store.setState({ doc: next });
}

10. Intersecciones y TRIM

Las intersecciones se reducen a tres casos: segmento-segmento (un sistema lineal), segmento-arco (una ecuación de segundo grado) y arco-arco (dos circunferencias). En los arcos, además, hay que comprobar que el ángulo del punto cae dentro del barrido.

TRIM en modo rápido usa todo lo visible como arista de corte. Para una línea: se calculan los parámetros t de los cortes (0 al inicio, 1 al final), se busca el par que rodea al clic y se quita ese tramo. En un círculo los parámetros son ángulos y lo que queda es un arco; en una polilínea, «nº de segmento + t». Si no hay ningún corte, el objeto se borra entero, como en AutoCAD 2021 y posteriores.

11. Mapa de archivos

src/features/autocad/
geometry/   lógica pura, sin React ni DOM (con tests)
  vec.ts          vectores 2D
  entities.ts     tipos, primitivas, cajas, transformaciones, pinzamientos
  intersect.ts    intersecciones, punto más cercano, ventana/captura
  snap.ts         OSNAP
  modify.ts       OFFSET y TRIM
  input.ts        10,20 · @10,0 · @10<45 · distancia directa
view/
  viewport.ts     mundo ↔ pantalla, zoom, rejilla
  renderer.ts     Canvas 2D: escena y superposición
commands/         un generador por comando + registro de alias
state/
  document.ts     Zustand + Immer: entidades, capas, historial, índice
  editor.ts       sesión, ejecución de comandos, selección, portapapeles
  spatialIndex.ts rbush
  persistence.ts  autoguardado en localStorage
export/           SVG y DXF R12
components/       la interfaz de React
learn/            estas demos