El sistema de labels y tippables de JMathAnim

Este documento describe la arquitectura de los objetos tippables (labels, marcas, puntas de flecha) que se anclan a un path, y termina con un tutorial para hacer labeleable un objeto propio. Es documentación de desarrollo, no del manual de usuario.

Vista general

Un tippable es un objeto rígido (un LaTeX, una marca de paralelas, una punta de flecha...) que se coloca automáticamente sobre otro objeto —el path— y lo sigue cuando este cambia. La colocación se recalcula en cada rebuild a partir de dos piezas:

  • LabelLocationParameters (request): la configuración del usuario. Dónde (t), en qué lado (upSide), a qué distancia (gap), con qué anclaje (refAnchor) y con qué política de rotación (rotationType). Es solo entrada: ningún path la modifica.
  • LabelPlacementResult (result): la salida del path. Un punto en coordenadas de escena (position), una dirección hacia donde debe desplazarse el label (direction), un factor de escala (amplitudeScale) y dos overrides opcionales.

El contrato entre ambos es la interfaz LabelLocatable:

public interface LabelLocatable {
    void computeLabelPlacement(LabelLocationParameters request,
                               LabelPlacementResult result);
}

Cualquier objeto que implemente esta interfaz puede recibir labels (LabelTip) y marcas (TippableObject). La implementan, entre otros: JMPath, AbstractShape, AbstractPoint, AbstractConnector (y por tanto Arrow), LengthMeasure y ShapeDelimiter.

La jerarquía de clases

Constructible
└── AbstractRigidBox            referencia + transform intrínseco + transform de colocación
    └── PivotRigidBox           calcula la colocación con el sistema pivote/anchor
        └── AbstractTippableObject   conecta un LabelLocatable con el pivote
            ├── LabelTip             label LaTeX (con argumentos {#0}, {#1}...)
            └── TippableObject       marcas genéricas (paralelas, arrowheads...)

AbstractRigidBox: el modelo de tres capas

Un rigid box nunca dibuja el objeto de referencia directamente; dibuja una copia derivada:

drawable = referencia → intrinsicTransform → placementTransform
  • mathObjectReference: el objeto canónico, sin colocar. No se toca.
  • intrinsicTransform: propiedad del USUARIO. Acumula scale(), rotate(), etc. aplicados al tippable. Sobrevive a los rebuilds.
  • placementTransform: propiedad del SISTEMA. Se reconstruye desde cero en cada rebuild; el usuario nunca escribe en él.

Esta separación es la que permite que un label escalado por el usuario siga correctamente al path: el cálculo de anchors se hace siempre sobre el bounding box intrínseco (getIntrinsicBoundingBox()), es decir, la referencia con el transform de usuario aplicado pero sin colocar.

PivotRigidBox: del placement al transform

Recibe el LabelPlacementResult y construye el placementTransform en tres pasos: escala opcional (amplitudeScale) alrededor del punto de anclaje, rotación alrededor del anclaje, y traslación del anclaje al destino. El destino es:

// LabelLocationParameters.computeLabelDestiny
destino = placement.position
        + placement.direction.normalize() * gap * (isGapRelative ? altoDelLabel : 1)
                                          * placement.amplitudeScale

La rotación depende de rotationType:

Modo Comportamiento
FIXED El label no rota nunca (labels de puntos, ejes...).
ROTATE El label rota siguiendo direction. Correcto para marcas direccionales (arrowheads).
SMART Como ROTATE, pero da la vuelta (180°) al label si quedaría boca abajo, invirtiendo también el anchor. Pensado para texto legible.

Los overrides del result (anchorOverride, rotationTypeOverride) ganan a la configuración del usuario. Así, un LengthMeasure centra siempre su label y un AbstractPoint fuerza FIXED, sin corromper los parámetros del usuario.

AbstractTippableObject: el ciclo de rebuild

@Override
public void rebuildShape() {
    if (isFreeMathObject()) return;
    placement.reset();                              // limpia amplitudeScale y overrides
    path.computeLabelPlacement(parameters, placement); // el path rellena el result
    super.rebuildShape();                           // PivotRigidBox construye el transform
}

En el constructor se declara la dependencia sobre el path (si es Versionable), de modo que el sistema de versiones (ver update-flow.md) marca el tippable como sucio cuando el path cambia. setPath(...) permite re-anclar el tippable a otro objeto actualizando el grafo de dependencias.

Los parámetros, uno a uno

Campo Significado
t Posición en el path, de 0 a 1.
isParametrized Si true, t se interpreta por longitud de arco; si false, por índice de punto del path.
upSide true: lado superior (90° CCW desde la tangente); false: el opuesto; null: auto (en paths cerrados, hacia fuera). Única fuente de verdad del lado.
gap Separación entre el path y el label.
isGapRelative Si true, el gap se multiplica por la altura del label (gaps proporcionales al tamaño del texto).
refAnchor Qué punto del label aterriza en el destino (LOWER, CENTER...).
rotationType FIXED / ROTATE / SMART (ver tabla anterior).
slopeDirectionType Sentido de la tangente (POSITIVE = sentido de recorrido del path).
rotateDirection Ángulo que se suma a la tangente para obtener direction. LabelTip usa 90° (perpendicular); las marcas usan 0 (tangente).

Caso particular: los conectores

AbstractConnector no expone su shape directamente como path del label: mantiene dos arcos auxiliares de dos puntos, labelArcUpside y labelArcDownside (los lados izquierdo y derecho del segmento A→B), reconstruidos en cada rebuild junto al cuerpo de la flecha. Su computeLabelPlacement elige el arco según request.upSide y delega en él, inyectando después el amplitudeScale (que combina la escala de animación ShowCreation con un factor de proximidad: el label encoge cuando A y B se acercan demasiado).

Los labels dinámicos (addLengthLabelTip, addVecLabelTip) añaden además un Updater que reescribe los argumentos LaTeX ({#0}, {#1}) cuando los extremos se mueven.

Tutorial: hacer labeleable un objeto propio

Hay dos recetas, según de dónde salga la geometría.

Receta A: delegar en un Shape interno (la fácil)

Si tu objeto ya contiene un Shape o JMPath que representa la curva sobre la que quieres colocar labels, basta con delegar. Es lo que hacen AbstractShape y AbstractConnector:

public class Orbita extends Constructible<Orbita> implements LabelLocatable {

    private final Shape ellipse; // reconstruido en rebuildShape()

    @Override
    public void computeLabelPlacement(LabelLocationParameters request,
                                      LabelPlacementResult result) {
        ellipse.computeLabelPlacement(request, result);
        // Opcional: post-procesar el result (escala, overrides...)
    }
}

Con esto heredas gratis toda la lógica de JMPath: parametrización por longitud de arco, tangentes, auto-detección de lado en paths cerrados, y el manejo de rotateDirection/slopeDirectionType.

Receta B: cálculo manual (control total)

Si la posición del label no sale de un path (o quieres forzar un comportamiento), rellena el result a mano. Es lo que hacen AbstractPoint y LengthMeasure. Ejemplo: un objeto que coloca labels sobre una circunferencia definida por centro y radio, sin materializar ningún Shape:

public class Reloj extends Constructible<Reloj> implements LabelLocatable {

    private final Vec center;
    private double radius;

    @Override
    public void computeLabelPlacement(LabelLocationParameters request,
                                      LabelPlacementResult result) {
        // 1. Posición: el punto de la circunferencia en el ángulo t*2π
        double angle = request.t * 2 * PI;
        result.position.copyCoordinatesFrom(
                center.add(radius * Math.cos(angle), radius * Math.sin(angle)));

        // 2. Dirección: hacia donde debe desplazarse el label.
        //    Partimos de la tangente y aplicamos el protocolo estándar:
        Vec tangent = Vec.to(-Math.sin(angle), Math.cos(angle));
        if (request.slopeDirectionType == SlopeDirectionType.NEGATIVE) {
            tangent.scale(-1);
        }
        boolean up = !Boolean.FALSE.equals(request.upSide);
        tangent.rotate(up ? request.rotateDirection : -request.rotateDirection);
        result.direction.copyCoordinatesFrom(tangent.normalize());

        // 3. Overrides opcionales: fuerza comportamiento si tu objeto lo exige
        // result.anchorOverride = AnchorType.CENTER;
        // result.rotationTypeOverride = RotationType.FIXED;
    }
}

Las reglas del contrato

  1. El request es de solo lectura. Nunca escribas en LabelLocationParameters; si necesitas forzar anchor o rotación, usa los overrides del result.
  2. Escribe siempre position y direction. Son Vec finales: usa copyCoordinatesFrom(...), no los reemplaces. El resto de campos del result (amplitudeScale, overrides) llegan ya reseteados a sus valores por defecto, así que solo tócalos si los necesitas.
  3. Respeta el protocolo de direction si quieres que upSide, slopeDirectionType y rotateDirection funcionen como en el resto de la librería: tangente → invertir si NEGATIVE → rotar ±rotateDirection según el lado → normalizar. Si delegas en un JMPath (receta A), esto es automático.
  4. Versionable y versiona tus mutaciones. El tippable declara una dependencia sobre tu objeto; si tus setters llaman a changeVersion() (como cualquier Constructible), los labels te seguirán solos. Si tu clase implementa LabelLocatable pero no Versionable, los labels se colocarán una vez y no se enterarán de tus cambios.
  5. computeLabelPlacement debe ser barato y sin efectos secundarios. Se llama en cada rebuild del tippable. Precalcula en tu rebuildShape() lo que sea costoso (así lo hacen LengthMeasure/ShapeDelimiter con sceneLabelPosition/sceneLabelDir) y limítate a copiarlo aquí.

Usarlo

Reloj reloj = Reloj.make(Vec.to(0, 0), 2);

// Un label LaTeX en la posición de las 12 (t=0.25), por fuera
LabelTip doce = LabelTip.make(reloj, .25, "$XII$", true);

// Una marca genérica (sin texto) en las 3
TippableObject marca = TippableObject.make(reloj, 0,
        SlopeDirectionType.POSITIVE, Shape.segment(Vec.to(0, 0), Vec.to(0, .1)));

scene.add(reloj, doce, marca);

A partir de aquí, todos los setters fluidos del tippable aplican: setGap, setGapRelative, setAnchor, setUpSide, setRotationType, setValue (mueve t, útil para animar el label recorriendo el path)...

Las dos formas de vida de un label

Un tippable puede vivir de dos maneras:

  1. Objeto independiente (el ejemplo anterior): se crea con LabelTip.make(...) / TippableObject.make(...) (o el DSL label(...)) y se añade/quita de la escena por su cuenta. Sigue al path vía dependencia, pero su ciclo de vida (escena, animaciones) es del usuario.
  2. Objeto interno (labels inline del DSL: shape(..., label: "x")): se registra en el padre con addInternalObject(key, tip). El padre lo actualiza en su postUpdateActions() (viendo la versión definitiva del padre en el mismo frame), lo dibuja al final de su draw() (vía super.draw()drawInternalObjects) y las animaciones que usan applyToObjectAndInternal (fadeIn/fadeOut...) le aplican también sus efectos. No se añade a la escena. copyStateFrom los copia emparejando por clave y, si el tippable estaba anclado al propio contenedor, la copia se re-ancla a la copia con setPath(...).

Resumen del flujo completo

usuario mueve el path
        │  changeVersion()
        ▼
tippable.needsUpdate() == true          (versión efectiva, ver update-flow.md)
        │
        ▼
AbstractTippableObject.rebuildShape()
        │  placement.reset()
        │  path.computeLabelPlacement(parameters, placement)
        ▼
PivotRigidBox.rebuildShape()
        │  destino = computeLabelDestiny(bbIntrínseco, placement)
        │  rotAngle según rotationType (con overrides)
        │  placementTransform = escala ∘ rotación ∘ traslación
        ▼
AbstractRigidBox.rebuildShape()
           drawable = referencia → intrinsicTransform → placementTransform