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. Acumulascale(),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
- El request es de solo lectura. Nunca escribas en
LabelLocationParameters; si necesitas forzar anchor o rotación, usa los overrides del result. - Escribe siempre
positionydirection. SonVecfinales: usacopyCoordinatesFrom(...), 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. - Respeta el protocolo de
directionsi quieres queupSide,slopeDirectionTypeyrotateDirectionfuncionen como en el resto de la librería: tangente → invertir siNEGATIVE→ rotar±rotateDirectionsegún el lado → normalizar. Si delegas en unJMPath(receta A), esto es automático. - Sé
Versionabley versiona tus mutaciones. El tippable declara una dependencia sobre tu objeto; si tus setters llaman achangeVersion()(como cualquierConstructible), los labels te seguirán solos. Si tu clase implementaLabelLocatablepero noVersionable, los labels se colocarán una vez y no se enterarán de tus cambios. computeLabelPlacementdebe ser barato y sin efectos secundarios. Se llama en cada rebuild del tippable. Precalcula en turebuildShape()lo que sea costoso (así lo hacenLengthMeasure/ShapeDelimiterconsceneLabelPosition/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:
- Objeto independiente (el ejemplo anterior): se crea con
LabelTip.make(...)/TippableObject.make(...)(o el DSLlabel(...)) 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. - Objeto interno (labels inline del DSL:
shape(..., label: "x")): se registra en el padre conaddInternalObject(key, tip). El padre lo actualiza en supostUpdateActions()(viendo la versión definitiva del padre en el mismo frame), lo dibuja al final de sudraw()(víasuper.draw()→drawInternalObjects) y las animaciones que usanapplyToObjectAndInternal(fadeIn/fadeOut...) le aplican también sus efectos. No se añade a la escena.copyStateFromlos copia emparejando por clave y, si el tippable estaba anclado al propio contenedor, la copia se re-ancla a la copia consetPath(...).
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