Definir clases propias en Groovy con JMathAnim

Documento de referencia para quien ya sabe escribir objetos personalizados en Java usando JMathAnim como librería y quiere hacer lo mismo desde un script Groovy.

Todas las referencias de código apuntan a jmathanim-core.


1. El problema de fondo

Un script Groovy de JMathAnim no es un fichero Groovy normal. Antes de compilarse pasa por tres cosas que cambian las reglas del juego:

  1. Se compila con clase base propia, JMAnimBaseScript (GroovyUtils.createCompilerConfiguration, GroovyUtils.java:628). De ahí salen latex(...), shape(...), morph(...) y el resto del DSL.
  2. Se le inyecta un binding con scene, play, config, camera, fixedCamera, scriptDir, PI, DEGREES y los objetos del DSLRegistry (GroovyUtils.createBindings, GroovyUtils.java:583).
  3. Se le aplica un ImportCustomizer gigante que hace star-import de todos los paquetes com.jmathanim.* encontrados en el jar.

Ni el punto 1 ni el punto 2 alcanzan a una clase que definas tú. Ese es el origen de casi todos los tropiezos.


2. Dónde vive la clase

Opción Cómo Implicaciones
En el mismo .groovy class Fraction extends ... {} junto al código del script Lo más simple. Se recompila en cada ejecución
Fichero aparte incluido import("Fraction.groovy") Inclusión textual (GroovyUtils.processImports, GroovyUtils.java:856). Los errores se mapean bien al fichero original gracias a los marcadores BEGIN/END IMPORT
Fichero aparte por classpath Fraction.groovy en el directorio del script, sin import(...) El directorio del script se añade al classpath del compilador (GroovyUtils.java:631). El nombre de fichero debe coincidir con el de la clase
Jar precompilado menú Plugins del editor, o @JMPlugin('milib.jar') en el script Añade star-imports de los paquetes que declare el plugin. Si el JAR cambia en disco se recarga solo; también se puede descargar desde el menú Plugins para reemplazarlo. Ver SISTEMA_GRAFICO.md §10

Tres detalles que conviene conocer:

  • Cada ejecución crea un classloader hijo nuevo (GroovyUtils.java:155) precisamente para que se pueda reejecutar un script que define clases top-level sin errores de duplicate class definition.
  • En el GUI, el resaltado de línea en ejecución es exacto para el script principal y para lo incluido con import(...). Un fichero resuelto por classpath se compila con el mismo LineTrackingCustomizer pero con el mapa de líneas del script principal, así que el resaltado puede señalar líneas equivocadas. No afecta a la ejecución.
  • El preprocesado de JMathAnim (@JMPlugin, import(...), cadenas r"...") solo se aplica al script principal y a lo incluido con import(...). Un fichero resuelto por classpath lo compila Groovy a partir de su fuente en crudo: ahí @JMPlugin es una anotación normal (existe como clase real, com.jmathanim.core.groovy.JMPlugin, para que compile) que no carga nada. Si esa clase usa tipos del jar, el @JMPlugin tiene que estar en el script principal o en un fichero traído con import(...).

Recomendación: empezar con la clase en el mismo fichero, y pasar a import("...") cuando crezca.


3. Dentro de una clase no existe el binding ni el DSL

Esta es la consideración principal.

3.1 Variables del binding

scene, play, config, camera, fixedCamera y scriptDir solo existen en el cuerpo del script. Dentro de una clase usa el accesor estático JMA, creado exactamente para esto (com/jmathanim/core/groovy/JMA.java, se inicializa en createBindings):

class Fraction extends MathObjectGroup {
    void addToScene() {
        JMA.scene.add(this)        // 'scene' a secas no existe aqui
        JMA.play.fadeIn(1, this)
        double fps = JMA.config.fps
    }
}

3.2 Funciones del DSL

latex(...), shape(...), group(...), axes(...), morph(...), appear(...) y compañía son métodos de JMAnimBaseScript (core/groovy/dsl/JMAnimBaseScript.groovy). Tu clase no hereda de ella, así que no los tiene a secas. Lo normal es usar JMA, que desde agosto de 2026 lleva un delegado estático por cada llamada del DSL, con exactamente los mismos parámetros:

import com.jmathanim.core.groovy.JMA

class Fraction extends MathObjectGroup {
    static Fraction make(String a, String b) {
        def num = JMA.latex(text: a, color: "blue")
        def bar = JMA.shape(type: "segment", from: [-.5, 0], to: [.5, 0])
        ...
    }
}

Alternativas, cuando JMA no encaje:

  1. La API Java de siempre, que ya conoces: LatexMathObject.make(...), Shape.segment(...), MathObjectGroup.make(...).
  2. Llamar directamente a los builders del DSL, que son clases públicas con métodos estáticos y son a lo que JMA delega:
import com.jmathanim.core.groovy.dsl.*

def num = LatexDSL.latex(text: r"\frac{a}{b}", color: "blue")
def g   = GroupDSL.make(obj: [num, den])

Añade el import explícito: los auto-imports dependen de que se esté ejecutando desde el jar, y desde target/classes la lista es más corta. 3. Pasar los objetos ya construidos desde el script al constructor o al factory de tu clase. Suele ser lo más limpio.


4. Imports automáticos y nombres

  • El ImportCustomizer se aplica a toda la unidad de compilación. Una clase en el mismo fichero, o incluida con import(...), ve LatexMathObject, Shape, Vec, AnchorType, MathObjectGroup, etc. sin escribir ni un import.
  • Si compilas la clase aparte en un jar de plugin, necesitas imports reales.
  • Tu clase queda en el paquete por defecto y gana frente a los star-imports. No la llames Line, Point, Text, Group, Shape o cualquier otro nombre del core: romperías silenciosamente el resto del script.

5. De qué heredar

Para un objeto compuesto (varios MathObject que se mueven juntos), como una fracción:

Base Qué implementar Notas
MathObjectGroup nada obligatorio Lo más cómodo. Los métodos fluidos (shift, scale, ...) devuelven MathObjectGroup, no tu tipo
AbstractMathGroup<Fraction> makeNewInstance() copy() ya viene resuelto (AbstractMathGroup.java:319). Tipado propio en los métodos fluidos
MathObject<Fraction> copy(), computeBoundingBox() y los hooks de AbstractUpdateable Solo si necesitas control total del dibujado
CompoundMathObject<Shape> copy() Un objeto de escena de verdad, no un grupo lógico: entra, se dibuja, se quita y se anima como una unidad, y las piezas son internas
StatefulCompound<Fraction, Shape> createInstance(), rebuild() Cuando la geometría depende de unos pocos valores con nombre. copy(), copyStateFrom y el ciclo de update ya vienen resueltos

MathObject<T extends MathObject<T>> es autotipado (CRTP). Si extiendes en crudo sin parámetro genérico, Groovy lo acepta y funciona en dinámico, pero pierdes el tipo de retorno de los métodos encadenados.

Para las dos últimas filas hay una guía aparte, paso a paso y con un brazo articulado como ejemplo: OBJETOS_COMPUESTOS_POR_SUBCLASE.md. Está escrita en Java, pero el diseño y las trampas son los mismos definiendo la clase aquí.

5.1 El detalle importante: copy() no copia tus campos

AbstractMathGroup.copy() hace makeNewInstance(), copia el estilo y copia los elementos de la lista. Tus campos propios (num, den, bar) quedan a null en la copia. Y como las animaciones (morph, transform, highlight) trabajan sobre copias, esto se manifiesta como fallos raros a mitad de animación.

Dos soluciones:

  • Preferida: no guardar los hijos en campos. Registrarlos con addWithKey("num", obj) y exponerlos con getters que consulten el diccionario. copy() sí copia el diccionario, remapeado a los objetos copiados (AbstractMathGroup.java:324-331).
  • Alternativa: sobrescribir copy() y volver a enganchar los campos a los elementos ya copiados.

Y si extiendes MathObjectGroup, sobrescribe makeNewInstance() para devolver una instancia de tu clase; si no, copy() te devuelve un MathObjectGroup pelado.


6. Trampas de Groovy específicas de este proyecto

  • No uses @Field. El preprocesador borra la línea entera que contenga @Field o import groovy.transform.Field (GroovyUtils.removeFieldLines, GroovyUtils.java:554). Para estado a nivel de script, asigna sin def: fr = Fraction.of(...) va al binding y es visible desde los métodos del script.
  • isX() + getX() colisionan. Si una clase tiene ambos, obj.x en un script devuelve el boolean. Evita ese par de nombres al diseñar propiedades.
  • Las cadenas r"..." se preprocesan en todo el fichero, también dentro de tu clase. Ideal para LaTeX: r"\frac{a}{b}" sin escapar nada.
  • Un campo sin modificador es una propiedad pública con getter y setter generados. Si quieres un campo privado real, escribe private double x.
  • this dentro de un closure de una clase es la instancia, no el script. Si el closure necesita la escena, usa JMA.scene, no supongas el binding.
  • Parámetros numéricos: al comprobar valores opcionales usa != null y no la verdad de Groovy, o un 0 o un false legítimos se descartarán en silencio.
  • Avisos y trazas: usa JMathAnimScene.logger.warn(...), nunca println/System.out. El GUI redirige la salida estándar a un fichero de log y los prints se pierden.

7. Lo que sí heredas gratis

Los extension modules (core/groovy/extensions, registrados en META-INF/services/org.codehaus.groovy.runtime.ExtensionModule) se aplican por tipo, así que cualquier subclase de MathObject los recibe:

fr2 = fr + [1, 0]        // copia desplazada
fr3 = fr * 2             // copia escalada
fr << otroObjeto         // add sobre grupos
scene << fr              // add a la escena

Si quieres operadores propios de tu clase, define métodos plus, minus, multiply o div dentro de ella. Es más simple que crear otro extension module.


8. Actualizaciones automáticas

Si la fracción debe recolocarse sola cuando cambian sus dependencias, sobrescribe performMathObjectUpdateActions() (AbstractMathGroup.java:562) llamando a super y después a tu recolocación. No recalcules a mano en cada frame:

@Override
void performMathObjectUpdateActions() {
    super.performMathObjectUpdateActions()
    relayout()
}

Para hijos que no forman parte del grupo pero deben actualizarse y dibujarse con él, mira registerInternalObject / updateInternalObjects / drawInternalObjects en MathObject.java:692-720.


9. Ejemplo completo

class Fraction extends MathObjectGroup {

    static Fraction of(String numerator, String denominator) {
        def f = new Fraction()
        f.addWithKey("bar", Shape.segment(Point.at(0, 0), Point.at(1, 0)))
        f.addWithKey("num", LatexMathObject.make(numerator))
        f.addWithKey("den", LatexMathObject.make(denominator))
        return f.relayout()
    }

    // Los hijos se consultan por clave, no se guardan en campos:
    // asi 'copy()' los reconstruye correctamente
    Shape            getBar() { get("bar") as Shape }
    LatexMathObject  getNum() { get("num") as LatexMathObject }
    LatexMathObject  getDen() { get("den") as LatexMathObject }

    Fraction relayout() {
        bar.setWidth(Math.max(num.width, den.width) * 1.2)
        num.stack().withDestinyAnchor(AnchorType.UPPER).withGaps(0, .03).toObject(bar)
        den.stack().withDestinyAnchor(AnchorType.LOWER).withGaps(0, .03).toObject(bar)
        return this
    }

    @Override
    protected MathObjectGroup makeNewInstance() {
        return new Fraction()
    }

    @Override
    void performMathObjectUpdateActions() {
        super.performMathObjectUpdateActions()
        relayout()
    }
}

// ---- script ----
fr = Fraction.of(r"a+b", r"c^2")
fr.stack().toScreen(ScreenAnchor.CENTER)
scene.add(fr)

play.fadeIn(1, fr)
play.shift(1, 1, 0, fr)          // (runtime, dx, dy, objs...)

Puntos del ejemplo que suelen escribirse mal:

  • El constructor de MathObjectGroup es protected, pero new Fraction() funciona porque una subclase sí puede invocarlo.
  • El apilado moderno es fluido: obj.stack().with...().toObject(otro) (utils/stack/StackUtils.java). No hay stackTo(...) público en MathObject.
  • play.shift es shift(double runtime, double dx, double dy, objs...) o shift(double runtime, Vec v, objs...) (PlayAnim.java:257). No acepta una lista como vector.
  • getWidth() viene de Boxable como método por defecto, así que en Groovy se puede leer como propiedad: num.width.

10. Lo que no vas a tener

Tus clases no aparecen en dsl-completions.yaml, así que en el editor del GUI no hay autocompletado, ni resaltado, ni plegado para ellas. Si quieres esa integración, la vía es añadir una FooDSL real al core siguiendo el checklist de DSL (FooDSL.groovy + JMAnimBaseScript + las entradas en los ficheros de definiciones del core, bajo resources/autocomplete/), no una clase de usuario.