diff --git a/src/main/java/com/tiedup/remake/rig/bridge/GlbJointAliasTable.java b/src/main/java/com/tiedup/remake/rig/bridge/GlbJointAliasTable.java
new file mode 100644
index 0000000..8e43ae7
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/bridge/GlbJointAliasTable.java
@@ -0,0 +1,119 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.bridge;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+
+import com.google.common.collect.ImmutableMap;
+
+/**
+ * Table d'alias runtime pour mapper les noms de joints des GLB legacy TiedUp
+ * (riggés via PlayerAnimator / bendy-lib avec noms type {@code leftUpperArm})
+ * vers le skeleton biped Epic Fight utilisé par RIG ({@code Arm_L}, etc.).
+ *
+ *
Voir {@code docs/plans/rig/ARCHITECTURE.md §6.3} pour la source de vérité
+ * du mapping. Tout joint inconnu après lookup doit être loggé WARN par le
+ * caller et fallback sur {@code Root}.
+ *
+ * Cas spécial "body/torso" — le GLB legacy a souvent un unique joint
+ * couvrant l'ensemble du torse. On le mappe sur {@code Chest} par défaut
+ * (meilleur fit pour les items bondage majoritairement attachés au haut du
+ * corps : harnais, menottes de poitrine, collier). Si un item a besoin
+ * d'attachement à {@code Torso} (ceinture), le modeler devra renommer son
+ * joint en {@code waist} explicitement.
+ */
+public final class GlbJointAliasTable {
+
+ /**
+ * Mapping direct PlayerAnimator → biped EF. Les clés sont la forme
+ * lowercase EXACTE des noms exportés par les GLB legacy.
+ */
+ private static final Map ALIAS = ImmutableMap.builder()
+ // Torso region
+ .put("body", "Chest")
+ .put("torso", "Chest")
+ .put("chest", "Chest")
+ .put("waist", "Torso")
+ .put("hip", "Torso")
+
+ // Head
+ .put("head", "Head")
+
+ // Arms left
+ .put("leftshoulder", "Shoulder_L")
+ .put("leftupperarm", "Arm_L")
+ .put("leftarm", "Arm_L")
+ .put("leftlowerarm", "Elbow_L")
+ .put("leftforearm", "Elbow_L")
+ .put("leftelbow", "Elbow_L")
+ .put("lefthand", "Hand_L")
+
+ // Arms right
+ .put("rightshoulder", "Shoulder_R")
+ .put("rightupperarm", "Arm_R")
+ .put("rightarm", "Arm_R")
+ .put("rightlowerarm", "Elbow_R")
+ .put("rightforearm", "Elbow_R")
+ .put("rightelbow", "Elbow_R")
+ .put("righthand", "Hand_R")
+
+ // Legs left
+ .put("leftupperleg", "Thigh_L")
+ .put("leftleg", "Thigh_L")
+ .put("leftlowerleg", "Knee_L")
+ .put("leftknee", "Knee_L")
+ .put("leftfoot", "Leg_L")
+
+ // Legs right
+ .put("rightupperleg", "Thigh_R")
+ .put("rightleg", "Thigh_R")
+ .put("rightlowerleg", "Knee_R")
+ .put("rightknee", "Knee_R")
+ .put("rightfoot", "Leg_R")
+
+ // Root fallback (déjà nommé Root dans GLB modernes)
+ .put("root", "Root")
+ .put("armature", "Root")
+ .build();
+
+ private GlbJointAliasTable() {}
+
+ /**
+ * Traduit un nom de joint GLB legacy vers le nom biped EF équivalent.
+ * Case-insensitive. Les noms déjà au format biped EF (ex: {@code Arm_L}) sont
+ * retournés tels quels après vérification.
+ *
+ * @param gltfJointName nom tel qu'exporté dans le GLB (jointNames[])
+ * @return nom biped EF (ex: {@code Arm_L}), ou null si inconnu
+ */
+ @Nullable
+ public static String mapGltfJointName(String gltfJointName) {
+ if (gltfJointName == null || gltfJointName.isEmpty()) {
+ return null;
+ }
+
+ // Direct hit sur le biped EF (GLB moderne déjà bien rigged).
+ if (isBipedJointName(gltfJointName)) {
+ return gltfJointName;
+ }
+
+ return ALIAS.get(gltfJointName.toLowerCase());
+ }
+
+ /**
+ * Vérifie si un nom est déjà au format biped EF. Utilisé pour court-circuiter
+ * l'alias lookup sur les GLB modernes.
+ */
+ public static boolean isBipedJointName(String name) {
+ // Heuristique : les noms biped EF sont en PascalCase avec suffixe _R/_L,
+ // ou parmi {Root, Torso, Chest, Head}.
+ return switch (name) {
+ case "Root", "Torso", "Chest", "Head" -> true;
+ default -> name.endsWith("_R") || name.endsWith("_L");
+ };
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/bridge/GltfToSkinnedMesh.java b/src/main/java/com/tiedup/remake/rig/bridge/GltfToSkinnedMesh.java
new file mode 100644
index 0000000..3aae07f
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/bridge/GltfToSkinnedMesh.java
@@ -0,0 +1,238 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.bridge;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import it.unimi.dsi.fastutil.ints.IntArrayList;
+import it.unimi.dsi.fastutil.ints.IntList;
+
+import com.tiedup.remake.client.gltf.GltfData;
+import com.tiedup.remake.client.gltf.GltfData.Primitive;
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.armature.Armature;
+import com.tiedup.remake.rig.armature.Joint;
+import com.tiedup.remake.rig.asset.AssetAccessor;
+import com.tiedup.remake.rig.math.Vec2f;
+import com.tiedup.remake.rig.math.Vec3f;
+import com.tiedup.remake.rig.mesh.MeshPartDefinition;
+import com.tiedup.remake.rig.mesh.SingleGroupVertexBuilder;
+import com.tiedup.remake.rig.mesh.SkinnedMesh;
+import com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer.VanillaMeshPartDefinition;
+
+/**
+ * Pont Phase 1 : convertit un {@link GltfData} (format GLB legacy TiedUp
+ * riggé 11-joints PlayerAnimator) en {@link SkinnedMesh} Epic Fight
+ * (biped ~20 joints).
+ *
+ * Algorithme (voir {@code docs/plans/rig/MIGRATION.md §1.2.1}) :
+ *
+ * - Pré-calculer le mapping {@code gltfJointIdx → bipedJointId} via
+ * {@link GlbJointAliasTable} + {@link Armature#searchJointByName}.
+ * - Pour chaque vertex :
+ *
+ * - Position / normal / UV depuis {@link GltfData}
+ * - Retenir les 3 joints de plus fort poids parmi les 4 glTF
+ * - Renormaliser les poids retenus pour sommer à 1.0
+ * - Construire le {@link SingleGroupVertexBuilder}
+ *
+ *
+ * - Grouper les indices par {@link Primitive} en autant de
+ * {@link MeshPartDefinition}.
+ * - {@link SingleGroupVertexBuilder#loadVertexInformation(List, Map)}
+ * construit le {@link SkinnedMesh}.
+ *
+ *
+ * Les animations éventuellement embarquées dans le GLB sont ignorées —
+ * les animations passent par JSON EF natif via {@code JsonAssetLoader}.
+ */
+public final class GltfToSkinnedMesh {
+
+ private static final float WEIGHT_EPSILON = 1.0e-4F;
+
+ private GltfToSkinnedMesh() {}
+
+ /**
+ * Convertit un GLB parsé en {@link SkinnedMesh} utilisable par le pipeline
+ * de rendu RIG.
+ *
+ * @param data données GLB parsées par {@code GlbParser.parse(...)}
+ * @param armature armature biped EF cible (doit être déjà chargée)
+ * @return SkinnedMesh prêt à être rendu
+ * @throws IllegalStateException si {@code armature} est null
+ */
+ public static SkinnedMesh convert(GltfData data, AssetAccessor extends Armature> armature) {
+ if (armature == null || armature.get() == null) {
+ throw new IllegalStateException(
+ "Armature not loaded — GltfToSkinnedMesh.convert() called before resource reload completed"
+ );
+ }
+
+ Armature arm = armature.get();
+ int[] jointIdMap = buildJointIdMap(data.jointNames(), arm);
+
+ int vertexCount = data.vertexCount();
+ float[] positions = data.positions();
+ float[] normals = data.normals();
+ float[] texCoords = data.texCoords();
+ int[] joints = data.joints();
+ float[] weights = data.weights();
+
+ List vertices = new ArrayList<>(vertexCount);
+ for (int i = 0; i < vertexCount; i++) {
+ vertices.add(buildVertex(i, positions, normals, texCoords, joints, weights, jointIdMap));
+ }
+
+ Map partIndices = buildPartIndices(data.primitives());
+
+ return SingleGroupVertexBuilder.loadVertexInformation(vertices, partIndices);
+ }
+
+ /**
+ * Construit le mapping {@code gltfJointIdx → bipedJointId} une seule fois
+ * avant la boucle vertex. Les noms inconnus retombent sur la racine
+ * {@code Root} (id 0) avec un log WARN.
+ */
+ private static int[] buildJointIdMap(String[] gltfJointNames, Armature arm) {
+ int[] map = new int[gltfJointNames.length];
+ int unknownCount = 0;
+ int aliasedCount = 0;
+ int rootId = arm.rootJoint != null ? arm.rootJoint.getId() : 0;
+
+ for (int i = 0; i < gltfJointNames.length; i++) {
+ String gltfName = gltfJointNames[i];
+ String bipedName = GlbJointAliasTable.mapGltfJointName(gltfName);
+
+ if (bipedName == null) {
+ TiedUpRigConstants.LOGGER.warn(
+ "GltfToSkinnedMesh: unknown joint '{}' — fallback to Root",
+ gltfName
+ );
+ map[i] = rootId;
+ unknownCount++;
+ continue;
+ }
+
+ Joint joint = arm.searchJointByName(bipedName);
+ if (joint == null) {
+ TiedUpRigConstants.LOGGER.warn(
+ "GltfToSkinnedMesh: biped joint '{}' (aliased from '{}') not found in armature — fallback to Root",
+ bipedName, gltfName
+ );
+ map[i] = rootId;
+ unknownCount++;
+ continue;
+ }
+
+ map[i] = joint.getId();
+ if (!gltfName.equals(bipedName)) {
+ aliasedCount++;
+ }
+ }
+
+ TiedUpRigConstants.LOGGER.info(
+ "GltfToSkinnedMesh: {} joints mapped ({} via alias, {} unknown→Root)",
+ gltfJointNames.length, aliasedCount, unknownCount
+ );
+ return map;
+ }
+
+ /**
+ * Construit un vertex individuel : position/normal/UV depuis les arrays
+ * flattened, puis sélection des 3 plus forts poids (drop du 4e) + renormalisation.
+ */
+ private static SingleGroupVertexBuilder buildVertex(
+ int i,
+ float[] positions, float[] normals, float[] texCoords,
+ int[] joints, float[] weights,
+ int[] jointIdMap) {
+
+ SingleGroupVertexBuilder vb = new SingleGroupVertexBuilder();
+ vb.setPosition(new Vec3f(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]));
+ vb.setNormal(new Vec3f(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]));
+ vb.setTextureCoordinate(new Vec2f(texCoords[i * 2], texCoords[i * 2 + 1]));
+
+ // Récupère les 4 joints/poids glTF, sélectionne les 3 plus forts.
+ int[] rawJoints = new int[4];
+ float[] rawWeights = new float[4];
+ for (int k = 0; k < 4; k++) {
+ rawJoints[k] = joints[i * 4 + k];
+ rawWeights[k] = weights[i * 4 + k];
+ }
+
+ // Trouve l'index du plus faible poids (à drop).
+ int minIdx = 0;
+ for (int k = 1; k < 4; k++) {
+ if (rawWeights[k] < rawWeights[minIdx]) {
+ minIdx = k;
+ }
+ }
+
+ // Build les 3 retenus + compte effective.
+ float w0 = 0, w1 = 0, w2 = 0;
+ int id0 = 0, id1 = 0, id2 = 0;
+ int effectiveCount = 0;
+ int slot = 0;
+ for (int k = 0; k < 4; k++) {
+ if (k == minIdx) continue;
+ float w = rawWeights[k];
+ int id = jointIdMap[rawJoints[k]];
+ switch (slot) {
+ case 0 -> { w0 = w; id0 = id; }
+ case 1 -> { w1 = w; id1 = id; }
+ case 2 -> { w2 = w; id2 = id; }
+ }
+ if (w > WEIGHT_EPSILON) effectiveCount++;
+ slot++;
+ }
+
+ // Renormalise les 3 poids pour qu'ils somment à 1.0.
+ float sum = w0 + w1 + w2;
+ if (sum > WEIGHT_EPSILON) {
+ float inv = 1.0F / sum;
+ w0 *= inv; w1 *= inv; w2 *= inv;
+ } else {
+ // Vertex sans skinning (tout-zéro ou bugué) — attache au Root avec poids 1.
+ w0 = 1.0F; w1 = 0; w2 = 0;
+ id0 = 0; id1 = 0; id2 = 0;
+ effectiveCount = 1;
+ }
+
+ vb.setEffectiveJointIDs(new Vec3f(id0, id1, id2));
+ vb.setEffectiveJointWeights(new Vec3f(w0, w1, w2));
+ vb.setEffectiveJointNumber(Math.max(1, effectiveCount));
+ return vb;
+ }
+
+ /**
+ * Groupe les indices par primitive (= material dans Blender) → une
+ * {@link VanillaMeshPartDefinition} par primitive. Le partName est pris sur
+ * le {@code materialName} si défini, sinon un nom synthétique
+ * {@code "part_N"}.
+ */
+ private static Map buildPartIndices(List primitives) {
+ Map partIndices = new HashMap<>();
+ int fallbackCounter = 0;
+
+ for (Primitive prim : primitives) {
+ String partName = prim.materialName();
+ if (partName == null || partName.isEmpty()) {
+ partName = "part_" + fallbackCounter++;
+ }
+
+ MeshPartDefinition partDef = VanillaMeshPartDefinition.of(partName);
+ IntList indexList = new IntArrayList(prim.indices().length);
+ for (int idx : prim.indices()) {
+ indexList.add(idx);
+ }
+ partIndices.put(partDef, indexList);
+ }
+
+ return partIndices;
+ }
+}