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"
|
||||
);
|
||||
|
||||
// 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/)
|
||||
// Additive registry — the 6 builtin PoseType enum values remain the
|
||||
// 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.HumanoidArmature;
|
||||
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.math.OpenMatrix4f;
|
||||
|
||||
@@ -251,18 +252,25 @@ public final class TiedUpArmatures {
|
||||
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} rencontre un arg de
|
||||
* 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}
|
||||
* (et son équivalent long-form {@code tiedup:armature/biped}) est reconnu.
|
||||
* Tout autre ID retourne {@code null} — le caller décide du fallback
|
||||
* (InstantiateInvoker warn + BIPED, cf. sa Javadoc).</p>
|
||||
* <p><b>Builtin</b> : seul l'ID canonique {@code tiedup:biped} (et son
|
||||
* équivalent long-form {@code tiedup:armature/biped}) est résolu en dur —
|
||||
* {@link HumanoidArmature} procédurale construite par {@link #buildBiped()}.
|
||||
* 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
|
||||
* registry JSON Blender-authored, ce lookup interrogera le registry map
|
||||
* et supportera les armatures tierces (datapack mods). API conservée
|
||||
* stable pour les call sites InstantiateInvoker + test smoke.</p>
|
||||
* <p><b>Datapack</b> : pour tout autre ID, on delegate au
|
||||
* {@link ArmatureReloadListener} — chargement depuis
|
||||
* {@code data/<ns>/tiedup/armatures/<name>.json} (cf. D6). Permet aux
|
||||
* modders de définir quadruped / centaure / neko / ... sans coder Java.</p>
|
||||
*
|
||||
* @param id l'ID à résoudre (ex. {@code tiedup:biped} ou
|
||||
* {@code tiedup:armature/biped}). Jamais null — caller check.
|
||||
* <p>Retourne {@code null} si l'ID n'est ni le biped builtin ni présent
|
||||
* 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
|
||||
* n'est pas connu du registry. Pas de fallback automatique ici —
|
||||
* 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)) {
|
||||
return BIPED;
|
||||
}
|
||||
Armature datapackArmature = ArmatureReloadListener.get(id);
|
||||
if (datapackArmature != null) {
|
||||
return wrapDatapackArmature(id, datapackArmature);
|
||||
}
|
||||
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(
|
||||
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(
|
||||
"[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