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}) :

+ *
    + *
  1. Pré-calculer le mapping {@code gltfJointIdx → bipedJointId} via + * {@link GlbJointAliasTable} + {@link Armature#searchJointByName}.
  2. + *
  3. 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}
    • + *
    + *
  4. + *
  5. Grouper les indices par {@link Primitive} en autant de + * {@link MeshPartDefinition}.
  6. + *
  7. {@link SingleGroupVertexBuilder#loadVertexInformation(List, Map)} + * construit le {@link SkinnedMesh}.
  8. + *
+ * + *

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 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; + } +}