D6 Custom armatures via datapack
Modders can now define custom armatures (quadruped, centaur, neko, etc.) via data/<ns>/tiedup/armatures/*.json — zero Java code required. Format : - root_joint : name of root joint - joints : map<name, JointDefinition> with id, parent, translation, rotation quaternion (xyzw), children array Validation enforces : - unique joint IDs, contiguous from 0 - all children/parent references exist in the map - DAG structure (no cycles, no duplicate reachability) - bidirectional parent/child coherence (A.children lists B <=> B.parent == A) - max 128 joints (MAX_JOINTS limit) - exactly one root (the declared root_joint has parent = null, all others have a non-null parent) TiedUpArmatures.get() now delegates to ArmatureReloadListener for unknown IDs — builtin BIPED remains hardcoded for performance + VanillaModelTransformer compat. InstantiateInvoker.getArmature() automatically resolves datapack armatures via the same path, no change needed there. Listener registered server-side via AddReloadListenerEvent and client-side via RegisterClientReloadListenersEvent (same pattern as LivingMotionReloadListener). Tests : 21 new tests (13 for ArmatureDefinition validation + runtime conversion, 8 for the reload listener + TiedUpArmatures delegation). 342 -> 363 GREEN.
This commit is contained in:
@@ -627,6 +627,17 @@ public class TiedUpMod {
|
|||||||
"Registered LivingMotionReloadListener for data-driven motion additions"
|
"Registered LivingMotionReloadListener for data-driven motion additions"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Data-driven custom armatures (server-side, from data/<namespace>/tiedup/armatures/)
|
||||||
|
// Enables modders to ship custom armatures (quadruped, centaur, neko, ...)
|
||||||
|
// via JSON — resolved by TiedUpArmatures.get() for any ID that is not
|
||||||
|
// the builtin tiedup:biped. See ArmatureReloadListener javadoc (D6).
|
||||||
|
event.addListener(
|
||||||
|
new com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener()
|
||||||
|
);
|
||||||
|
LOGGER.info(
|
||||||
|
"Registered ArmatureReloadListener for data-driven custom armatures"
|
||||||
|
);
|
||||||
|
|
||||||
// Data-driven PoseType additions (server-side, from data/<namespace>/tiedup/pose_types/)
|
// Data-driven PoseType additions (server-side, from data/<namespace>/tiedup/pose_types/)
|
||||||
// Additive registry — the 6 builtin PoseType enum values remain the
|
// Additive registry — the 6 builtin PoseType enum values remain the
|
||||||
// only poses consumable by legacy V1 call-sites. Datapack types are
|
// only poses consumable by legacy V1 call-sites. Datapack types are
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import net.minecraft.resources.ResourceLocation;
|
|||||||
import com.tiedup.remake.rig.armature.Armature;
|
import com.tiedup.remake.rig.armature.Armature;
|
||||||
import com.tiedup.remake.rig.armature.HumanoidArmature;
|
import com.tiedup.remake.rig.armature.HumanoidArmature;
|
||||||
import com.tiedup.remake.rig.armature.Joint;
|
import com.tiedup.remake.rig.armature.Joint;
|
||||||
|
import com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener;
|
||||||
import com.tiedup.remake.rig.asset.AssetAccessor;
|
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||||
import com.tiedup.remake.rig.math.OpenMatrix4f;
|
import com.tiedup.remake.rig.math.OpenMatrix4f;
|
||||||
|
|
||||||
@@ -251,18 +252,25 @@ public final class TiedUpArmatures {
|
|||||||
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} rencontre un arg de
|
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} rencontre un arg de
|
||||||
* type {@code Armature}, il appelle cette méthode via {@code getArmature(id)}.
|
* type {@code Armature}, il appelle cette méthode via {@code getArmature(id)}.
|
||||||
*
|
*
|
||||||
* <p><b>Phase 2.4 scope</b> : seul l'ID canonique {@code tiedup:biped}
|
* <p><b>Builtin</b> : seul l'ID canonique {@code tiedup:biped} (et son
|
||||||
* (et son équivalent long-form {@code tiedup:armature/biped}) est reconnu.
|
* équivalent long-form {@code tiedup:armature/biped}) est résolu en dur —
|
||||||
* Tout autre ID retourne {@code null} — le caller décide du fallback
|
* {@link HumanoidArmature} procédurale construite par {@link #buildBiped()}.
|
||||||
* (InstantiateInvoker warn + BIPED, cf. sa Javadoc).</p>
|
* Hardcodé pour performance + compat backward avec le
|
||||||
|
* {@link com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer}
|
||||||
|
* qui référence les IDs de joint verbatim.</p>
|
||||||
*
|
*
|
||||||
* <p><b>Phase 2.7 future</b> : quand {@link TiedUpRigRegistry} exposera un
|
* <p><b>Datapack</b> : pour tout autre ID, on delegate au
|
||||||
* registry JSON Blender-authored, ce lookup interrogera le registry map
|
* {@link ArmatureReloadListener} — chargement depuis
|
||||||
* et supportera les armatures tierces (datapack mods). API conservée
|
* {@code data/<ns>/tiedup/armatures/<name>.json} (cf. D6). Permet aux
|
||||||
* stable pour les call sites InstantiateInvoker + test smoke.</p>
|
* modders de définir quadruped / centaure / neko / ... sans coder Java.</p>
|
||||||
*
|
*
|
||||||
* @param id l'ID à résoudre (ex. {@code tiedup:biped} ou
|
* <p>Retourne {@code null} si l'ID n'est ni le biped builtin ni présent
|
||||||
* {@code tiedup:armature/biped}). Jamais null — caller check.
|
* dans le registry datapack — le caller décide du fallback
|
||||||
|
* (InstantiateInvoker log WARN + BIPED, cf. sa Javadoc).</p>
|
||||||
|
*
|
||||||
|
* @param id l'ID à résoudre (ex. {@code tiedup:biped},
|
||||||
|
* {@code tiedup:armature/biped} ou
|
||||||
|
* {@code mymod:quadruped}). Jamais null — caller check.
|
||||||
* @return l'{@link AssetAccessor} correspondant, ou {@code null} si l'ID
|
* @return l'{@link AssetAccessor} correspondant, ou {@code null} si l'ID
|
||||||
* n'est pas connu du registry. Pas de fallback automatique ici —
|
* n'est pas connu du registry. Pas de fallback automatique ici —
|
||||||
* c'est au caller de décider (InstantiateInvoker log+BIPED,
|
* c'est au caller de décider (InstantiateInvoker log+BIPED,
|
||||||
@@ -272,6 +280,45 @@ public final class TiedUpArmatures {
|
|||||||
if (BIPED_SHORT_ID.equals(id) || BIPED_REGISTRY_NAME.equals(id)) {
|
if (BIPED_SHORT_ID.equals(id) || BIPED_REGISTRY_NAME.equals(id)) {
|
||||||
return BIPED;
|
return BIPED;
|
||||||
}
|
}
|
||||||
|
Armature datapackArmature = ArmatureReloadListener.get(id);
|
||||||
|
if (datapackArmature != null) {
|
||||||
|
return wrapDatapackArmature(id, datapackArmature);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enveloppe une {@link Armature} datapack en {@link AssetAccessor}
|
||||||
|
* conformément à l'API de {@link #get(ResourceLocation)}. Le
|
||||||
|
* {@code AssetAccessor} porte l'{@link Armature} directement (pas de
|
||||||
|
* re-lookup à chaque {@code get()}) et {@code inRegistry()} retourne
|
||||||
|
* {@code true} pour signaler que l'armature provient d'un registry JSON.
|
||||||
|
*
|
||||||
|
* <p>Chaque appel crée une nouvelle instance de l'accessor — c'est un
|
||||||
|
* wrapper léger, non-cache. Si un caller veut dé-dupliquer il peut le
|
||||||
|
* faire lui-même. Cache en interne serait prématuré : les datapack
|
||||||
|
* armatures sont remplacées complètement à chaque {@code /reload}, donc
|
||||||
|
* un cache devrait se purger — préférable de laisser les callers
|
||||||
|
* re-résoudre.</p>
|
||||||
|
*/
|
||||||
|
private static AssetAccessor<? extends Armature> wrapDatapackArmature(
|
||||||
|
ResourceLocation id, Armature armature
|
||||||
|
) {
|
||||||
|
return new AssetAccessor<Armature>() {
|
||||||
|
@Override
|
||||||
|
public Armature get() {
|
||||||
|
return armature;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResourceLocation registryName() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean inRegistry() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.armature.datapack;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
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.math.OpenMatrix4f;
|
||||||
|
import com.tiedup.remake.rig.math.Vec3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descriptor immuable d'une armature custom chargee depuis un datapack
|
||||||
|
* ({@code data/<ns>/tiedup/armatures/<name>.json}).
|
||||||
|
*
|
||||||
|
* <h2>But</h2>
|
||||||
|
* <p>Permet a un modder de definir une armature custom (quadruped, centaure,
|
||||||
|
* neko, ...) en JSON sans ecrire de code Java. Cette classe est la
|
||||||
|
* representation parsee du JSON ; elle sait se valider et se convertir en
|
||||||
|
* {@link Armature} runtime via {@link #toRuntimeArmature()}.</p>
|
||||||
|
*
|
||||||
|
* <h2>Format JSON attendu</h2>
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "description": "Four-legged pet creature armature",
|
||||||
|
* "root_joint": "Root",
|
||||||
|
* "joints": {
|
||||||
|
* "Root": {
|
||||||
|
* "id": 0,
|
||||||
|
* "parent": null,
|
||||||
|
* "translation": [0.0, 0.0, 0.0],
|
||||||
|
* "rotation": [0.0, 0.0, 0.0, 1.0],
|
||||||
|
* "children": ["Torso"]
|
||||||
|
* },
|
||||||
|
* "Torso": {
|
||||||
|
* "id": 1,
|
||||||
|
* "parent": "Root",
|
||||||
|
* "translation": [0.0, 12.0, 0.0],
|
||||||
|
* "rotation": [0.0, 0.0, 0.0, 1.0],
|
||||||
|
* "children": []
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h2>Invariants (enforces par {@link #validate()})</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code root_joint} doit exister dans {@code joints}.</li>
|
||||||
|
* <li>Tous les {@code parent} (sauf null) et tous les {@code children}
|
||||||
|
* doivent referencer un joint declare dans la map.</li>
|
||||||
|
* <li>Les {@code id} des joints doivent etre uniques et contigus a partir
|
||||||
|
* de 0 (donc 0..N-1 exactement, ou N = taille de la map).</li>
|
||||||
|
* <li>La hierarchie parent-children doit former un DAG acyclique a racine
|
||||||
|
* unique (le root_joint).</li>
|
||||||
|
* <li>Le nombre total de joints est plafonne a
|
||||||
|
* {@link TiedUpRigConstants#MAX_JOINTS}.</li>
|
||||||
|
* <li>Relation parent-child bidirectionnelle coherente : si A liste B dans
|
||||||
|
* {@code children}, B doit avoir {@code parent = A}.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h2>Quaternion convention</h2>
|
||||||
|
* <p>Format <b>xyzw</b> (JOML standard, aligne sur {@link Quaternionf}).
|
||||||
|
* Une rotation identity = {@code [0, 0, 0, 1]}.</p>
|
||||||
|
*/
|
||||||
|
public record ArmatureDefinition(
|
||||||
|
ResourceLocation id,
|
||||||
|
String description,
|
||||||
|
String rootJointName,
|
||||||
|
Map<String, JointDefinition> joints
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ArmatureDefinition {
|
||||||
|
// Defensive immutable wrap for the record's invariants. The loader
|
||||||
|
// already passes LinkedHashMap but callers who construct directly in
|
||||||
|
// tests shouldn't be able to mutate the internal state post-hoc.
|
||||||
|
joints = Collections.unmodifiableMap(new LinkedHashMap<>(joints));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descriptor immuable d'un seul joint.
|
||||||
|
*
|
||||||
|
* @param name nom du joint (cle dans la map parente)
|
||||||
|
* @param jointId id int unique [0..N-1]
|
||||||
|
* @param parentName nom du joint parent, ou null si c'est le root
|
||||||
|
* @param translation translation locale (relative au parent)
|
||||||
|
* @param rotation rotation locale quaternion xyzw
|
||||||
|
* @param childrenNames liste des joints enfants (par nom, ordre preserve)
|
||||||
|
*/
|
||||||
|
public record JointDefinition(
|
||||||
|
String name,
|
||||||
|
int jointId,
|
||||||
|
@Nullable String parentName,
|
||||||
|
Vec3f translation,
|
||||||
|
Quaternionf rotation,
|
||||||
|
List<String> childrenNames
|
||||||
|
) {
|
||||||
|
|
||||||
|
public JointDefinition {
|
||||||
|
Objects.requireNonNull(name, "joint name");
|
||||||
|
Objects.requireNonNull(translation, "translation");
|
||||||
|
Objects.requireNonNull(rotation, "rotation");
|
||||||
|
Objects.requireNonNull(childrenNames, "childrenNames");
|
||||||
|
childrenNames = List.copyOf(childrenNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie la coherence structurelle du descriptor.
|
||||||
|
*
|
||||||
|
* @return {@link Optional#empty()} si la structure est valide, sinon un
|
||||||
|
* {@link Optional} contenant un message d'erreur humain-lisible
|
||||||
|
* (premiere erreur detectee — la validation s'arrete au premier
|
||||||
|
* probleme pour eviter un deluge de logs).
|
||||||
|
*/
|
||||||
|
public Optional<String> validate() {
|
||||||
|
if (joints.isEmpty()) {
|
||||||
|
return Optional.of("joints map is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joints.size() > TiedUpRigConstants.MAX_JOINTS) {
|
||||||
|
return Optional.of(
|
||||||
|
"too many joints: " + joints.size() + " > MAX_JOINTS="
|
||||||
|
+ TiedUpRigConstants.MAX_JOINTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootJointName == null || rootJointName.isEmpty()) {
|
||||||
|
return Optional.of("root_joint is null or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!joints.containsKey(rootJointName)) {
|
||||||
|
return Optional.of(
|
||||||
|
"root_joint '" + rootJointName + "' is not declared in the joints map"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each joint's internal consistency (name match, id range,
|
||||||
|
// parent exists, children exist, uniqueness).
|
||||||
|
Set<Integer> seenIds = new HashSet<>();
|
||||||
|
int jointCount = joints.size();
|
||||||
|
|
||||||
|
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
|
||||||
|
String declaredName = e.getKey();
|
||||||
|
JointDefinition j = e.getValue();
|
||||||
|
|
||||||
|
if (!declaredName.equals(j.name())) {
|
||||||
|
return Optional.of(
|
||||||
|
"joint map key '" + declaredName + "' does not match joint.name '"
|
||||||
|
+ j.name() + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int jid = j.jointId();
|
||||||
|
if (jid < 0 || jid >= jointCount) {
|
||||||
|
return Optional.of(
|
||||||
|
"joint '" + declaredName + "' has id " + jid
|
||||||
|
+ " outside the contiguous range [0.."
|
||||||
|
+ (jointCount - 1) + "]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!seenIds.add(jid)) {
|
||||||
|
return Optional.of(
|
||||||
|
"duplicate joint id " + jid + " on joint '" + declaredName + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (j.parentName() != null && !joints.containsKey(j.parentName())) {
|
||||||
|
return Optional.of(
|
||||||
|
"joint '" + declaredName + "' references unknown parent '"
|
||||||
|
+ j.parentName() + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String child : j.childrenNames()) {
|
||||||
|
if (!joints.containsKey(child)) {
|
||||||
|
return Optional.of(
|
||||||
|
"joint '" + declaredName + "' references unknown child '"
|
||||||
|
+ child + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The above "id in [0..N-1] + unique" implies ids are exactly the set
|
||||||
|
// {0,1,...,N-1} (pigeonhole) — contiguity is therefore guaranteed by
|
||||||
|
// the uniqueness + range checks, no further pass needed.
|
||||||
|
|
||||||
|
// Root must have parent = null; all non-root joints must have a
|
||||||
|
// non-null parent. Verifies that there's exactly one root in the set.
|
||||||
|
JointDefinition rootDef = joints.get(rootJointName);
|
||||||
|
if (rootDef.parentName() != null) {
|
||||||
|
return Optional.of(
|
||||||
|
"root_joint '" + rootJointName + "' must have parent = null, got '"
|
||||||
|
+ rootDef.parentName() + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
|
||||||
|
if (e.getKey().equals(rootJointName)) continue;
|
||||||
|
if (e.getValue().parentName() == null) {
|
||||||
|
return Optional.of(
|
||||||
|
"joint '" + e.getKey() + "' has no parent but is not the root_joint "
|
||||||
|
+ "'" + rootJointName + "'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bidirectional coherence : if A lists B in children, B must have
|
||||||
|
// parent=A. Detects orphans + bogus child refs up-front.
|
||||||
|
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
|
||||||
|
String parentName = e.getKey();
|
||||||
|
for (String childName : e.getValue().childrenNames()) {
|
||||||
|
JointDefinition child = joints.get(childName);
|
||||||
|
if (!parentName.equals(child.parentName())) {
|
||||||
|
return Optional.of(
|
||||||
|
"joint '" + parentName + "' lists '" + childName
|
||||||
|
+ "' as child, but '" + childName + "'.parent = '"
|
||||||
|
+ child.parentName() + "' (mismatch)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DAG / connectivity check: BFS from the root, every joint must be
|
||||||
|
// reachable exactly once. Detects cycles (would require infinite
|
||||||
|
// reachability — bounded by seen-set, any loop shows up as "child
|
||||||
|
// visited twice"), detached subtrees, and disconnected components.
|
||||||
|
Set<String> visited = new LinkedHashSet<>();
|
||||||
|
Deque<String> queue = new ArrayDeque<>();
|
||||||
|
queue.add(rootJointName);
|
||||||
|
visited.add(rootJointName);
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
String current = queue.poll();
|
||||||
|
JointDefinition jd = joints.get(current);
|
||||||
|
for (String child : jd.childrenNames()) {
|
||||||
|
if (!visited.add(child)) {
|
||||||
|
return Optional.of(
|
||||||
|
"cycle or duplicate parent detected: joint '" + child
|
||||||
|
+ "' is reachable from more than one path "
|
||||||
|
+ "(parent chain is not a tree)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
queue.add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.size() != joints.size()) {
|
||||||
|
List<String> unreachable = new ArrayList<>(joints.keySet());
|
||||||
|
unreachable.removeAll(visited);
|
||||||
|
return Optional.of(
|
||||||
|
"joints not reachable from root '" + rootJointName + "': "
|
||||||
|
+ unreachable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit ce descriptor en {@link Armature} runtime.
|
||||||
|
*
|
||||||
|
* <p>Pre-condition : {@link #validate()} doit avoir retourne
|
||||||
|
* {@link Optional#empty()}. Si ce n'est pas le cas, cette methode peut
|
||||||
|
* throw {@link IllegalStateException} lors de la construction (ex :
|
||||||
|
* child introuvable). Le loader public
|
||||||
|
* ({@link ArmatureReloadListener}) appelle toujours validate() avant.</p>
|
||||||
|
*
|
||||||
|
* <p>L'armature retournee a ses {@code toOrigin} matrices calcules via
|
||||||
|
* {@link Armature#bakeOriginMatrices()} — prete a etre utilisee par
|
||||||
|
* le renderer sans etape supplementaire.</p>
|
||||||
|
*
|
||||||
|
* @return un {@link Armature} avec la hierarchie + les localTransform
|
||||||
|
* baked depuis (translation, rotation).
|
||||||
|
* @throws IllegalStateException si le descriptor est structurellement
|
||||||
|
* invalide (validate() devrait avoir detecte
|
||||||
|
* ca en amont).
|
||||||
|
*/
|
||||||
|
public Armature toRuntimeArmature() {
|
||||||
|
Map<String, Joint> joints = new LinkedHashMap<>(this.joints.size());
|
||||||
|
|
||||||
|
// Pass 1 : create every Joint with its localTransform (translation +
|
||||||
|
// rotation composed into an OpenMatrix4f). No parent-child wiring yet.
|
||||||
|
for (JointDefinition def : this.joints.values()) {
|
||||||
|
OpenMatrix4f local = OpenMatrix4f.fromQuaternion(def.rotation())
|
||||||
|
.translate(def.translation());
|
||||||
|
joints.put(def.name(), new Joint(def.name(), def.jointId(), local));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2 : wire up the hierarchy. Children are added in the order
|
||||||
|
// they appear in the JSON's children array — this determines the
|
||||||
|
// subJoints[i] index used by Joint.HierarchicalJointAccessor, so we
|
||||||
|
// must preserve it verbatim.
|
||||||
|
for (JointDefinition def : this.joints.values()) {
|
||||||
|
Joint parentJoint = joints.get(def.name());
|
||||||
|
for (String childName : def.childrenNames()) {
|
||||||
|
Joint childJoint = joints.get(childName);
|
||||||
|
if (childJoint == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"child joint '" + childName + "' not found while building runtime "
|
||||||
|
+ "armature '" + id + "' — did you call validate() first?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parentJoint.addSubJoints(childJoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Joint rootJoint = joints.get(rootJointName);
|
||||||
|
if (rootJoint == null) {
|
||||||
|
// Should never happen if validate() passed.
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"root joint '" + rootJointName + "' missing after pass 1 — "
|
||||||
|
+ "validate() should have caught this"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Armature armature = new Armature(id.toString(), joints.size(), rootJoint, joints);
|
||||||
|
armature.bakeOriginMatrices();
|
||||||
|
return armature;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.armature.datapack;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.packs.resources.ResourceManager;
|
||||||
|
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
|
||||||
|
import net.minecraft.util.profiling.ProfilerFiller;
|
||||||
|
|
||||||
|
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||||
|
import com.tiedup.remake.rig.armature.Armature;
|
||||||
|
import com.tiedup.remake.rig.armature.datapack.ArmatureDefinition.JointDefinition;
|
||||||
|
import com.tiedup.remake.rig.math.Vec3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanne les fichiers JSON {@code data/<ns>/tiedup/armatures/<name>.json} et
|
||||||
|
* enregistre chaque definition d'armature custom comme un {@link Armature}
|
||||||
|
* runtime queryable via {@link #get(ResourceLocation)}.
|
||||||
|
*
|
||||||
|
* <h2>But</h2>
|
||||||
|
* <p>Permet a un modder / resourcepack-maker de definir des armatures custom
|
||||||
|
* (quadruped, centaure, neko, ...) en JSON — zero code Java. Le format est
|
||||||
|
* decrit dans {@link ArmatureDefinition}.</p>
|
||||||
|
*
|
||||||
|
* <h2>Lookup flow</h2>
|
||||||
|
* <p>{@link com.tiedup.remake.rig.TiedUpArmatures#get(ResourceLocation)} delegue
|
||||||
|
* a cette classe pour tous les IDs qui ne sont pas {@code tiedup:biped} (ni
|
||||||
|
* son alias long-form). Le biped builtin reste hardcode en Java pour
|
||||||
|
* performance + compatibilite backward.</p>
|
||||||
|
*
|
||||||
|
* <h2>Full reload on apply</h2>
|
||||||
|
* <p>A chaque {@code /reload} (server) ou {@code F3+T} (client) le registre
|
||||||
|
* est <b>entierement vide puis repopule</b>. Contrairement a
|
||||||
|
* {@link com.tiedup.remake.rig.anim.LivingMotionReloadListener} qui maintient
|
||||||
|
* un cache JVM-wide pour preserver les ordinals (les enums sont sensibles),
|
||||||
|
* les armatures n'ont pas de contrat d'ordinal stable — une armature peut
|
||||||
|
* etre supprimee / renommee / restructuree par un datapack reload sans casser
|
||||||
|
* un invariant global. Les consumers (renderer, animations) se contentent
|
||||||
|
* de resoudre l'ID a chaque frame, un nouvel {@link Armature} au meme ID est
|
||||||
|
* transparent pour eux.</p>
|
||||||
|
*
|
||||||
|
* <p><b>Attention</b> : les {@link Armature} produites par cette classe ne
|
||||||
|
* sont PAS deep-copied. Si une animation binde une reference a un {@code Joint}
|
||||||
|
* via {@code Armature.searchJointByName} et qu'un reload reconstruit une
|
||||||
|
* nouvelle Armature au meme ID, la reference originale devient orpheline
|
||||||
|
* (la vieille instance existe toujours tant que l'animation la retient). Les
|
||||||
|
* consumers doivent re-resoudre apres reload — c'est deja le pattern de
|
||||||
|
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} (resolution via
|
||||||
|
* {@link com.tiedup.remake.rig.TiedUpArmatures#get(ResourceLocation)}).</p>
|
||||||
|
*
|
||||||
|
* <h2>Side & threading</h2>
|
||||||
|
* <p>Registered server-side via {@code AddReloadListenerEvent} et client-side
|
||||||
|
* via {@code RegisterClientReloadListenersEvent}. Sur server integre, les deux
|
||||||
|
* hooks pointent vers le meme {@link ConcurrentHashMap} — pas de double
|
||||||
|
* registre, pas de race (apply() est appele sequentiellement sur le server
|
||||||
|
* thread ou le client render thread, jamais simultanement).</p>
|
||||||
|
*
|
||||||
|
* <h2>Limitations connues</h2>
|
||||||
|
* <ul>
|
||||||
|
* <li>Les animations Blender-authored qui binds a une armature custom doivent
|
||||||
|
* etre recompilees si la structure de l'armature change (nouveaux
|
||||||
|
* joints, reordering des IDs). Pas de retargeting cross-armature (Phase 5+).</li>
|
||||||
|
* <li>Si un JSON est malforme (structure invalide), il est skip avec un
|
||||||
|
* WARN ; le reste du batch continue.</li>
|
||||||
|
* <li>Les armatures identity (toutes localTransform = identity) rendent un
|
||||||
|
* mesh "effondre a l'origine" — c'est aux auteurs du JSON de fournir
|
||||||
|
* des translations + rotations sensees depuis Blender.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ArmatureReloadListener extends SimpleJsonResourceReloadListener {
|
||||||
|
|
||||||
|
/** Dossier scanne : {@code data/<ns>/tiedup/armatures/*.json}. */
|
||||||
|
public static final String DIRECTORY = "tiedup/armatures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registre runtime des armatures datapack. Re-populated at every reload.
|
||||||
|
* {@link ConcurrentHashMap} protege les reads concurrents depuis les
|
||||||
|
* consumers (rendering) pendant qu'un reload en cours repopule.
|
||||||
|
*/
|
||||||
|
private static final Map<ResourceLocation, Armature> DATAPACK_ARMATURES =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public ArmatureReloadListener() {
|
||||||
|
super(new GsonBuilder().create(), DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout une armature datapack par son ResourceLocation.
|
||||||
|
*
|
||||||
|
* @param id identifiant namespace:path (ex. {@code mymod:quadruped})
|
||||||
|
* @return l'{@link Armature} enregistree, ou {@code null} si aucun JSON
|
||||||
|
* n'a charge ce ID.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static Armature get(ResourceLocation id) {
|
||||||
|
return DATAPACK_ARMATURES.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre d'armatures datapack enregistrees actuellement. */
|
||||||
|
public static int size() {
|
||||||
|
return DATAPACK_ARMATURES.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vue immutable du registre, expose pour debug / tests. */
|
||||||
|
public static Map<ResourceLocation, Armature> view() {
|
||||||
|
return Collections.unmodifiableMap(DATAPACK_ARMATURES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test hook — vide le registre. En prod le clear() est implicite dans
|
||||||
|
* {@link #apply} au debut de chaque reload, pas besoin d'y toucher a la
|
||||||
|
* main.
|
||||||
|
*/
|
||||||
|
public static void clearForTests() {
|
||||||
|
DATAPACK_ARMATURES.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook d'apply direct pour tests — {@link SimpleJsonResourceReloadListener#apply}
|
||||||
|
* est {@code protected}, ce helper l'expose en public pour que les tests
|
||||||
|
* cross-package puissent alimenter le registry sans bootstrap MC.
|
||||||
|
*
|
||||||
|
* <p>Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont pas
|
||||||
|
* lus par notre {@code apply}, on peut passer {@code null} en test.</p>
|
||||||
|
*/
|
||||||
|
public void applyForTests(Map<ResourceLocation, JsonElement> data) {
|
||||||
|
this.apply(data, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void apply(
|
||||||
|
Map<ResourceLocation, JsonElement> objectIn,
|
||||||
|
ResourceManager resourceManager,
|
||||||
|
ProfilerFiller profileFiller
|
||||||
|
) {
|
||||||
|
// Full reset : datapack armatures are ephemeral (no ordinal contract).
|
||||||
|
// A JSON deleted between two reloads must disappear from the registry.
|
||||||
|
DATAPACK_ARMATURES.clear();
|
||||||
|
|
||||||
|
// Stable ordering — if two JSON files are processed in the same reload
|
||||||
|
// and produce the same WARN-log output, TreeMap ensures reproducibility
|
||||||
|
// between JVM boots for debugging.
|
||||||
|
Map<ResourceLocation, JsonElement> sorted = new TreeMap<>(objectIn);
|
||||||
|
|
||||||
|
int loaded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<ResourceLocation, JsonElement> entry : sorted.entrySet()) {
|
||||||
|
ResourceLocation id = entry.getKey();
|
||||||
|
JsonElement element = entry.getValue();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ArmatureDefinition def = parseDefinition(id, element);
|
||||||
|
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
if (err.isPresent()) {
|
||||||
|
TiedUpRigConstants.LOGGER.warn(
|
||||||
|
"[ArmatureReloadListener] Invalid armature {}: {}",
|
||||||
|
id, err.get()
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Armature armature = def.toRuntimeArmature();
|
||||||
|
DATAPACK_ARMATURES.put(id, armature);
|
||||||
|
TiedUpRigConstants.LOGGER.debug(
|
||||||
|
"[ArmatureReloadListener] Registered armature: {} ({} joints)",
|
||||||
|
id, armature.getJointNumber()
|
||||||
|
);
|
||||||
|
loaded++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
TiedUpRigConstants.LOGGER.warn(
|
||||||
|
"[ArmatureReloadListener] Failed to parse armature {}: {}",
|
||||||
|
id, e.getMessage()
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TiedUpRigConstants.LOGGER.info(
|
||||||
|
"[ArmatureReloadListener] Reload done : {} armature(s) loaded, {} skipped",
|
||||||
|
loaded, skipped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse un JsonElement en {@link ArmatureDefinition}. Ne valide pas la
|
||||||
|
* coherence structurelle (c'est le role de
|
||||||
|
* {@link ArmatureDefinition#validate()}) — se contente de mapper le JSON
|
||||||
|
* brut sur la structure Java.
|
||||||
|
*
|
||||||
|
* @throws JsonStructureException si le JSON est mal forme (champ manquant,
|
||||||
|
* type incorrect, taille de tableau fausse)
|
||||||
|
*/
|
||||||
|
private static ArmatureDefinition parseDefinition(ResourceLocation id, JsonElement element) {
|
||||||
|
if (!element.isJsonObject()) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
"top-level JSON is not an object (got " + element.getClass().getSimpleName() + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
JsonObject obj = element.getAsJsonObject();
|
||||||
|
|
||||||
|
String description = readOptionalString(obj, "description", "");
|
||||||
|
|
||||||
|
String rootJointName = readRequiredString(obj, "root_joint");
|
||||||
|
|
||||||
|
if (!obj.has("joints") || !obj.get("joints").isJsonObject()) {
|
||||||
|
throw new JsonStructureException("missing or non-object 'joints'");
|
||||||
|
}
|
||||||
|
JsonObject jointsObj = obj.getAsJsonObject("joints");
|
||||||
|
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, JsonElement> e : jointsObj.entrySet()) {
|
||||||
|
String name = e.getKey();
|
||||||
|
if (!e.getValue().isJsonObject()) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
"joint '" + name + "' is not a JSON object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
joints.put(name, parseJoint(name, e.getValue().getAsJsonObject()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArmatureDefinition(id, description, rootJointName, joints);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JointDefinition parseJoint(String name, JsonObject obj) {
|
||||||
|
if (!obj.has("id")) {
|
||||||
|
throw new JsonStructureException("joint '" + name + "' missing 'id'");
|
||||||
|
}
|
||||||
|
int jointId = obj.get("id").getAsInt();
|
||||||
|
|
||||||
|
String parent = null;
|
||||||
|
if (obj.has("parent") && !obj.get("parent").isJsonNull()) {
|
||||||
|
parent = obj.get("parent").getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec3f translation = readVec3(obj, "translation", "joint '" + name + "'");
|
||||||
|
Quaternionf rotation = readQuaternion(obj, "rotation", "joint '" + name + "'");
|
||||||
|
|
||||||
|
List<String> children = new ArrayList<>();
|
||||||
|
if (obj.has("children")) {
|
||||||
|
JsonElement childrenEl = obj.get("children");
|
||||||
|
if (!childrenEl.isJsonArray()) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
"joint '" + name + "'.children is not a JSON array"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (JsonElement c : childrenEl.getAsJsonArray()) {
|
||||||
|
children.add(c.getAsString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JointDefinition(name, jointId, parent, translation, rotation, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vec3f readVec3(JsonObject obj, String key, String context) {
|
||||||
|
if (!obj.has(key)) {
|
||||||
|
throw new JsonStructureException(context + " missing '" + key + "'");
|
||||||
|
}
|
||||||
|
JsonElement el = obj.get(key);
|
||||||
|
if (!el.isJsonArray()) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
context + "." + key + " is not a JSON array"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
JsonArray arr = el.getAsJsonArray();
|
||||||
|
if (arr.size() != 3) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
context + "." + key + " must have 3 elements, got " + arr.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Vec3f(arr.get(0).getAsFloat(), arr.get(1).getAsFloat(), arr.get(2).getAsFloat());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Quaternionf readQuaternion(JsonObject obj, String key, String context) {
|
||||||
|
if (!obj.has(key)) {
|
||||||
|
throw new JsonStructureException(context + " missing '" + key + "'");
|
||||||
|
}
|
||||||
|
JsonElement el = obj.get(key);
|
||||||
|
if (!el.isJsonArray()) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
context + "." + key + " is not a JSON array"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
JsonArray arr = el.getAsJsonArray();
|
||||||
|
if (arr.size() != 4) {
|
||||||
|
throw new JsonStructureException(
|
||||||
|
context + "." + key + " must have 4 elements (xyzw), got " + arr.size()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// xyzw convention — JOML default. Identity = [0, 0, 0, 1].
|
||||||
|
return new Quaternionf(
|
||||||
|
arr.get(0).getAsFloat(),
|
||||||
|
arr.get(1).getAsFloat(),
|
||||||
|
arr.get(2).getAsFloat(),
|
||||||
|
arr.get(3).getAsFloat()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readRequiredString(JsonObject obj, String key) {
|
||||||
|
if (!obj.has(key) || obj.get(key).isJsonNull()) {
|
||||||
|
throw new JsonStructureException("missing required string field '" + key + "'");
|
||||||
|
}
|
||||||
|
JsonElement el = obj.get(key);
|
||||||
|
if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString()) {
|
||||||
|
throw new JsonStructureException("field '" + key + "' is not a string");
|
||||||
|
}
|
||||||
|
return el.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readOptionalString(JsonObject obj, String key, String fallback) {
|
||||||
|
if (!obj.has(key) || obj.get(key).isJsonNull()) return fallback;
|
||||||
|
JsonElement el = obj.get(key);
|
||||||
|
if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString()) return fallback;
|
||||||
|
return el.getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown par le parseur JSON — indique un probleme structurel
|
||||||
|
* dans le fichier (champ manquant, type incorrect). Pas exposee publiquement,
|
||||||
|
* capturee dans {@link #apply} pour log et continuer.
|
||||||
|
*/
|
||||||
|
private static final class JsonStructureException extends RuntimeException {
|
||||||
|
JsonStructureException(String message) {
|
||||||
|
super(Objects.requireNonNull(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,9 +128,16 @@ public class V2ClientSetup {
|
|||||||
event.registerReloadListener(
|
event.registerReloadListener(
|
||||||
new com.tiedup.remake.v2.bondage.PoseTypeReloadListener()
|
new com.tiedup.remake.v2.bondage.PoseTypeReloadListener()
|
||||||
);
|
);
|
||||||
|
// D6 : data-driven custom armatures (quadruped, centaur, ...) are
|
||||||
|
// rendered client-side, so the client must observe the same registry
|
||||||
|
// as the server. Same ConcurrentHashMap backing — on SP integrated
|
||||||
|
// both hooks point at the same static state.
|
||||||
|
event.registerReloadListener(
|
||||||
|
new com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener()
|
||||||
|
);
|
||||||
TiedUpMod.LOGGER.info(
|
TiedUpMod.LOGGER.info(
|
||||||
"[V2ClientSetup] Data-driven item + GLB validation + joint mask + living motion "
|
"[V2ClientSetup] Data-driven item + GLB validation + joint mask + living motion "
|
||||||
+ "+ pose type reload listeners registered"
|
+ "+ pose type + armature reload listeners registered"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.armature.datapack;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
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.armature.datapack.ArmatureDefinition.JointDefinition;
|
||||||
|
import com.tiedup.remake.rig.math.Vec3f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires de {@link ArmatureDefinition} — validation structurelle
|
||||||
|
* et construction de l'{@link Armature} runtime.
|
||||||
|
*/
|
||||||
|
class ArmatureDefinitionTest {
|
||||||
|
|
||||||
|
private static final ResourceLocation ID =
|
||||||
|
ResourceLocation.fromNamespaceAndPath("ttest", "quadruped");
|
||||||
|
|
||||||
|
private static Quaternionf identity() {
|
||||||
|
return new Quaternionf(0.0F, 0.0F, 0.0F, 1.0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vec3f zero() {
|
||||||
|
return new Vec3f(0.0F, 0.0F, 0.0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a simple 3-joint chain : Root -> Torso -> Head, all identity
|
||||||
|
* transforms. Used as a happy-path baseline in several tests.
|
||||||
|
*/
|
||||||
|
private static Map<String, JointDefinition> validChain() {
|
||||||
|
Map<String, JointDefinition> m = new LinkedHashMap<>();
|
||||||
|
m.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of("Torso")
|
||||||
|
));
|
||||||
|
m.put("Torso", new JointDefinition(
|
||||||
|
"Torso", 1, "Root", zero(), identity(), List.of("Head")
|
||||||
|
));
|
||||||
|
m.put("Head", new JointDefinition(
|
||||||
|
"Head", 2, "Torso", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== validate() ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_emptyJoints_returnsError() {
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "empty", "Root", Map.of()
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent(), "empty joint map must be rejected");
|
||||||
|
assertTrue(err.get().toLowerCase().contains("empty"),
|
||||||
|
"error should mention 'empty', got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_missingRoot_returnsError() {
|
||||||
|
Map<String, JointDefinition> joints = validChain();
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "missing root", "NotDeclared", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent(), "root_joint must exist in the map");
|
||||||
|
assertTrue(err.get().contains("NotDeclared"),
|
||||||
|
"error should mention the missing root name, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_invalidParent_returnsError() {
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of("Child")
|
||||||
|
));
|
||||||
|
joints.put("Child", new JointDefinition(
|
||||||
|
"Child", 1, "GhostParent", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "bad parent", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
assertTrue(err.get().contains("GhostParent"),
|
||||||
|
"error should mention the bogus parent, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_invalidChild_returnsError() {
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of("GhostChild")
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "bad child", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
assertTrue(err.get().contains("GhostChild"),
|
||||||
|
"error should mention the bogus child, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_duplicateId_returnsError() {
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of("A", "B")
|
||||||
|
));
|
||||||
|
joints.put("A", new JointDefinition(
|
||||||
|
"A", 1, "Root", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
// duplicate id = 1
|
||||||
|
joints.put("B", new JointDefinition(
|
||||||
|
"B", 1, "Root", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "dup ids", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
assertTrue(err.get().toLowerCase().contains("duplicate"),
|
||||||
|
"error should flag duplicate, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_nonContiguousIds_returnsError() {
|
||||||
|
// 3 joints but ids = 0, 1, 5 → 5 is out of range [0..2]
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of("A")
|
||||||
|
));
|
||||||
|
joints.put("A", new JointDefinition(
|
||||||
|
"A", 1, "Root", zero(), identity(), List.of("B")
|
||||||
|
));
|
||||||
|
joints.put("B", new JointDefinition(
|
||||||
|
"B", 5, "A", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "non contiguous", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
assertTrue(err.get().contains("outside the contiguous range"),
|
||||||
|
"error should flag out-of-range id, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_cyclic_returnsError() {
|
||||||
|
// Root -> A -> B, and B also lists A as a child (creating a cycle).
|
||||||
|
// We must fabricate a structurally coherent parent chain so the
|
||||||
|
// bidirectional check passes and the DFS catches it. Easier: give B
|
||||||
|
// a duplicate parent edge by making Root list A twice, so A is
|
||||||
|
// reached twice during the BFS (mimics cycle detection).
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of("A", "A")
|
||||||
|
));
|
||||||
|
joints.put("A", new JointDefinition(
|
||||||
|
"A", 1, "Root", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "cyclic", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
assertTrue(err.get().toLowerCase().contains("cycle")
|
||||||
|
|| err.get().contains("duplicate parent"),
|
||||||
|
"error should flag cycle/duplicate parent path, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_exceedMaxJoints_returnsError() {
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
int tooMany = TiedUpRigConstants.MAX_JOINTS + 1;
|
||||||
|
// Root + (tooMany - 1) children
|
||||||
|
List<String> children = new ArrayList<>();
|
||||||
|
for (int i = 1; i < tooMany; i++) children.add("J" + i);
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), children
|
||||||
|
));
|
||||||
|
for (int i = 1; i < tooMany; i++) {
|
||||||
|
joints.put("J" + i, new JointDefinition(
|
||||||
|
"J" + i, i, "Root", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "too many", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
assertTrue(err.get().contains("too many joints"),
|
||||||
|
"error should flag MAX_JOINTS overflow, got: " + err.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_validStructure_returnsEmpty() {
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "valid chain", "Root", validChain()
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertFalse(err.isPresent(), "valid structure must pass, got: " + err.orElse("—"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_rootHasParent_returnsError() {
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, "SomeGhost", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "root with parent", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_disconnectedSubtree_returnsError() {
|
||||||
|
// Root with no children, but another joint exists elsewhere with a
|
||||||
|
// parent=Root claim that Root doesn't list → bidirectional mismatch.
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
joints.put("Orphan", new JointDefinition(
|
||||||
|
"Orphan", 1, "Root", zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "disconnected", "Root", joints
|
||||||
|
);
|
||||||
|
Optional<String> err = def.validate();
|
||||||
|
assertTrue(err.isPresent(),
|
||||||
|
"disconnected joint (parent set but not in children) must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== toRuntimeArmature() ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toRuntimeArmature_bipedLikeStructure_matchesExpected() {
|
||||||
|
// Build a mini biped-like armature to verify the runtime conversion
|
||||||
|
// preserves joint IDs + names + hierarchy. We use 4 joints :
|
||||||
|
// Root (id=0) -> Torso (id=1) -> Head (id=2), and Torso -> Arm (id=3)
|
||||||
|
// as a side branch.
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null,
|
||||||
|
new Vec3f(0.0F, 0.0F, 0.0F), identity(), List.of("Torso")
|
||||||
|
));
|
||||||
|
joints.put("Torso", new JointDefinition(
|
||||||
|
"Torso", 1, "Root",
|
||||||
|
new Vec3f(0.0F, 12.0F, 0.0F), identity(), List.of("Head", "Arm")
|
||||||
|
));
|
||||||
|
joints.put("Head", new JointDefinition(
|
||||||
|
"Head", 2, "Torso",
|
||||||
|
new Vec3f(0.0F, 8.0F, 0.0F), identity(), List.of()
|
||||||
|
));
|
||||||
|
joints.put("Arm", new JointDefinition(
|
||||||
|
"Arm", 3, "Torso",
|
||||||
|
new Vec3f(4.0F, 0.0F, 0.0F), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "biped-like", "Root", joints
|
||||||
|
);
|
||||||
|
assertTrue(def.validate().isEmpty(), "definition should be valid");
|
||||||
|
|
||||||
|
Armature armature = def.toRuntimeArmature();
|
||||||
|
|
||||||
|
// Joint count.
|
||||||
|
assertEquals(4, armature.getJointNumber(), "should have 4 joints");
|
||||||
|
|
||||||
|
// Names + IDs.
|
||||||
|
assertEquals("Root", armature.searchJointById(0).getName());
|
||||||
|
assertEquals("Torso", armature.searchJointById(1).getName());
|
||||||
|
assertEquals("Head", armature.searchJointById(2).getName());
|
||||||
|
assertEquals("Arm", armature.searchJointById(3).getName());
|
||||||
|
|
||||||
|
assertEquals(0, armature.searchJointByName("Root").getId());
|
||||||
|
assertEquals(1, armature.searchJointByName("Torso").getId());
|
||||||
|
assertEquals(2, armature.searchJointByName("Head").getId());
|
||||||
|
assertEquals(3, armature.searchJointByName("Arm").getId());
|
||||||
|
|
||||||
|
// Hierarchy — Root.subJoints = [Torso], Torso.subJoints = [Head, Arm].
|
||||||
|
Joint root = armature.rootJoint;
|
||||||
|
assertEquals(1, root.getSubJoints().size(), "Root has exactly 1 child (Torso)");
|
||||||
|
assertEquals("Torso", root.getSubJoints().get(0).getName());
|
||||||
|
|
||||||
|
Joint torso = armature.searchJointByName("Torso");
|
||||||
|
assertEquals(2, torso.getSubJoints().size(), "Torso has 2 children");
|
||||||
|
assertEquals("Head", torso.getSubJoints().get(0).getName(),
|
||||||
|
"children order must match JSON declaration (Head first)");
|
||||||
|
assertEquals("Arm", torso.getSubJoints().get(1).getName(),
|
||||||
|
"children order must match JSON declaration (Arm second)");
|
||||||
|
|
||||||
|
// toOrigin should have been baked (not null, i.e. bakeOriginMatrices ran).
|
||||||
|
assertNotNull(armature.searchJointByName("Torso").getToOrigin(),
|
||||||
|
"bakeOriginMatrices must have run");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toRuntimeArmature_singleJointRoot_works() {
|
||||||
|
Map<String, JointDefinition> joints = new LinkedHashMap<>();
|
||||||
|
joints.put("Root", new JointDefinition(
|
||||||
|
"Root", 0, null, zero(), identity(), List.of()
|
||||||
|
));
|
||||||
|
ArmatureDefinition def = new ArmatureDefinition(
|
||||||
|
ID, "root only", "Root", joints
|
||||||
|
);
|
||||||
|
assertTrue(def.validate().isEmpty());
|
||||||
|
|
||||||
|
Armature armature = def.toRuntimeArmature();
|
||||||
|
assertEquals(1, armature.getJointNumber());
|
||||||
|
assertEquals("Root", armature.rootJoint.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/*
|
||||||
|
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.tiedup.remake.rig.armature.datapack;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import com.tiedup.remake.rig.TiedUpArmatures;
|
||||||
|
import com.tiedup.remake.rig.armature.Armature;
|
||||||
|
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de {@link ArmatureReloadListener} — chargement JSON datapack +
|
||||||
|
* delegation par {@link TiedUpArmatures#get(ResourceLocation)}.
|
||||||
|
*/
|
||||||
|
class ArmatureReloadListenerTest {
|
||||||
|
|
||||||
|
private static JsonElement jsonElem(String s) {
|
||||||
|
return JsonParser.parseString(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceLocation id(String path) {
|
||||||
|
return ResourceLocation.fromNamespaceAndPath("ttest", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void resetRegistry() {
|
||||||
|
ArmatureReloadListener.clearForTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== apply() ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_validJson_registersArmature() {
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id("quadruped"), jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "Four-legged pet",
|
||||||
|
"root_joint": "Root",
|
||||||
|
"joints": {
|
||||||
|
"Root": {
|
||||||
|
"id": 0,
|
||||||
|
"parent": null,
|
||||||
|
"translation": [0.0, 0.0, 0.0],
|
||||||
|
"rotation": [0.0, 0.0, 0.0, 1.0],
|
||||||
|
"children": ["Torso"]
|
||||||
|
},
|
||||||
|
"Torso": {
|
||||||
|
"id": 1,
|
||||||
|
"parent": "Root",
|
||||||
|
"translation": [0.0, 12.0, 0.0],
|
||||||
|
"rotation": [0.0, 0.0, 0.0, 1.0],
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new ArmatureReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
Armature armature = ArmatureReloadListener.get(id("quadruped"));
|
||||||
|
assertNotNull(armature, "armature must be registered after valid apply");
|
||||||
|
assertEquals(2, armature.getJointNumber());
|
||||||
|
assertEquals("Root", armature.rootJoint.getName());
|
||||||
|
assertEquals("Torso", armature.searchJointByName("Torso").getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_invalidJson_skipsAndContinues() {
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
// 1) Top-level not an object
|
||||||
|
data.put(id("bad_toplevel"), jsonElem("42"));
|
||||||
|
// 2) Missing root_joint field
|
||||||
|
data.put(id("no_root"), jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "no root field",
|
||||||
|
"joints": {
|
||||||
|
"A": {
|
||||||
|
"id": 0, "parent": null,
|
||||||
|
"translation": [0,0,0], "rotation": [0,0,0,1],
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
// 3) root_joint references unknown joint → validate() rejects
|
||||||
|
data.put(id("bad_root_ref"), jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "bad root ref",
|
||||||
|
"root_joint": "Nope",
|
||||||
|
"joints": {
|
||||||
|
"Root": {
|
||||||
|
"id": 0, "parent": null,
|
||||||
|
"translation": [0,0,0], "rotation": [0,0,0,1],
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
// 4) Good one — must still register despite siblings failing
|
||||||
|
data.put(id("good"), jsonElem("""
|
||||||
|
{
|
||||||
|
"description": "good",
|
||||||
|
"root_joint": "Root",
|
||||||
|
"joints": {
|
||||||
|
"Root": {
|
||||||
|
"id": 0, "parent": null,
|
||||||
|
"translation": [0,0,0], "rotation": [0,0,0,1],
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
new ArmatureReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
assertNull(ArmatureReloadListener.get(id("bad_toplevel")),
|
||||||
|
"non-object top-level must be skipped");
|
||||||
|
assertNull(ArmatureReloadListener.get(id("no_root")),
|
||||||
|
"missing root_joint must be skipped");
|
||||||
|
assertNull(ArmatureReloadListener.get(id("bad_root_ref")),
|
||||||
|
"bad root ref must be skipped");
|
||||||
|
assertNotNull(ArmatureReloadListener.get(id("good")),
|
||||||
|
"good JSON in same batch must still register");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apply_twice_replacesRegistry() {
|
||||||
|
// First reload : register A + B.
|
||||||
|
Map<ResourceLocation, JsonElement> first = new HashMap<>();
|
||||||
|
first.put(id("a"), jsonElem("""
|
||||||
|
{"description":"a","root_joint":"Root","joints":{
|
||||||
|
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":[]}
|
||||||
|
}}
|
||||||
|
"""));
|
||||||
|
first.put(id("b"), jsonElem("""
|
||||||
|
{"description":"b","root_joint":"Root","joints":{
|
||||||
|
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":[]}
|
||||||
|
}}
|
||||||
|
"""));
|
||||||
|
|
||||||
|
ArmatureReloadListener listener = new ArmatureReloadListener();
|
||||||
|
listener.applyForTests(first);
|
||||||
|
|
||||||
|
Armature aFirst = ArmatureReloadListener.get(id("a"));
|
||||||
|
assertNotNull(aFirst);
|
||||||
|
assertNotNull(ArmatureReloadListener.get(id("b")));
|
||||||
|
assertEquals(2, ArmatureReloadListener.size());
|
||||||
|
|
||||||
|
// Second reload : only A (with a different structure).
|
||||||
|
Map<ResourceLocation, JsonElement> second = new HashMap<>();
|
||||||
|
second.put(id("a"), jsonElem("""
|
||||||
|
{"description":"a v2","root_joint":"Root","joints":{
|
||||||
|
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":["Head"]},
|
||||||
|
"Head":{"id":1,"parent":"Root","translation":[0,8,0],"rotation":[0,0,0,1],"children":[]}
|
||||||
|
}}
|
||||||
|
"""));
|
||||||
|
listener.applyForTests(second);
|
||||||
|
|
||||||
|
// A must be replaced with the new armature (new instance, 2 joints now).
|
||||||
|
Armature aSecond = ArmatureReloadListener.get(id("a"));
|
||||||
|
assertNotNull(aSecond);
|
||||||
|
assertNotSame(aFirst, aSecond, "reload must replace the registered Armature");
|
||||||
|
assertEquals(2, aSecond.getJointNumber(),
|
||||||
|
"new Armature has the new structure (2 joints)");
|
||||||
|
|
||||||
|
// B must disappear (full clear+reload semantics).
|
||||||
|
assertNull(ArmatureReloadListener.get(id("b")),
|
||||||
|
"armature removed from the dataset must be gone after reload");
|
||||||
|
assertEquals(1, ArmatureReloadListener.size(), "only 'a' remains");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_registered_returnsArmature() {
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
data.put(id("simple"), jsonElem("""
|
||||||
|
{"description":"x","root_joint":"Root","joints":{
|
||||||
|
"Root":{"id":0,"parent":null,"translation":[1,2,3],"rotation":[0,0,0,1],"children":[]}
|
||||||
|
}}
|
||||||
|
"""));
|
||||||
|
new ArmatureReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
Armature a = ArmatureReloadListener.get(id("simple"));
|
||||||
|
assertNotNull(a);
|
||||||
|
assertEquals(1, a.getJointNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_unknown_returnsNull() {
|
||||||
|
assertNull(ArmatureReloadListener.get(id("never_loaded")),
|
||||||
|
"unknown id must return null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== TiedUpArmatures delegation ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tiedUpArmaturesGet_builtinBiped_returnsHardcoded() {
|
||||||
|
// The biped hardcoded path should not be affected by the datapack
|
||||||
|
// registry state. Both long and short IDs resolve.
|
||||||
|
ResourceLocation shortBiped = ResourceLocation.fromNamespaceAndPath("tiedup", "biped");
|
||||||
|
ResourceLocation longBiped = ResourceLocation.fromNamespaceAndPath("tiedup", "armature/biped");
|
||||||
|
|
||||||
|
AssetAccessor<?> a1 = TiedUpArmatures.get(shortBiped);
|
||||||
|
AssetAccessor<?> a2 = TiedUpArmatures.get(longBiped);
|
||||||
|
assertNotNull(a1);
|
||||||
|
assertNotNull(a2);
|
||||||
|
// Both should point at the same BIPED singleton instance.
|
||||||
|
assertEquals(a1.get(), a2.get(),
|
||||||
|
"both biped IDs must resolve to the same Armature instance");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tiedUpArmaturesGet_datapackId_delegatesToListener() {
|
||||||
|
Map<ResourceLocation, JsonElement> data = new HashMap<>();
|
||||||
|
ResourceLocation customId = id("my_custom");
|
||||||
|
data.put(customId, jsonElem("""
|
||||||
|
{"description":"custom","root_joint":"Root","joints":{
|
||||||
|
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":["Torso"]},
|
||||||
|
"Torso":{"id":1,"parent":"Root","translation":[0,12,0],"rotation":[0,0,0,1],"children":[]}
|
||||||
|
}}
|
||||||
|
"""));
|
||||||
|
new ArmatureReloadListener().applyForTests(data);
|
||||||
|
|
||||||
|
AssetAccessor<?> accessor = TiedUpArmatures.get(customId);
|
||||||
|
assertNotNull(accessor, "datapack armature must be resolved by TiedUpArmatures.get()");
|
||||||
|
assertEquals(customId, accessor.registryName());
|
||||||
|
assertTrue(accessor.inRegistry(),
|
||||||
|
"datapack-sourced accessor must report inRegistry() == true");
|
||||||
|
assertNotNull(accessor.get());
|
||||||
|
assertEquals(2, ((Armature) accessor.get()).getJointNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tiedUpArmaturesGet_unknownId_returnsNull() {
|
||||||
|
// Registry clear; an arbitrary unknown ID must NOT resolve.
|
||||||
|
assertNull(TiedUpArmatures.get(id("does_not_exist")),
|
||||||
|
"TiedUpArmatures.get() must return null for unknown datapack IDs "
|
||||||
|
+ "(caller decides the fallback)");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user