El flujo de actualización de JMathAnim (modelo pull)

Este documento describe el funcionamiento interno del sistema de versiones y actualización tras la migración de push (listeners de versión) a pull (versiones efectivas). Es documentación de desarrollo, no del manual de usuario.

Idea central

Cada objeto que puede cambiar (Vec, Point, JMPath, MathObject, MODrawProperties, Camera...) lleva un número de versión. Todas las versiones salen de un único contador global:

// AbstractVersioned
public void changeVersion() {
    version = ++JMathAnimScene.globalVersion;
}

Dos propiedades se derivan de esto:

  1. Toda mutación produce una versión estrictamente mayor que cualquier versión anterior en cualquier objeto. Comparar versiones equivale a comparar "quién cambió más recientemente".
  2. Si globalVersion no ha cambiado, nada ha cambiado en ninguna parte. Esta es la base de la caché de versiones efectivas y del test SteadyStateUpdateTest (una escena estática debe mantener globalVersion constante entre frames).

Versión efectiva: el mecanismo pull

Un objeto declara sus entradas con addDependency(...). La detección de cambios ya no se hace notificando a nadie: cada objeto pregunta por el estado de sus dependencias cuando lo necesita.

AbstractVersioned.getVersion() no devuelve la versión propia en bruto, sino la versión efectiva:

versionEfectiva(obj) = max( obj.version,
                            versionEfectiva(dep) para cada dep de obj )

Es decir, el máximo recursivo sobre todo el árbol de dependencias. Con esto, la comprobación de si un objeto está sucio es una sola comparación:

// AbstractUpdateable
public boolean needsUpdate() {
    return getVersion() != lastProcessedVersion;
}

lastProcessedVersion es la versión efectiva que el objeto vio la última vez que se actualizó. Si cualquier cosa en su árbol de dependencias (directa o transitiva) ha cambiado desde entonces, el máximo habrá subido y la comparación falla.

Dos protecciones dentro de getVersion():

  • Caché por globalVersion: el resultado se memoriza junto con el valor de globalVersion en el momento del cálculo. Mientras globalVersion no cambie, la caché es válida (nada pudo cambiar). En una escena en reposo el coste por objeto y frame es O(1); la recursión completa solo se paga cuando algo realmente mutó.
  • Guarda anti-ciclos: si durante la recursión se vuelve a entrar en un objeto que ya está calculando (ciclo accidental de dependencias), la reentrada devuelve la versión propia en bruto y corta la recursión. Un ciclo degrada la precisión del orden de actualización, pero no cuelga el programa (coherente con el aviso de ciclo del DependencyGraph).

Además, addDependency/removeDependency llaman a changeVersion() sobre el objeto dueño: un cambio estructural de entradas lo marca como sucio y, de paso, invalida todas las cachés (porque sube globalVersion).

El ciclo de un frame

Por cada frame, la escena hace DependencyGraph.updateAll():

  1. Si la estructura de dependencias pudo cambiar (se detecta con el contador estático dependencyStructureVersion), se reconstruyen las aristas desde getDependencies() y se reordena topológicamente.
  2. Se recorre el orden topológico (dependencias antes que dependientes) y se llama a update() en cada nodo Updateable.

AbstractUpdateable.update() hace, en orden:

1. applyUpdaters(true)                  // Updaters "before"
2. si needsUpdate():                    // comparación pull
       performMathObjectUpdateActions() // recálculo real del objeto
3. applyUpdaters(false)                 // Updaters "after"
4. si algo cambió (flag):
       changeVersion()                  // el objeto publica su nueva versión
       performUpdateBoundingBox()
5. postUpdateActions()                  // p.ej. objetos internos (labels, tips)
6. lastProcessedVersion = getVersion()  // "hasta aquí he visto"

El paso 5 corre después del bump de versión a propósito: los objetos internos (registrados con addInternalObject) dependen de su padre, y así ven la versión definitiva del padre en el mismo frame. Con el orden antiguo la veían un frame tarde y generaban un frame de eco.

El renderer usa el mismo mecanismo: cachea la última versión dibujada de cada objeto y compara con getVersion(). Como ahora getVersion() es la versión efectiva, un cambio en un Vec interno de un path invalida el dibujo del Shape sin que nadie tenga que avisarle.

Ejemplo: un Arrow cuyo extremo es un Vec que se mueve

Vec A = Vec.to(0, 0);
Vec B = Vec.to(2, 0);
Arrow arrow = Arrow.make(A, B, ArrowType.ARROW1);
scene.add(arrow);

// ... más tarde ...
A.shift(0.5, 1); // movemos el extremo inicial

Al construirse, Arrow (vía AbstractConnector) declara sus entradas:

protected AbstractConnector(Coordinates<?> A, Coordinates<?> B, ...) {
    this.A = A;
    this.B = B;
    addDependency(A);
    addDependency(B);
    ...
}

El grafo queda así (las flechas significan "depende de"):

arrow ──► A (Vec)
     └──► B (Vec)
     └──► mp (estilo)

Supongamos que tras estabilizarse la escena globalVersion = 100, con A.version = 40, B.version = 41, arrow.version = 100 y arrow.lastProcessedVersion = 100 (su versión efectiva: max(100, 40, 41) = 100).

Frame N — se mueve el Vec. A.shift(0.5, 1) recalcula las coordenadas y termina en A.changeVersion():

globalVersion: 100 → 101
A.version:      40 → 101

Nadie notifica a arrow. A no sabe quién depende de él, y no le hace falta.

Frame N — actualización. El DependencyGraph recorre el orden topológico. A y B son nodos hoja sin nada que recalcular; le toca a arrow:

arrow.needsUpdate()
  = getVersion() != lastProcessedVersion
  = max(arrow.version=100, A=101, B=41, mp=…) != 100
  = 101 != 100  →  true

(La caché de la versión efectiva estaba invalidada porque globalVersion cambió al mover A.)

Como está sucio, arrow ejecuta performMathObjectUpdateActions(), que llama a rebuildShape(): reconstruye cuerpo y cabeza de la flecha con las nuevas coordenadas de A. Después:

arrow.changeVersion()          → globalVersion: 101 → 102, arrow.version = 102
performUpdateBoundingBox()
postUpdateActions()            → actualiza labels/tips internos, que ya ven la 102
lastProcessedVersion = getVersion() = max(102, 101, 41, …) = 102

Frame N — render. El renderer compara arrow.getVersion() = 102 con la última versión que dibujó (100) y redibuja la flecha.

Frame N+1 — reposo. Nadie muta nada, así que globalVersion sigue en 102:

  • arrow.getVersion() es un acierto de caché (O(1)): devuelve 102.
  • needsUpdate()102 != 102false. No hay recálculo ni bump.
  • El renderer ve la misma versión y no redibuja.

La escena queda estable en un solo frame: sin frames de eco y sin trabajo por frame en reposo.

Comparación con el modelo push anterior

Push (listeners) Pull (versiones efectivas)
Propagación changeVersion() recorría la lista de listeners y les subía la versión en cascada Nadie propaga nada; el dependiente compara getVersion() cuando le toca
Estado por objeto Lista de listeners + flag notifying Caché de versión efectiva (2 longs + 1 boolean)
Registro addDependency tenía que registrar el listener simétrico; olvidarlo rompía la propagación en silencio addDependency es la única fuente de verdad
Frames de eco El bump del padre re-ensuciaba a quien ya se había actualizado Imposible por construcción: cada uno anota lo último que vio
Ciclos Flag notifying contra recursión infinita en la notificación Guarda de reentrada en getVersion()
Listener que lanza excepción Podía dejar la propagación rota No existe el caso

Convenciones a mantener

  • Toda mutación de estado observable debe terminar en changeVersion(). Los setters de MathObject, Vec.applyAffineTransform, etc. ya lo hacen.
  • Vecs expuestos que se recalculan durante update() deben subir su versión (idealmente con comprobación de igualdad para no ensuciar en vano). Cachés puramente internas pueden escribir campos en bruto, pero entonces el dueño debe hacer changeVersion() al final (test guardián: InnerVecPropagationTest).
  • Las consultas de solo lectura no deben mutar versiones (test guardián: SteadyStateUpdateTest).
  • Declarar entradas con addDependency. Si un objeto lee otro sin declararlo, ningún mecanismo (ni el antiguo ni este) detectará el cambio.

Clases implicadas

Clase Papel
Versionable Contrato mínimo: changeVersion() y getVersion() (efectiva)
Dependable Añade getDependencies() / addDependency()
AbstractVersioned Implementa versión efectiva con caché y guarda anti-ciclos
AbstractUpdateable Ciclo needsUpdate()/update() y hook postUpdateActions()
DependencyGraph Orden topológico y updateAll() por frame
Updater Lógica adicional por objeto; sus targets se registran como dependencias en registerUpdater