Objetos compuestos escribiendo una subclase (Java)
Guía paso a paso para construir un objeto compuesto propio como clase Java, usando JMathAnim como librería. El ejemplo que se desarrolla de principio a fin es un brazo articulado de dos piezas (brazo y antebrazo) con dos articulaciones.
La clase terminada está en
jmathanim-core/src/test/java/com/jmathanim/demos/ArticulatedArm.java, y el listado completo está también en la sección 9 de este documento.Para hacer lo mismo desde un script Groovy, con el bloque
compound(...)del DSL, verdocs/manual/02e_CompoundObjects/CompoundObjects.md; para las reglas de definir clases dentro de un script,CLASES_GROOVY_PERSONALIZADAS.md.Todas las referencias apuntan a
jmathanim-core.
1. La decisión de diseño, antes de escribir código
Un brazo articulado tiene dos piezas y dos ángulos. La tentación es escribir una
clase con un método bendElbow(angle) que rote el antebrazo y otro
turnShoulder(angle) que rote las dos piezas. Funciona en la primera animación y
se rompe en la segunda: los ángulos quedan implícitos en la posición de las
piezas, no hay nada que interpolar, una animación interrumpida deja el brazo en
una postura que nadie describe y una copia del objeto no sabe cómo está doblada.
El diseño correcto es el inverso:
El objeto no tiene métodos que muevan las piezas. Tiene dos valores con nombre (
shoulderyelbow) y una función que dice dónde están las piezas para un par de valores dados.
Con eso, la geometría es derivada y todo lo demás sale gratis: la postura se
puede animar, interrumpir, invertir, componer y copiar. La clase base que
implementa esa idea es StatefulCompound
(mathobjects/compound/StatefulCompound.java).
2. De qué heredar
| Base | Qué es | Qué hay que implementar |
|---|---|---|
MathObjectGroup |
Grupo lógico: al añadirlo a la escena, entran sus miembros uno a uno | Nada, y makeNewInstance() si se quiere el tipo propio |
CompoundMathObject<T> |
Objeto de escena de verdad, con piezas internas y sin estado | Nada, ya es concreta |
AbstractCompoundMathObject<S, T> |
La base de los dos anteriores | copy() |
StatefulCompound<S, T> |
Compuesto cuya geometría es función de unos valores con nombre | createInstance() y rebuild() |
Para el brazo la respuesta es StatefulCompound, porque hay estado (dos
ángulos):
public class ArticulatedArm extends StatefulCompound<ArticulatedArm, Shape> {
Los dos parámetros genéricos importan:
- El primero es autotipado (CRTP): es la propia clase, y es lo que hace que
arm.shift(...),arm.scale(...)oarm.state("elbow", 1)devuelvan unArticulatedArmy no una base, de modo que se pueden encadenar sin castear. Poner ahí otra clase compila y produceClassCastExceptionen cuanto algo devuelve(S) this. - El segundo es el tipo de las piezas. Aquí las dos son
Shape, así queget("forearm")devuelve unShapeya tipado y se le pueden llamar métodos deShapesin cast. Si las piezas fuesen de tipos distintos (unShapey unLatexMathObject), habría que ponerMathObject<?>, que es lo que usa el bloque del DSL.
Un compuesto es el objeto de escena y sus piezas son internas suyas
(AbstractCompoundMathObject.ownsItsPieces, :170): se hace add(arm) y nunca
add(arm.get("forearm")). La escena se niega a quedarse una pieza por separado,
porque es el compuesto quien la dibuja.
3. Paso 1: el esqueleto
package com.jmathanim;
import com.jmathanim.mathobjects.compound.StatefulCompound;
import com.jmathanim.mathobjects.shapes.Shape;
public class ArticulatedArm extends StatefulCompound<ArticulatedArm, Shape> {
protected ArticulatedArm() {
// Aquí va el estado (paso 2)
}
@Override
protected ArticulatedArm createInstance() {
return new ArticulatedArm();
}
@Override
protected void rebuild() {
// Aquí va la geometría (paso 4)
}
}
Eso es todo lo obligatorio. Tres notas sobre este esqueleto:
- El constructor es
protectedy así se queda. El de la jerarquía también lo es. Una subclase puede invocarlo, así quenew ArticulatedArm()funciona desde dentro de la clase (el factory del paso 3), y el resto del código está obligado a pasar por ese factory, que es el que garantiza que el objeto tiene piezas. createInstance()no es un capricho.StatefulCompound.copy()(:638) crea una instancia vacía con ese método y la rellena concopyBaseTo(estilo, piezas, nombres, huecos, cámara, etiquetas, metadatos) más el estado. Si devuelve la clase equivocada, cada copia degenera; si devuelvenull, el objeto directamente no se puede copiar y queda un aviso en el log.rebuild()es el único sitio donde el estado se convierte en geometría. No es unupdate()general: se llama en la primera actualización y luego sólo en los frames en los que algún valor ha cambiado de verdad (StatefulCompound.performMathObjectUpdateActions,:576).
4. Paso 2: el estado, declarado en el constructor
protected ArticulatedArm() {
declareState("shoulder", 0); // ángulo del brazo entero, desde la posición estirada
declareState("elbow", 0); // ángulo del antebrazo respecto del brazo
setAbsoluteRebuild(true); // rebuild() coloca desde el reposo (ver paso 4)
}
En el constructor, no en el factory. copy() crea la instancia con
createInstance(), que llama al constructor y a nada más: un estado declarado en
el método make(...) no existiría en las copias, y las copias son lo que animan
Transform, growIn y compañía.
Cada valor declarado es un Scalar (utils/Scalar.java) registrado como
dependencia del compuesto (declareState, :153). Eso es lo que hace que todo
funcione:
- escribir en él marca el brazo como pendiente de actualizar,
- el grafo de dependencias actualiza los dos en el orden correcto,
- y cualquier animación que sepa mover un valor numérico (
ScalarAnimation) mueve la geometría sin saber nada del brazo.
Un double como campo sería invisible para las tres cosas.
Hay dos variantes de declareState que conviene conocer aunque el brazo sólo use
la primera:
| Llamada | Para qué |
|---|---|
declareState("shoulder", scalar) (:172) |
El valor lo lleva un Scalar de fuera: un slider, una animación, otro compuesto. Ver sección 12 |
declareState("mode", List.of("off", "on")) (:195) |
Valor discreto con nombres. Se lee con stateName("mode") y se escribe con state("mode", "on"), y las animaciones se niegan a interpolarlo |
Los ángulos van en radianes
Convención de todo el proyecto. La constante está en la escena, así que lo cómodo en una clase que no es una escena es importarla:
import static com.jmathanim.core.JMathAnimScene.DEGREES;
5. Paso 3: las piezas, en un factory estático
public static ArticulatedArm make(double upperArmLength, double forearmLength, double width) {
ArticulatedArm arm = new ArticulatedArm();
double h = .5 * width;
double elbowX = upperArmLength;
double handX = upperArmLength + forearmLength;
// Reposo: brazo estirado sobre el eje X, con el hombro en el origen
arm.addWithKey("upperArm", Shape.rectangle(Rect.make(0, -h, elbowX, h)));
arm.addWithKey("forearm", Shape.rectangle(Rect.make(elbowX, -h, handX, h)));
arm.upperArmLength = upperArmLength;
arm.forearmLength = forearmLength;
// Estilo común, a todo el compuesto
arm.drawColor("#3d3d3d").fillColor("#c8b998").fillAlpha(1).thickness(3);
// Retoque por pieza, después, para que no lo pise el estilo común
arm.get("forearm").fillColor("#8a7a5c");
return arm;
}
/** Sobrecarga de conveniencia: Java no tiene parámetros por omisión. */
public static ArticulatedArm make(double upperArmLength, double forearmLength) {
return make(upperArmLength, forearmLength, .5);
}
Cuatro cosas que decide este método:
Las piezas se registran con nombre, no se guardan en campos.
addWithKey(clave, pieza) (AbstractCompoundMathObject.java:240) las añade y las
mete en un diccionario, y se leen con get("upperArm") (:333). Guardarlas
además en campos es el error clásico: copy() copia el diccionario remapeado a
las piezas copiadas, pero un campo propio se queda apuntando a la pieza del
original (o a null), y el fallo aparece a mitad de animación, cuando la que se
está dibujando es la copia.
Las piezas se construyen en la posición del estado inicial. Con los dos
ángulos a 0 el brazo está estirado, así que estirado es como hay que dibujarlo.
Esa posición es la referencia desde la que se miden los ángulos, y es literalmente
la que rebuild() restaura antes de cada pasada.
El orden de escritura es el orden de dibujado. upperArm primero, forearm
encima. Se puede cambiar por pieza con layer(...), y entonces el compuesto las
ordena él mismo (getDrawingOrder, :514), porque sus piezas no son objetos de
escena y el bucle de la escena no las ve.
El estilo se aplica al compuesto y baja a las piezas. getMp() de un
compuesto es un DrawStylePropertiesObjectsArray que contiene a las piezas, así
que arm.fillColor(...) escribe en todas. Los retoques por pieza van después,
o el estilo común los borra.
Nótese que el factory escribe arm.upperArmLength y llama a arm.addWithKey(...),
que es public, y en el paso 2 el constructor llama a declareState(...), que es
protected. Todo eso es legal porque estamos dentro de la clase: es la razón
práctica de que el factory viva aquí y no en una clase de utilidades aparte.
6. Paso 4: rebuild(), la postura
Aquí está todo el ejemplo. Dos articulaciones que no se suman: doblar el codo mueve el antebrazo, y girar el hombro mueve el antebrazo y el codo.
@Override
protected void rebuild() {
// Las piezas están en reposo (brazo estirado): es el único momento en que las
// articulaciones se pueden leer de la geometría
Vec shoulderJoint = get("upperArm").getLeft();
Vec elbowJoint = get("upperArm").getRight();
Vec handRest = get("forearm").getRight();
AffineJTransform bendElbow =
AffineJTransform.create2DRotationTransform(elbowJoint, state("elbow"));
AffineJTransform turnShoulder =
AffineJTransform.create2DRotationTransform(shoulderJoint, state("shoulder"));
// El codo dobla sólo el antebrazo...
get("forearm").applyAffineTransform(bendElbow);
// ...y el hombro gira los dos como una sola cosa
get("upperArm").applyAffineTransform(turnShoulder);
get("forearm").applyAffineTransform(turnShoulder);
// La mano recibe las mismas dos transformaciones, en el mismo orden
hand.copyCoordinatesFrom(handRest
.applyAffineTransform(bendElbow)
.applyAffineTransform(turnShoulder));
}
Se lee como un dibujo, no como una secuencia de movimientos: dice dónde está el brazo para un par de ángulos. Y se escribe en el orden en que se construye una jerarquía: primero la articulación hija, después la padre, que arrastra todo lo que cuelga de ella.
Para las piezas, get("forearm").rotate(elbowJoint, state("elbow")) haría lo
mismo y es más corto. El AffineJTransform explícito se gana su sitio porque la
misma transformación se aplica luego al Vec de la mano: una sola definición
de cada giro, imposible que las dos se desincronicen.
Por qué esto no acumula error
Por setAbsoluteRebuild(true) del paso 2. En modo absoluto, StatefulCompound
guarda una foto de las piezas tal como estaban antes de la primera pasada (el
reposo) y las devuelve a esa posición antes de cada rebuild() (:470 y
:529). Por eso se rota por el ángulo completo y no por la diferencia: no hay
nada que deshacer y nada que acumular. Una animación interrumpida a mitad, un
valor puesto hacia atrás o una lambda reverse() dejan el brazo exactamente donde
el estado dice.
Sin ese setAbsoluteRebuild(true), rebuild() es incremental y hay que
escribirlo con diferencias. Ver la sección 11.
Por qué las articulaciones se leen de la geometría
Vec.to(0, 0) y Vec.to(upperArmLength, 0) funcionarían hasta el primer
arm.shift(...). Leídas de la pieza en reposo, las dos articulaciones son
siempre (0, 0) y (L1, 0) relativas a donde esté el brazo ahora: desplazar,
rotar o escalar el compuesto entero se lleva consigo la posición de reposo
(StatefulCompound.applyAffineTransform, :559), y las articulaciones siguen
donde tienen que estar.
Ojo con el detalle que hace esto correcto. getLeft() y getRight() de una pieza
son anclas de su caja envolvente (mathobjects/Boxable.java:62-132), es decir
los puntos medios de sus lados izquierdo y derecho, que coinciden con las
articulaciones sólo mientras el rectángulo está sin rotar. Dentro de
rebuild() eso está garantizado por el modo absoluto, y fuera no: leer
arm.get("upperArm").getRight() desde la escena, con el brazo ya doblado, da el
borde de una caja alineada con los ejes y no el codo. Por eso la posición de la
mano se calcula aquí y se guarda en un campo (paso 5).
Los tres valores que se pueden leer dentro de rebuild()
| Llamada | Qué es |
|---|---|
state("elbow") |
El valor actual |
previousState("elbow") |
El valor con el que se construyó la geometría que hay en pantalla |
delta("elbow") (:357) |
La diferencia entre los dos |
En modo absoluto sólo hace falta el primero. Los otros dos siguen estando, y son la herramienta para reaccionar a un cambio una sola vez en lugar de en cada frame (por ejemplo, disparar un sonido al pasar el codo de estirado a doblado).
7. Paso 5: campos propios y copyOwnFieldsFrom
Un brazo tiene dos longitudes y una mano. Ninguna de las tres es estado: las longitudes son constructivas y la mano es derivada.
private double upperArmLength;
private double forearmLength;
/** Posición de la mano después de la última pasada. Derivada del estado. */
private final Vec hand = Vec.to(0, 0);
@Override
protected void copyOwnFieldsFrom(ArticulatedArm other) {
this.upperArmLength = other.upperArmLength;
this.forearmLength = other.forearmLength;
this.hand.copyCoordinatesFrom(other.hand);
}
copyOwnFieldsFrom (StatefulCompound.java:444) es el único gancho que hay que
tocar cuando la clase tiene campos propios. Lo llaman copy() y también
copyStateFrom, y esto último ocurre una vez por frame mientras una animación
guarda y restaura el estado de sus objetos: no es el sitio para reconstruir nada.
De ahí que hand sea final y se escriba con copyCoordinatesFrom en vez de
reasignar un Vec nuevo.
Con esto, el getter que faltaba:
/** Donde está la mano ahora mismo. */
public Vec getHand() {
return hand.copy();
}
Se devuelve una copia a propósito: el Vec interno lo reescribe la siguiente
pasada, y quien lo haya guardado se llevaría un valor que cambia solo.
Si algún otro objeto tiene que seguir a la mano, eso es un updater que lea
getHand(), no un campo que alguien recuerde refrescar. Verdocs/dev/UPDATE_SYSTEM.md.
8. Paso 6: los métodos que la clase sí aporta
Esta es la razón de escribir una clase: vocabulario propio, con tipos y con nombre.
/** La animación que lleva el brazo a una postura, moviendo los dos ángulos a la vez. */
public AnimationGroup reach(double runtime, double shoulderAngle, double elbowAngle) {
return AnimationGroup.make(
ScalarAnimation.make(runtime, state("shoulder"), shoulderAngle, stateScalar("shoulder")),
ScalarAnimation.make(runtime, state("elbow"), elbowAngle, stateScalar("elbow")));
}
/** Lo estira, que es la postura de reposo. */
public AnimationGroup stretch(double runtime) {
return reach(runtime, 0, 0);
}
public boolean isFolded() {
return Math.abs(state("elbow")) > 90 * DEGREES;
}
Lo importante es lo que estos métodos no hacen: no tocan las piezas. Un
bendElbow que rotase el antebrazo por su cuenta sería un segundo dueño de la
misma geometría, y el primer cambio de cualquier valor la devolvería a donde dice
el estado. El método construye la animación del valor; el valor coloca las piezas.
Y tampoco reproducen. Devuelven la animación y no la juegan, que es lo que hace que la clase no dependa de ninguna escena y que se pueda componer:
play.run(arm.reach(1.5, 60 * DEGREES, -100 * DEGREES)); // reproducir
AnimationGroup.make(arm.reach(2, ...), other.reach(2, ...)); // componer
anims.add(arm.reach(2, ...)); // acumular
Un detalle de la implementación: stateScalar("shoulder") (:376) devuelve el
Scalar que hay detrás del valor, que es justo lo que espera
ScalarAnimation.make(runtime, a, b, Parametric...). Animar ese Scalar es
literalmente lo que hace animState del DSL.
Para poner una postura sin animar, no hace falta método ninguno:
arm.state("elbow", -30 * DEGREES) devuelve ArticulatedArm, así que se puede
encadenar, y la geometría sigue en la siguiente actualización.
9. Paso 7: la clase completa
package com.jmathanim;
import com.jmathanim.animations.AnimationGroup;
import com.jmathanim.animations.ScalarAnimation;
import com.jmathanim.basic.AffineJTransform;
import com.jmathanim.basic.Rect;
import com.jmathanim.basic.Vec;
import com.jmathanim.mathobjects.compound.StatefulCompound;
import com.jmathanim.mathobjects.shapes.Shape;
import static com.jmathanim.core.JMathAnimScene.DEGREES;
/**
* Brazo articulado de dos piezas. La geometría es función de dos ángulos:
* 'shoulder' gira el brazo entero y 'elbow' dobla el antebrazo.
*/
public class ArticulatedArm extends StatefulCompound<ArticulatedArm, Shape> {
private double upperArmLength;
private double forearmLength;
/** Posición de la mano después de la última pasada. Derivada del estado. */
private final Vec hand = Vec.to(0, 0);
protected ArticulatedArm() {
// En el constructor, para que las copias nazcan con los mismos valores
declareState("shoulder", 0); // ángulo del brazo entero, desde la posición estirada
declareState("elbow", 0); // ángulo del antebrazo respecto del brazo
setAbsoluteRebuild(true); // rebuild() coloca desde el reposo
}
public static ArticulatedArm make(double upperArmLength, double forearmLength, double width) {
ArticulatedArm arm = new ArticulatedArm();
double h = .5 * width;
double elbowX = upperArmLength;
double handX = upperArmLength + forearmLength;
// Reposo: brazo estirado sobre el eje X, con el hombro en el origen
arm.addWithKey("upperArm", Shape.rectangle(Rect.make(0, -h, elbowX, h)));
arm.addWithKey("forearm", Shape.rectangle(Rect.make(elbowX, -h, handX, h)));
arm.upperArmLength = upperArmLength;
arm.forearmLength = forearmLength;
// Estilo común y, después, el retoque por pieza
arm.drawColor("#3d3d3d").fillColor("#c8b998").fillAlpha(1).thickness(3);
arm.get("forearm").fillColor("#8a7a5c");
return arm;
}
public static ArticulatedArm make(double upperArmLength, double forearmLength) {
return make(upperArmLength, forearmLength, .5);
}
@Override
protected ArticulatedArm createInstance() {
return new ArticulatedArm();
}
@Override
protected void copyOwnFieldsFrom(ArticulatedArm other) {
this.upperArmLength = other.upperArmLength;
this.forearmLength = other.forearmLength;
this.hand.copyCoordinatesFrom(other.hand);
}
@Override
protected void rebuild() {
// Las piezas están en reposo: las articulaciones se leen aquí y sólo aquí
Vec shoulderJoint = get("upperArm").getLeft();
Vec elbowJoint = get("upperArm").getRight();
Vec handRest = get("forearm").getRight();
AffineJTransform bendElbow =
AffineJTransform.create2DRotationTransform(elbowJoint, state("elbow"));
AffineJTransform turnShoulder =
AffineJTransform.create2DRotationTransform(shoulderJoint, state("shoulder"));
// Primero la articulación hija, después la padre, que arrastra las dos piezas
get("forearm").applyAffineTransform(bendElbow);
get("upperArm").applyAffineTransform(turnShoulder);
get("forearm").applyAffineTransform(turnShoulder);
// La mano recibe las mismas dos transformaciones, en el mismo orden
hand.copyCoordinatesFrom(handRest
.applyAffineTransform(bendElbow)
.applyAffineTransform(turnShoulder));
}
// ---- Lo que el brazo sabe hacer ----
/** La animación que lleva el brazo a una postura, moviendo los dos ángulos a la vez. */
public AnimationGroup reach(double runtime, double shoulderAngle, double elbowAngle) {
return AnimationGroup.make(
ScalarAnimation.make(runtime, state("shoulder"), shoulderAngle, stateScalar("shoulder")),
ScalarAnimation.make(runtime, state("elbow"), elbowAngle, stateScalar("elbow")));
}
/** Lo estira, que es la postura de reposo. */
public AnimationGroup stretch(double runtime) {
return reach(runtime, 0, 0);
}
/** Donde está la mano ahora mismo. */
public Vec getHand() {
return hand.copy();
}
public double getReachLength() {
return upperArmLength + forearmLength;
}
public boolean isFolded() {
return Math.abs(state("elbow")) > 90 * DEGREES;
}
}
10. Paso 8: usarlo desde una escena
package com.jmathanim;
import com.jmathanim.animations.Animation;
import com.jmathanim.animations.AnimationGroup;
import com.jmathanim.core.Scene2D;
import com.jmathanim.mathobjects.compound.MathObjectGroup;
import com.jmathanim.utils.UsefulLambdas;
import com.jmathanim.utils.layouts.BoxLayout;
import java.util.ArrayList;
public class ArmDemo extends Scene2D {
public static void main(String[] args) {
new ArmDemo().execute();
}
@Override
public void runSketch() throws Exception {
config.parseFile("#preview.xml");
config.parseFile("#light.xml");
ArticulatedArm arm = ArticulatedArm.make(2.5, 2);
add(arm);
camera.adjustToAllObjects();
camera.scale(1.6);
// Postura sin animar: se escribe el estado y la geometría sigue en el update
arm.state("shoulder", 20 * DEGREES).state("elbow", -30 * DEGREES);
waitSeconds(.5);
// Los dos ángulos en una sola animación: la mano recorre la curva que hacen juntos
play.run(arm.reach(1.5, 60 * DEGREES, -100 * DEGREES));
// Uno detrás del otro: el brazo sube y luego se dobla, y la mano traza dos arcos
play.run(arm.reach(1, 60 * DEGREES, 0));
play.run(arm.reach(1, 60 * DEGREES, -100 * DEGREES));
// Sigue siendo un objeto: se mueve entero y las articulaciones van con él
play.shift(1, 0, -1.5, arm);
play.run(arm.stretch(1));
// Y se copia, con sus dos ángulos y su mano
ArticulatedArm second = arm.copy().shift(0, 2);
add(second);
play.run(second.reach(1, -40 * DEGREES, 80 * DEGREES));
}
}
Componer en vez de reproducir, que es donde se nota que reach devuelva la
animación:
ArrayList<ArticulatedArm> arms = new ArrayList<>();
MathObjectGroup group = MathObjectGroup.make();
for (int n = 0; n < 6; n++) {
ArticulatedArm a = ArticulatedArm.make(2.5, 2);
arms.add(a);
group.add(a);
}
group.setLayout(BoxLayout.make(3, .5, .5));
add(group);
camera.adjustToAllObjects();
ArrayList<Animation> anims = new ArrayList<>();
for (ArticulatedArm a : arms) {
anims.add(a.reach(2, 45 * DEGREES, -110 * DEGREES));
}
AnimationGroup wave = AnimationGroup.make(anims.toArray(new Animation[0]))
.addDelayEffect(.6);
play.run(wave);
play.run(wave.setLambda(UsefulLambdas.reverse()));
play, camera, config y la constante DEGREES son miembros heredados de
JMathAnimScene, así que dentro de runSketch() se usan a secas. El grupo es
sólo para el layout: los brazos son objetos de escena de pleno derecho, y cada uno
lleva su estado.
11. La variante incremental de rebuild()
Sin setAbsoluteRebuild(true), las piezas se quedan donde las dejó la pasada
anterior y rebuild() las mueve por la diferencia:
@Override
protected void rebuild() {
// Ojo: aquí las piezas NO están en reposo, así que las articulaciones no se
// pueden leer de la caja envolvente y hay que llevarlas por otra vía
get("forearm").rotate(elbowJoint, delta("elbow"));
get("upperArm").rotate(shoulderJoint, delta("shoulder"));
get("forearm").rotate(shoulderJoint, delta("shoulder"));
}
El trato es este:
Absoluto (setAbsoluteRebuild(true)) |
Incremental (por omisión) | |
|---|---|---|
| Coste | Restaura todas las piezas en cada frame en que un valor cambia | Sólo lo que haga el método |
| Riesgo | Ninguno, nada puede desviarse | Cada pasada tiene que ser inversa exacta de sí misma, o las piezas se torcen para siempre |
| Cómo se lee la geometría | Todas las piezas en reposo, así que leer cualquiera da siempre lo mismo | Leer la posición de una pieza para decidir dónde ponerla es el camino a la deriva |
Regla práctica: empezar en absoluto, y pasar a incremental sólo si el brazo crece hasta que restaurar sus piezas se note (piezas con cientos de puntos, no un par de rectángulos). En el brazo articulado, el modo absoluto es además lo que permite leer las articulaciones de la geometría, que es la mitad de la elegancia del ejemplo.
12. Estado gobernado desde fuera
declareState(nombre, scalar) (:172) registra un Scalar que el compuesto no
posee, y entonces lo que mueva ese Scalar mueve el brazo: un slider, una
animación, otro compuesto que comparte el mismo valor.
Se añade como una variante del factory, no como un constructor nuevo: el nombre ya
lo declaró el constructor, y aquí sólo se sustituye el Scalar que lo respalda
(putState, :305, suelta el anterior como dependencia).
/** Brazo cuyo codo lo gobierna un Scalar de fuera: un slider, una animación, otro brazo. */
public static ArticulatedArm makeLinked(double upperArmLength, double forearmLength,
Scalar sharedElbow) {
ArticulatedArm arm = make(upperArmLength, forearmLength);
arm.declareState("elbow", sharedElbow);
return arm;
}
Scalar t = Scalar.make(0);
ArticulatedArm arm = ArticulatedArm.makeLinked(2.5, 2, t);
add(arm);
play.run(ScalarAnimation.make(2, 0, -110 * DEGREES, t)); // el codo sigue a t
Dos advertencias:
- El compartir no se hereda en las copias:
copy()le da a la copia unScalarpropio con el mismo valor, para que copiar un objeto no cablee silenciosamente un segundo objeto al slider. - Si se prefiere un constructor con parámetros, hay que dejar además uno sin
argumentos para
createInstance(), que es quien construye las copias vacías.
13. Checklist
| Obligatorio | Dónde |
|---|---|
createInstance() devolviendo la clase concreta |
Método |
rebuild() con la geometría en función del estado |
Método |
declareState(...) de todos los valores |
Constructor |
setAbsoluteRebuild(true) si rebuild() es una postura |
Constructor |
Piezas con addWithKey, leídas con get("nombre") |
Factory estático |
| Piezas dibujadas en la posición del estado inicial | Factory estático |
copyOwnFieldsFrom(other) si hay campos propios |
Método |
14. Trampas
El autotipo tiene que ser la propia clase. extends StatefulCompound<OtraCosa,
Shape> compila sin quejarse y estalla con ClassCastException en el primer
método fluido, porque la base devuelve (S) this. Y si se hereda de esta clase
(class RobotArm extends ArticulatedArm), hay que decidir qué se hace con el
autotipo: lo más simple es no volver a parametrizarlo y sobrescribir
createInstance() para devolver la subclase, aceptando que los métodos fluidos
siguen devolviendo ArticulatedArm.
No guardar las piezas en campos. copy() remapea el diccionario, no los
campos. Con addWithKey + get("nombre") la copia funciona; con campos, la copia
mueve las piezas del original.
Los ganchos son protected, y copy() no es uno de ellos. En esta jerarquía
copy() ya está resuelto: se sobrescribe createInstance() y, si hay campos,
copyOwnFieldsFrom. Sobrescribir copy() a mano se salta copyBaseTo y con él
el estilo, los nombres de las piezas, las etiquetas y los metadatos.
Piezas y valores son dos espacios de nombres distintos. arm.get("forearm")
es una pieza y arm.state("elbow") es un valor. Pedir un valor con get(...)
devuelve null con un aviso, y pedir una pieza con state(...) lanza
IllegalArgumentException con la lista de los que sí existen.
Las anclas de una pieza son de su caja envolvente. getLeft(), getRight(),
getUpper(), getCenter() de un rectángulo rotado son las de una caja alineada
con los ejes, no las de la pieza. Dentro de un rebuild() absoluto están en
reposo y valen; fuera hay que calcular la posición y guardarla, como se hace con
la mano.
Mover el compuesto, no sus piezas. La posición de reposo viaja con el objeto, así que desplazar, rotar o escalar el brazo mantiene las articulaciones en su sitio. Mover una pieza a mano desde fuera no: el siguiente cambio de un valor la devuelve a donde dice la postura. Si una pieza tiene que moverse por su cuenta, eso es un valor más del estado.
rebuild() tiene que ser barato. Se ejecuta en cada frame en el que algún
valor cambia. No es el sitio para compilar LaTeX ni para reconstruir caminos,
salvo con una guarda que lo limite al frame en que haga falta (comparar
state(...) con previousState(...)).
Y tiene que depender sólo del estado. Para los mismos valores, la misma geometría. Si hace falta algo aleatorio o una decisión de una sola vez, se resuelve una vez y se escribe en el estado, y de la segunda pasada en adelante ya es una consecuencia de un valor como cualquier otra.
Una pieza que cambia lo que es no se restaura, sólo se recoloca. La foto del
reposo se usa mientras la pieza siga hecha de las mismas partes; reescribir el
contenido de una fórmula (setLaTeX) la invalida, y se vuelve a tomar la
siguiente vez que cambie un valor. Es lo que permite los eventos de una sola vez,
y también significa que el contenido nuevo queda fotografiado donde estuviese la
pieza en ese momento.
Avisos con el logger. JMathAnimScene.logger.warn(...), nunca
System.out.println: el GUI redirige la salida estándar a un fichero de log.
Números, no objetos, en el estado. Un modo, una bandera o un contador son
números y vale la pena codificarlos como tales, porque el estado se copia y se
restaura. Cuando el número es en realidad una elección entre pocas opciones, se
declara con sus nombres (declareState("mode", List.of("off", "on"))). Lo que no
cabe (un texto libre, una referencia a otro objeto) va a un campo propio, y
entonces a copyOwnFieldsFrom.
15. La misma clase desde un script Groovy
Una clase Java como esta se puede usar tal cual desde un script, y ahí se
encuentra con el DSL: apply(obj: arm, state: [elbow: -1]) y animState(obj:
arm, to: [shoulder: 1], runtime: 2) funcionan sobre ella exactamente igual que
sobre un compound(...), porque las dos cosas son StatefulCompound. Si la clase
está en el core, se importa sola; si está en un jar aparte, se declara con
@JMPlugin('milib.jar').
Y al revés, para traducir en cualquiera de los dos sentidos:
| Bloque del DSL | En una subclase Java |
|---|---|
obj: [nombre: pieza, ...] |
addWithKey("nombre", pieza) en el factory, en el mismo orden |
from: grupo |
adopt(grupo) (AbstractCompoundMathObject.java:683) |
state: [a: 0, b: 0] |
declareState("a", 0) en el constructor |
state: [modo: ["off", "on"]] |
declareState("modo", List.of("off", "on")) |
pose: { c -> ... } |
setAbsoluteRebuild(true) + rebuild() |
rebuild: { c -> ... } |
rebuild() a secas, con delta(...) |
init: { c -> ... } |
El final del factory, cuando las piezas ya están puestas |
style: [...] |
Los setters de estilo sobre el compuesto |
parts: [nombre: [...]] |
Los setters sobre get("nombre"), después del estilo común |
methods: [abrir: { ... }] |
Métodos de verdad de la clase |
addToScene: true |
add(arm) en la escena |
Lo que la clase gana: campos con tipo, métodos que un IDE completa y refactoriza,
herencia y un nombre importable desde cualquier sitio. Lo que pierde: no aparece
en dsl-completions.yaml, así que en el editor del GUI no hay autocompletado ni
plegado para ella. Si se quiere esa integración, la vía es añadir una FooDSL al
core siguiendo el checklist de DSL.
16. Referencias
| Fichero | Qué mirar |
|---|---|
mathobjects/compound/StatefulCompound.java |
El javadoc de cabecera, el ciclo de update (:576) y el modo absoluto (:470) |
mathobjects/compound/AbstractCompoundMathObject.java |
addWithKey (:240), get (:333), el dibujado (:532), copyBaseTo (:650) |
src/test/java/com/jmathanim/StatefulCompoundTest.java |
Una caja con tapa como subclase Java, con los tests que documentan qué se garantiza |
core/groovy/dsl/ClosureCompound.groovy |
La misma idea con closures: lo que hace el bloque del DSL por dentro |
docs/manual/02e_CompoundObjects/CompoundObjects.md |
La versión en DSL, paso a paso, con la caja de Schrödinger |
docs/dev/CLASES_GROOVY_PERSONALIZADAS.md |
Lo mismo pero definiendo la clase dentro de un script Groovy |
docs/dev/UPDATE_SYSTEM.md |
Cómo el grafo de dependencias llega hasta rebuild() |