diff --git a/src/main/java/com/tiedup/remake/rig/TiedUpRigConstants.java b/src/main/java/com/tiedup/remake/rig/TiedUpRigConstants.java
index c86ccb8..b1225fc 100644
--- a/src/main/java/com/tiedup/remake/rig/TiedUpRigConstants.java
+++ b/src/main/java/com/tiedup/remake/rig/TiedUpRigConstants.java
@@ -54,17 +54,17 @@ public final class TiedUpRigConstants {
/**
* Factory lazy : crée un Animator approprié au side runtime courant.
- * Client → ClientAnimator (à créer Phase 2)
- * Server → ServerAnimator (forké verbatim EF)
+ * Client → {@link com.tiedup.remake.rig.anim.client.ClientAnimator#getAnimator}
+ * Server → {@link ServerAnimator#getAnimator} (forké verbatim EF)
*
- *
Note Phase 0 : ClientAnimator n'est pas encore forké/créé
- * (Phase 2). Tant que c'est le cas, on retourne ServerAnimator des deux
- * côtés. Remplacer par {@code ClientAnimator::getAnimator} quand
- * disponible.
+ * Pattern lazy method-ref : {@code ClientAnimator::getAnimator} n'est
+ * chargé que si {@link #isPhysicalClient()} est true. Sur serveur dédié,
+ * la classe client n'est jamais référencée, donc jamais chargée → pas de
+ * {@code NoClassDefFoundError}.
*/
public static final Function, Animator> ANIMATOR_PROVIDER =
isPhysicalClient()
- ? ServerAnimator::getAnimator // TODO Phase 2 : ClientAnimator::getAnimator
+ ? com.tiedup.remake.rig.anim.client.ClientAnimator::getAnimator
: ServerAnimator::getAnimator;
private TiedUpRigConstants() {}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/AnimationManager.java b/src/main/java/com/tiedup/remake/rig/anim/AnimationManager.java
index 479ea4f..fcb9bb7 100644
--- a/src/main/java/com/tiedup/remake/rig/anim/AnimationManager.java
+++ b/src/main/java/com/tiedup/remake/rig/anim/AnimationManager.java
@@ -208,7 +208,8 @@ public class AnimationManager extends SimplePreparableReloadListener GltfToSkinnedMesh.convert(data, null));
}
+
+ /**
+ * Vertex influencé par 4 joints distincts avec poids non triviaux.
+ * Le plus faible (0.05) doit être drop, les 3 autres renormalisés
+ * pour sommer à 1.0.
+ */
+ @Test
+ void convertDropsLowestWeightAndRenormalizes() {
+ int vc = 1;
+ float[] positions = { 0F, 0F, 0F };
+ float[] normals = { 0F, 1F, 0F };
+ float[] texCoords = { 0.5F, 0.5F };
+ int[] indices = { 0 };
+
+ // Vertex avec poids 4→3 : body=0.5, leftUpperArm=0.3, rightUpperArm=0.15, [drop]=0.05
+ // Après drop + renorm : (0.5+0.3+0.15) = 0.95 → 0.526/0.316/0.158
+ int[] joints = { 0, 1, 2, 0 };
+ float[] weights = { 0.5F, 0.3F, 0.15F, 0.05F };
+
+ GltfData data = new GltfData(
+ positions, normals, texCoords, indices,
+ joints, weights,
+ new String[] { "body", "leftUpperArm", "rightUpperArm" },
+ new int[] { -1, 0, 0 },
+ new Matrix4f[] { new Matrix4f().identity(), new Matrix4f().identity(), new Matrix4f().identity() },
+ new Quaternionf[] { new Quaternionf(), new Quaternionf(), new Quaternionf() },
+ new Vector3f[] { new Vector3f(), new Vector3f(), new Vector3f() },
+ new Quaternionf[] { new Quaternionf(), new Quaternionf(), new Quaternionf() },
+ null, null,
+ new LinkedHashMap<>(), new LinkedHashMap<>(),
+ List.of(new Primitive(indices, "m", false, null)),
+ vc, 3
+ );
+
+ SkinnedMesh mesh = assertDoesNotThrow(() ->
+ GltfToSkinnedMesh.convert(data, buildMinimalArmature())
+ );
+ assertNotNull(mesh);
+ // Si on arrive ici sans NaN ni exception, le drop+renorm a marché.
+ // La validation des valeurs exactes passerait par accès aux VertexBuilder
+ // internals (non exposés) — suffit de vérifier non-crash.
+ }
+
+ /**
+ * Vertex avec tous les poids à zéro (GLB bugué ou non-skinné).
+ * Le fallback doit attacher au Root avec poids 1.0 sans crasher.
+ */
+ @Test
+ void convertHandlesZeroWeightVertex() {
+ int vc = 1;
+ GltfData data = new GltfData(
+ new float[] { 0F, 0F, 0F },
+ new float[] { 0F, 1F, 0F },
+ new float[] { 0.5F, 0.5F },
+ new int[] { 0 },
+ new int[] { 0, 1, 2, 0 },
+ new float[] { 0F, 0F, 0F, 0F }, // tous zéro
+ new String[] { "body", "leftUpperArm", "rightUpperArm" },
+ new int[] { -1, 0, 0 },
+ new Matrix4f[] { new Matrix4f().identity(), new Matrix4f().identity(), new Matrix4f().identity() },
+ new Quaternionf[] { new Quaternionf(), new Quaternionf(), new Quaternionf() },
+ new Vector3f[] { new Vector3f(), new Vector3f(), new Vector3f() },
+ new Quaternionf[] { new Quaternionf(), new Quaternionf(), new Quaternionf() },
+ null, null,
+ new LinkedHashMap<>(), new LinkedHashMap<>(),
+ List.of(new Primitive(new int[] { 0 }, "m", false, null)),
+ vc, 3
+ );
+
+ SkinnedMesh mesh = assertDoesNotThrow(() ->
+ GltfToSkinnedMesh.convert(data, buildMinimalArmature())
+ );
+ assertNotNull(mesh);
+ }
+
+ /**
+ * Joint GLB avec nom inconnu (ni alias ni biped natif).
+ * Doit logger WARN et fallback sur Root (id 0), sans crash.
+ */
+ @Test
+ void convertFallsBackToRootForUnknownJointName() {
+ int vc = 1;
+ GltfData data = new GltfData(
+ new float[] { 0F, 0F, 0F },
+ new float[] { 0F, 1F, 0F },
+ new float[] { 0.5F, 0.5F },
+ new int[] { 0 },
+ new int[] { 0, 0, 0, 0 },
+ new float[] { 1F, 0F, 0F, 0F },
+ new String[] { "TentacleJoint42" }, // nom inconnu
+ new int[] { -1 },
+ new Matrix4f[] { new Matrix4f().identity() },
+ new Quaternionf[] { new Quaternionf() },
+ new Vector3f[] { new Vector3f() },
+ new Quaternionf[] { new Quaternionf() },
+ null, null,
+ new LinkedHashMap<>(), new LinkedHashMap<>(),
+ List.of(new Primitive(new int[] { 0 }, "m", false, null)),
+ vc, 1
+ );
+
+ SkinnedMesh mesh = assertDoesNotThrow(() ->
+ GltfToSkinnedMesh.convert(data, buildMinimalArmature())
+ );
+ assertNotNull(mesh);
+ // Le log WARN "unknown joint 'TentacleJoint42' — fallback to Root" doit apparaître ;
+ // cf. TiedUpRigConstants.LOGGER. Non assertable en test sans mock logger.
+ }
}