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:
- Se compila con clase base propia,
JMAnimBaseScript(GroovyUtils.createCompilerConfiguration,GroovyUtils.java:628). De ahí salenlatex(...),shape(...),morph(...)y el resto del DSL. - Se le inyecta un binding con
scene,play,config,camera,fixedCamera,scriptDir,PI,DEGREESy los objetos delDSLRegistry(GroovyUtils.createBindings,GroovyUtils.java:583). - Se le aplica un
ImportCustomizergigante que hace star-import de todos los paquetescom.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 mismoLineTrackingCustomizerpero 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(...), cadenasr"...") solo se aplica al script principal y a lo incluido conimport(...). Un fichero resuelto por classpath lo compila Groovy a partir de su fuente en crudo: ahí@JMPlugines 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@JMPlugintiene que estar en el script principal o en un fichero traído conimport(...).
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:
- La API Java de siempre, que ya conoces:
LatexMathObject.make(...),Shape.segment(...),MathObjectGroup.make(...). - Llamar directamente a los builders del DSL, que son clases públicas con
métodos estáticos y son a lo que
JMAdelega:
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
ImportCustomizerse aplica a toda la unidad de compilación. Una clase en el mismo fichero, o incluida conimport(...), veLatexMathObject,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,Shapeo 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@Fieldoimport groovy.transform.Field(GroovyUtils.removeFieldLines,GroovyUtils.java:554). Para estado a nivel de script, asigna sindef:fr = Fraction.of(...)va al binding y es visible desde los métodos del script. isX()+getX()colisionan. Si una clase tiene ambos,obj.xen un script devuelve elboolean. 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. thisdentro de un closure de una clase es la instancia, no el script. Si el closure necesita la escena, usaJMA.scene, no supongas el binding.- Parámetros numéricos: al comprobar valores opcionales usa
!= nully no la verdad de Groovy, o un0o unfalselegítimos se descartarán en silencio. - Avisos y trazas: usa
JMathAnimScene.logger.warn(...), nuncaprintln/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
MathObjectGroupesprotected, peronew Fraction()funciona porque una subclase sí puede invocarlo. - El apilado moderno es fluido:
obj.stack().with...().toObject(otro)(utils/stack/StackUtils.java). No haystackTo(...)público enMathObject. play.shiftesshift(double runtime, double dx, double dy, objs...)oshift(double runtime, Vec v, objs...)(PlayAnim.java:257). No acepta una lista como vector.getWidth()viene deBoxablecomo 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.