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, ver docs/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 (shoulder y elbow) 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(...) o arm.state("elbow", 1) devuelvan un ArticulatedArm y no una base, de modo que se pueden encadenar sin castear. Poner ahí otra clase compila y produce ClassCastException en cuanto algo devuelve (S) this.
  • El segundo es el tipo de las piezas. Aquí las dos son Shape, así que get("forearm") devuelve un Shape ya tipado y se le pueden llamar métodos de Shape sin cast. Si las piezas fuesen de tipos distintos (un Shape y un LatexMathObject), habría que poner MathObject<?>, 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 protected y así se queda. El de la jerarquía también lo es. Una subclase puede invocarlo, así que new 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 con copyBaseTo (estilo, piezas, nombres, huecos, cámara, etiquetas, metadatos) más el estado. Si devuelve la clase equivocada, cada copia degenera; si devuelve null, 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 un update() 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. Ver docs/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 un Scalar propio 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()