diff --git a/Assets/Scripts/FingerIK.cs b/Assets/Scripts/FingerIK.cs
new file mode 100644
index 0000000..0ba033c
--- /dev/null
+++ b/Assets/Scripts/FingerIK.cs
@@ -0,0 +1,421 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+///
+/// Single-finger procedural pose + IK solver for a LOCAL +Y bone chain. Flexion (curl)
+/// is rotation around each joint's local hinge axis (default local -X, the palm-ward
+/// flex direction for the WhiteHand rig); negative per-joint limits allow a little
+/// back-of-hand (upward) bend when an IK target pulls there. Spread/abduction is around
+/// local Z on the base joint only and is also IK-solved toward the target for slight
+/// sideways reach. Works for any chain length: the thumb (2 rotating joints) and the long
+/// fingers (3) use identical code.
+///
+/// Two drive modes, blended per frame:
+/// 1. Procedural curl. (0..1) lerps every joint from its rest angle
+/// (0) toward its per-joint (closed fist). This is written first
+/// every frame and is the SEED pose for the IK pass.
+/// 2. IK reach. When a is assigned and > 0,
+/// CCD bends the chain (seeded from the curl pose) so the fingertip reaches the target,
+/// constraining every joint to its hinge axis and angle limits mid-sweep. The final
+/// per-joint pose is Slerp(seedCurl, solvedIK, ikWeight) so curl and IK never fight and
+/// nothing accumulates frame-to-frame.
+///
+/// All curl math is REST-RELATIVE: the captured rest localRotation is the 0-degree datum,
+/// so the rig's modeled fan/opposition offsets (e.g. thumb's ~55 deg local-Z rest) are
+/// preserved instead of being snapped to identity. Positive curl closes the fist for every
+/// digit on this rig.
+///
+/// Added at runtime onto the finger-root GameObject by , or wired by
+/// hand via . By default the owning drives the
+/// solve once per frame; set if using this component alone.
+///
+[ExecuteAlways]
+[DisallowMultipleComponent]
+public class FingerIK : MonoBehaviour
+{
+ [Header("Chain")]
+ [Tooltip("Rotating joints, root -> tip (EXCLUDES the *_end leaf). Auto-filled by HandIK.Setup.")]
+ [SerializeField] private List joints = new List();
+
+ [Tooltip("Fingertip transform (the *_end leaf). Has no joint of its own; CCD reaches it to the target.")]
+ [SerializeField] private Transform tip;
+
+ [Tooltip("Optional world-space goal. When set (and ikWeight > 0) CCD reaches the tip to it; otherwise curl drives the pose. HandIK's matching Finger Target slot overrides this when assigned; an empty HandIK slot leaves a target set here intact.")]
+ [SerializeField] private Transform target;
+
+ [Header("Solver")]
+ [Tooltip("CCD sweeps per Solve. 8-12 is plenty for short finger chains.")]
+ [Range(1, 16)] [SerializeField] private int iterations = 10;
+
+ [Tooltip("Stop early once the tip is within this distance (world units) of the target.")]
+ [SerializeField] private float tolerance = 0.001f;
+
+ [Tooltip("0 = pure curl pose (no IK), 1 = full IK reach. Blends the solved pose against the curl seed.")]
+ [Range(0f, 1f)] [SerializeField] private float ikWeight = 1f;
+
+ [Header("Curl")]
+ [Tooltip("0 = open (rest), 1 = fully curled (each joint at its maxCurl). Drives the pose and seeds the IK.")]
+ [Range(0f, 1f)] [SerializeField] private float curl;
+
+ [Tooltip("Curl bias per joint depth so distal joints curl more than the base for a natural fist (1 = uniform).")]
+ [SerializeField] private AnimationCurve curlProfile = AnimationCurve.Linear(0f, 1f, 1f, 1f);
+
+ [Tooltip("Spread/abduction around local Z (degrees), applied to the base joint only. For the thumb this is opposition.")]
+ [SerializeField] private float spread;
+
+ [Tooltip("Spread clamp (degrees), [min, max], applied to the base joint's spread/opposition DOF.")]
+ [SerializeField] private Vector2 spreadLimits = new Vector2(-12f, 12f);
+
+ [Header("Per-joint limits (degrees, rest-relative; + = curl toward palm)")]
+ [Tooltip("Local hinge (flexion) axis per joint. Default local -X (-1,0,0) = palm-ward flex on this rig. joints[i] uses hingeAxes[i]; short arrays reuse the last entry.")]
+ [SerializeField] private Vector3[] hingeAxes;
+
+ [Tooltip("Min curl angle per joint. 0 = open hand. Negative allows hyperextension.")]
+ [SerializeField] private float[] minCurl;
+
+ [Tooltip("Max curl angle per joint (closed fist).")]
+ [SerializeField] private float[] maxCurl;
+
+ [Header("Execution")]
+ [Tooltip("If true, this component solves itself in LateUpdate. Leave OFF when a HandIK manager drives the solve (avoids double-solving).")]
+ [SerializeField] private bool solveInLateUpdate;
+
+ // Rest localRotation per joint, captured before the first Solve mutates the bones.
+ // Serialized so an edit-mode capture survives domain reload; do NOT re-capture in a
+ // curled pose or the rest datum is poisoned.
+ [SerializeField, HideInInspector] private Quaternion[] restRotations;
+
+ // Per-frame scratch: the curl seed and the solved local rotations, so the final blend
+ // uses seed-vs-solved cleanly instead of blending in place.
+ private Quaternion[] _seedLocal;
+ private Quaternion[] _solvedLocal;
+
+ private static readonly Vector3 SpreadAxis = Vector3.forward; // local Z
+
+ public int JointCount => joints != null ? joints.Count : 0;
+ public Transform Tip => tip;
+ public Transform Target => target;
+ public float Curl => curl;
+ public float IkWeight => ikWeight;
+ public float Spread => spread;
+
+ public bool SolveInLateUpdate
+ {
+ get => solveInLateUpdate;
+ set => solveInLateUpdate = value;
+ }
+
+ private void Awake()
+ {
+ if (HasChain() && (restRotations == null || restRotations.Length != joints.Count))
+ CaptureRest();
+ EnsureScratch();
+ }
+
+ private void LateUpdate()
+ {
+ if (Application.isPlaying && solveInLateUpdate) Solve();
+ }
+
+#if UNITY_EDITOR
+ // Standalone edit-mode drive: when this finger has no HandIK owner (solveInLateUpdate ON),
+ // tick the solve from the editor loop so curl/target edits show live without entering Play.
+ // Managed fingers (HandIK sets solveInLateUpdate OFF) make this a no-op -> no double-solve.
+ private void OnEnable()
+ {
+ if (Application.isPlaying) return;
+ UnityEditor.EditorApplication.update -= EditorTick;
+ UnityEditor.EditorApplication.update += EditorTick;
+ }
+
+ private void OnDisable() => UnityEditor.EditorApplication.update -= EditorTick;
+
+ private void EditorTick()
+ {
+ if (Application.isPlaying) return;
+ if (!this) { UnityEditor.EditorApplication.update -= EditorTick; return; }
+ if (solveInLateUpdate) Solve();
+ }
+#endif
+
+ ///
+ /// Wires the chain (rotating joints root..tip, plus the *_end tip leaf), sizes the
+ /// per-joint limit arrays to sensible defaults, and captures the rest pose. Called by
+ /// HandIK or by hand. Capturing rest here (right after the joints are assigned, before
+ /// any Solve) keeps the rest datum at the modeled bind pose.
+ ///
+ public void Setup(IList chainJoints, Transform tipLeaf)
+ {
+ joints = chainJoints != null ? new List(chainJoints) : new List();
+ tip = tipLeaf;
+ EnsureLimitArrays();
+ // Capture rest ONLY when missing/stale (chain length changed). Re-capturing during a
+ // re-discovery of an already-posed hand would record a curled pose as the 0-degree
+ // datum and permanently poison every curl. Use ResetToRest first, then re-capture.
+ if (restRotations == null || restRotations.Length != joints.Count) CaptureRest();
+ EnsureScratch();
+ }
+
+ /// Overrides the per-joint hinge limits (degrees, rest-relative). Lengths are clamped/extended to the joint count.
+ public void SetLimits(float[] mins, float[] maxes)
+ {
+ EnsureLimitArrays();
+ if (mins != null)
+ for (int i = 0; i < minCurl.Length; i++) minCurl[i] = mins[Mathf.Min(i, mins.Length - 1)];
+ if (maxes != null)
+ for (int i = 0; i < maxCurl.Length; i++) maxCurl[i] = maxes[Mathf.Min(i, maxes.Length - 1)];
+ }
+
+ ///
+ /// Overrides the per-joint local hinge (flexion) axis. Sized to the joint count; a short
+ /// array reuses its last entry. Lets HandIK push the rig's curl direction centrally so a
+ /// re-discovery corrects any stale serialized axis on existing FingerIK components.
+ ///
+ public void SetHingeAxes(Vector3[] axes)
+ {
+ EnsureLimitArrays();
+ if (axes == null || axes.Length == 0) return;
+ for (int i = 0; i < hingeAxes.Length; i++)
+ {
+ Vector3 a = axes[Mathf.Min(i, axes.Length - 1)];
+ hingeAxes[i] = a == Vector3.zero ? Vector3.left : a.normalized;
+ }
+ }
+
+ /// Sets the base-joint spread/opposition clamp (degrees).
+ public void SetSpreadLimits(float min, float max) => spreadLimits = new Vector2(min, max);
+
+ /// Captures each joint's current localRotation as the rest reference (0-degree curl datum).
+ public void CaptureRest()
+ {
+ if (!HasChain()) return;
+ restRotations = new Quaternion[joints.Count];
+ for (int i = 0; i < joints.Count; i++)
+ restRotations[i] = joints[i] ? joints[i].localRotation : Quaternion.identity;
+ }
+
+ public void SetTarget(Transform t) => target = t;
+ public void SetCurl(float value) => curl = Mathf.Clamp01(value);
+ public void SetIkWeight(float value) => ikWeight = Mathf.Clamp01(value);
+ public void SetIterations(int value) => iterations = Mathf.Clamp(value, 1, 16);
+ public void SetSpread(float degrees) => spread = Mathf.Clamp(degrees, spreadLimits.x, spreadLimits.y);
+
+ /// Restores every joint to its captured rest localRotation.
+ public void ResetToRest()
+ {
+ if (!HasChain() || restRotations == null) return;
+ for (int i = 0; i < joints.Count && i < restRotations.Length; i++)
+ if (joints[i]) joints[i].localRotation = restRotations[i];
+ }
+
+ ///
+ /// One solve step. (1) Write the procedural curl seed to every joint. (2) If no target
+ /// or ikWeight <= 0, that seed IS the pose (cheap, deterministic). (3) Otherwise run
+ /// CCD from the seed, constraining each joint to its hinge + limits mid-sweep, then
+ /// blend each joint Slerp(seed, solved, ikWeight). Spread is applied last on the base.
+ ///
+ public void Solve()
+ {
+ if (!HasChain() || restRotations == null || restRotations.Length != joints.Count) return;
+ EnsureScratch();
+
+ WriteCurlSeed();
+
+ bool runIk = target && tip && ikWeight > 0f;
+ if (runIk)
+ {
+ SolveCcd();
+ SolveBaseLateral(); // target-driven sideways DOF on the base (folds in manual spread)
+ for (int i = 0; i < joints.Count; i++)
+ {
+ if (!joints[i]) continue;
+ joints[i].localRotation = ikWeight >= 1f
+ ? _solvedLocal[i]
+ : Quaternion.Slerp(_seedLocal[i], _solvedLocal[i], ikWeight);
+ }
+ }
+ else
+ {
+ ApplySpread(); // manual spread only when IK isn't driving the base laterally
+ }
+ }
+
+ private bool HasChain() => joints != null && joints.Count > 0;
+
+ private void EnsureScratch()
+ {
+ int n = joints != null ? joints.Count : 0;
+ if (_seedLocal == null || _seedLocal.Length != n) _seedLocal = new Quaternion[n];
+ if (_solvedLocal == null || _solvedLocal.Length != n) _solvedLocal = new Quaternion[n];
+ }
+
+ private Vector3 HingeAxis(int i)
+ {
+ if (hingeAxes == null || hingeAxes.Length == 0) return Vector3.left;
+ Vector3 a = hingeAxes[Mathf.Min(i, hingeAxes.Length - 1)];
+ return a == Vector3.zero ? Vector3.left : a.normalized;
+ }
+
+ private float MinCurl(int i) =>
+ (minCurl != null && minCurl.Length > 0) ? minCurl[Mathf.Min(i, minCurl.Length - 1)] : 0f;
+
+ private float MaxCurl(int i) =>
+ (maxCurl != null && maxCurl.Length > 0) ? maxCurl[Mathf.Min(i, maxCurl.Length - 1)] : 90f;
+
+ private void EnsureLimitArrays()
+ {
+ int n = joints != null ? joints.Count : 0;
+ if (hingeAxes == null || hingeAxes.Length != n)
+ {
+ hingeAxes = new Vector3[n];
+ for (int i = 0; i < n; i++) hingeAxes[i] = Vector3.left;
+ }
+ if (minCurl == null || minCurl.Length != n) minCurl = new float[n]; // 0 = open
+ if (maxCurl == null || maxCurl.Length != n)
+ {
+ maxCurl = new float[n];
+ for (int i = 0; i < n; i++) maxCurl[i] = 90f;
+ }
+ }
+
+ // Procedural curl pose: each joint = rest * AngleAxis(Lerp(0, maxCurl, curl * profile), hinge).
+ // Stored into _seedLocal AND written to the transform so CCD bends from this pose.
+ private void WriteCurlSeed()
+ {
+ float span = Mathf.Max(1, joints.Count - 1);
+ for (int i = 0; i < joints.Count; i++)
+ {
+ float depth = joints.Count > 1 ? i / span : 0f;
+ float bias = curlProfile != null ? curlProfile.Evaluate(depth) : 1f;
+ float theta = Mathf.Clamp(Mathf.Lerp(0f, MaxCurl(i), curl * bias), MinCurl(i), MaxCurl(i));
+ Quaternion seed = restRotations[i] * Quaternion.AngleAxis(theta, HingeAxis(i));
+ _seedLocal[i] = seed;
+ if (joints[i]) joints[i].localRotation = seed;
+ }
+ }
+
+ // Hinge-constrained CCD: sweep tip -> base. Each joint rotates ONLY about its world hinge
+ // axis by the in-plane angle that swings the tip toward the target, then is clamped to its
+ // limits. Rotating along the real DOF (instead of a free minimal-arc that the hinge clamp
+ // then discards) keeps CCD in the rig's curl direction, so it actually reaches curl-side
+ // targets within [min, max]. Re-read the tip after every write (the subtree moved). Snapshot
+ // the result into _solvedLocal so the final blend is seed-vs-solved.
+ private void SolveCcd()
+ {
+ float sqrTol = tolerance * tolerance;
+
+ for (int it = 0; it < iterations; it++)
+ {
+ for (int i = joints.Count - 1; i >= 0; i--)
+ {
+ Transform j = joints[i];
+ if (!j) continue;
+
+ Vector3 jointPos = j.position;
+ Vector3 toTip = tip.position - jointPos;
+ Vector3 toTarget = target.position - jointPos;
+ if (toTip.sqrMagnitude < 1e-8f || toTarget.sqrMagnitude < 1e-8f) continue;
+
+ // World hinge axis (invariant under the joint's own twist about local X).
+ Vector3 axisW = (j.rotation * HingeAxis(i)).normalized;
+ Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW);
+ Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW);
+ if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) continue;
+
+ float hingeAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW);
+ j.rotation = Quaternion.AngleAxis(hingeAng, axisW) * j.rotation;
+
+ ConstrainToHinge(i);
+ }
+
+ if ((tip.position - target.position).sqrMagnitude <= sqrTol) break;
+ }
+
+ for (int i = 0; i < joints.Count; i++)
+ _solvedLocal[i] = joints[i] ? joints[i].localRotation : restRotations[i];
+ }
+
+ // Rest-relative swing-twist hinge constraint: isolate the twist about the hinge axis
+ // from (Inverse(rest) * local), drop the swing (spread/Y drift the world CCD added),
+ // extract the signed hinge angle, clamp to [min, max], and rebuild rest * AngleAxis.
+ // One assignment enforces BOTH the hinge plane and the limits with no accumulated drift.
+ private void ConstrainToHinge(int i)
+ {
+ Transform j = joints[i];
+ if (!j) return;
+
+ Vector3 axis = HingeAxis(i);
+ Quaternion rel = Quaternion.Inverse(restRotations[i]) * j.localRotation;
+
+ // Twist component of rel about axis (swing-twist for a principal axis).
+ Vector3 v = new Vector3(rel.x, rel.y, rel.z);
+ Vector3 p = Vector3.Project(v, axis);
+ float mag = Mathf.Sqrt(p.x * p.x + p.y * p.y + p.z * p.z + rel.w * rel.w);
+ Quaternion twist = mag < 1e-8f
+ ? Quaternion.identity // 180-degree swing: undefined twist
+ : new Quaternion(p.x / mag, p.y / mag, p.z / mag, rel.w / mag);
+
+ // Signed hinge angle: ToAngleAxis gives [0,180] with a possibly-flipped axis;
+ // recover the sign from the dot with the hinge axis.
+ twist.ToAngleAxis(out float ang, out Vector3 ax);
+ float signed = ang * Mathf.Sign(Vector3.Dot(ax, axis));
+ signed = Mathf.Clamp(signed, MinCurl(i), MaxCurl(i));
+
+ j.localRotation = restRotations[i] * Quaternion.AngleAxis(signed, axis);
+ }
+
+ // Target-driven lateral (abduction) on the base joint about local Z, run once after the
+ // flexion CCD. Z tip-motion is orthogonal to the X flexion plane on this rig, so a small
+ // sideways swing toward the target composes cleanly on top of the curl/flexion pose. The
+ // manual spread is folded in as a bias and the total is clamped to spreadLimits, then written
+ // into the base's solved rotation so the ikWeight blend covers lateral too.
+ private void SolveBaseLateral()
+ {
+ Transform b = joints[0];
+ if (!b || !tip || !target) return;
+
+ b.localRotation = _solvedLocal[0]; // pose the base at its flexion-solved rotation to read world Z
+ Vector3 axisW = (b.rotation * SpreadAxis).normalized;
+ Vector3 toTip = tip.position - b.position;
+ Vector3 toTarget = target.position - b.position;
+
+ float latAng = 0f;
+ Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW);
+ Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW);
+ if (tipPlane.sqrMagnitude > 1e-10f && tgtPlane.sqrMagnitude > 1e-10f)
+ latAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW);
+
+ latAng = Mathf.Clamp(latAng + spread, spreadLimits.x, spreadLimits.y);
+ _solvedLocal[0] = _solvedLocal[0] * Quaternion.AngleAxis(latAng, SpreadAxis);
+ }
+
+ // Base-joint spread/opposition about local Z, composed AFTER curl so the two DOFs don't
+ // fight. Applied on the final base rotation when IK isn't driving the base laterally.
+ private void ApplySpread()
+ {
+ if (joints.Count == 0 || !joints[0]) return;
+ float s = Mathf.Clamp(spread, spreadLimits.x, spreadLimits.y);
+ if (Mathf.Approximately(s, 0f)) return;
+ joints[0].localRotation = joints[0].localRotation * Quaternion.AngleAxis(s, SpreadAxis);
+ }
+
+ private void OnDrawGizmosSelected()
+ {
+ if (!HasChain()) return;
+
+ Gizmos.color = Color.cyan;
+ for (int i = 0; i < joints.Count - 1; i++)
+ if (joints[i] && joints[i + 1]) Gizmos.DrawLine(joints[i].position, joints[i + 1].position);
+
+ Transform last = joints[joints.Count - 1];
+ if (last && tip) Gizmos.DrawLine(last.position, tip.position);
+ if (tip) Gizmos.DrawWireSphere(tip.position, 0.03f);
+
+ if (target && tip)
+ {
+ Gizmos.color = Color.yellow;
+ Gizmos.DrawLine(tip.position, target.position);
+ Gizmos.DrawWireSphere(target.position, 0.05f);
+ }
+ }
+}
diff --git a/Assets/Scripts/FingerIK.cs.meta b/Assets/Scripts/FingerIK.cs.meta
new file mode 100644
index 0000000..353e4c5
--- /dev/null
+++ b/Assets/Scripts/FingerIK.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 716a391c8e627654dab900c7386c898a
\ No newline at end of file
diff --git a/Assets/Scripts/HandIK.cs b/Assets/Scripts/HandIK.cs
new file mode 100644
index 0000000..f0332c5
--- /dev/null
+++ b/Assets/Scripts/HandIK.cs
@@ -0,0 +1,352 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+///
+/// Hand-level IK manager for the WhiteHand rig. Auto-discovers the five fingers under
+/// (Thumb, IndexFinger, MiddleFinger, RingFinger, LittleFinger),
+/// walks each single-child bone spine to its *_end leaf, and creates/configures a
+/// on every finger root with sensible per-joint curl limits (the
+/// thumb gets its own 2-joint range). Owns per-finger curl/target/weight and drives every
+/// finger's solve once per frame in LateUpdate (so FingerIK does not double-solve, and the
+/// override lands AFTER any Animator pose for the frame).
+///
+/// Runs in edit mode too ([ExecuteAlways]): an editor tick solves live so per-finger curl,
+/// the IK weight, and the per-finger target slots pose the hand WITHOUT entering Play, and
+/// dragging an assigned target in the scene view updates the reach live. The solve only re-runs
+/// when an input actually changed, so an idle hand never re-dirties the scene.
+///
+/// Minimal wiring: drop on WhiteHand (or Hand); leave handRoot empty to auto-find the 'Hand'
+/// node by name. Fingers auto-discover (in edit mode the first time, deferred to a safe point;
+/// in Play via discoverOnAwake). Right-click the component header for "Rediscover" (after rig
+/// edits) and "Reset To Rest".
+///
+[ExecuteAlways]
+[DisallowMultipleComponent]
+public class HandIK : MonoBehaviour
+{
+ public enum Finger { Thumb, Index, Middle, Ring, Little }
+
+ [Header("Rig")]
+ [Tooltip("The 'Hand' node whose 5 children are the finger roots. Empty = auto-find by name under this object.")]
+ [SerializeField] private Transform handRoot;
+
+ [Tooltip("Run discovery automatically in Awake.")]
+ [SerializeField] private bool discoverOnAwake = true;
+
+ [Header("Targets (edit-mode assignable, dragged live)")]
+ [Tooltip("Per-finger IK goal in rig order (Thumb, Index, Middle, Ring, Little). Assign a transform and drag it in the scene view; that finger reaches it live. A filled slot overrides the finger's own Target; an empty slot leaves a target set directly on the FingerIK intact (so either place works). Empty everywhere = the finger is curl-only.")]
+ [SerializeField] private Transform[] fingerTargets = new Transform[5];
+
+ [Header("Solver defaults (applied on discovery)")]
+ [Tooltip("CCD sweeps per finger solve.")]
+ [Range(1, 16)] [SerializeField] private int iterations = 10;
+
+ [Tooltip("IK reach weight applied to every finger on discovery (0 = pure curl, 1 = full IK).")]
+ [Range(0f, 1f)] [SerializeField] private float ikWeight = 1f;
+
+ // Indexed by (int)Finger. Built by Discover(); not hand-edited, so serialized hidden.
+ [SerializeField, HideInInspector] private FingerIK[] fingers = new FingerIK[5];
+
+ // Exact finger-root names in rig order, matching the Finger enum.
+ private static readonly string[] RootNames =
+ { "Thumb", "IndexFinger", "MiddleFinger", "RingFinger", "LittleFinger" };
+
+ // Default per-joint flexion limits (degrees, rest-relative; + closes the fist on this
+ // rig). Four long fingers: base (MCP), middle (PIP), distal (DIP). Small negative mins
+ // allow a little back-of-hand (upward) bend when an IK target pulls there.
+ private static readonly float[] FingerMin = { -25f, -12f, -12f };
+ private static readonly float[] FingerMax = { 70f, 90f, 70f };
+
+ // Thumb has only 2 rotating joints; its opposition lives in the rest local-Z pose, so
+ // these are pure flexion deltas on top of rest.
+ private static readonly float[] ThumbMin = { -20f, -12f };
+ private static readonly float[] ThumbMax = { 55f, 80f };
+
+ // This rig flexes every finger (thumb included) around local -X toward the palm; local +X
+ // curls the back of the hand (verified). Pushed to each FingerIK on discovery so a
+ // re-discovery overwrites any stale serialized axis. Single entry = reused for all joints.
+ private static readonly Vector3[] CurlHingeAxis = { new Vector3(-1f, 0f, 0f) };
+
+ public FingerIK Get(Finger f) => (fingers != null && (int)f < fingers.Length) ? fingers[(int)f] : null;
+
+ private void Awake()
+ {
+ if (!handRoot) handRoot = FindHandRoot();
+ if (Application.isPlaying && discoverOnAwake) Discover();
+ }
+
+ private void Reset() => handRoot = FindHandRoot();
+
+ // Broadcast the IK weight only when it actually changes, so editing other fields (or a
+ // per-finger ikWeight on a FingerIK) is not clobbered every OnValidate. Pushing it live is
+ // what makes the slider actually move the fingers (discovery alone left it inert).
+ private float _lastIkWeight = -1f;
+
+ private void OnValidate()
+ {
+ if (!handRoot) handRoot = FindHandRoot();
+ if (!Mathf.Approximately(ikWeight, _lastIkWeight))
+ {
+ _lastIkWeight = ikWeight;
+ if (FingersResolved()) SetIkWeightAll(ikWeight);
+ }
+ }
+
+ // Single writer for all finger bones. Drives the solve in LateUpdate in Play (overrides any
+ // animation applied between Update and LateUpdate) and from the editor tick in edit mode, so
+ // both share one path. FingerIK.solveInLateUpdate is left OFF on discovery (no double-solve).
+ private void LateUpdate()
+ {
+ if (Application.isPlaying) SolveAll();
+ }
+
+ // Pushes each per-finger target, then solves. Shared by Play (LateUpdate) and edit mode
+ // (EditorTick). Non-destructive: a Finger Target slot OVERRIDES the finger's own target
+ // when assigned, but an EMPTY slot leaves a target set directly on the FingerIK intact
+ // (so assigning on either surface works, and the slot never silently wipes it).
+ private void SolveAll()
+ {
+ if (fingers == null) return;
+ for (int i = 0; i < fingers.Length; i++)
+ {
+ FingerIK ik = fingers[i];
+ if (!ik) continue;
+ Transform slot = (fingerTargets != null && i < fingerTargets.Length) ? fingerTargets[i] : null;
+ if (slot) ik.SetTarget(slot);
+ ik.Solve();
+ }
+ }
+
+ private bool FingersResolved()
+ {
+ if (fingers == null || fingers.Length != RootNames.Length) return false;
+ for (int i = 0; i < fingers.Length; i++) if (!fingers[i]) return false;
+ return true;
+ }
+
+ public void SetCurl(Finger f, float value)
+ {
+ var ik = Get(f);
+ if (ik) ik.SetCurl(value);
+ }
+
+ public void SetTarget(Finger f, Transform t)
+ {
+ int i = (int)f;
+ if (fingerTargets != null && i >= 0 && i < fingerTargets.Length) fingerTargets[i] = t;
+ var ik = Get(f);
+ if (ik) ik.SetTarget(t);
+ }
+
+ public void SetIkWeight(Finger f, float value)
+ {
+ var ik = Get(f);
+ if (ik) ik.SetIkWeight(value);
+ }
+
+ public void SetSpread(Finger f, float degrees)
+ {
+ var ik = Get(f);
+ if (ik) ik.SetSpread(degrees);
+ }
+
+ /// Pushes the IK reach weight to every discovered finger (0 = pure curl, 1 = full IK).
+ public void SetIkWeightAll(float value)
+ {
+ ikWeight = Mathf.Clamp01(value);
+ if (fingers == null) return;
+ for (int i = 0; i < fingers.Length; i++)
+ if (fingers[i]) fingers[i].SetIkWeight(ikWeight);
+ }
+
+ [ContextMenu("Reset To Rest")]
+ public void ResetAll()
+ {
+ if (fingers == null) return;
+ for (int i = 0; i < fingers.Length; i++)
+ if (fingers[i]) fingers[i].ResetToRest();
+ }
+
+ ///
+ /// (Re)builds the 5 FingerIK components from the rig. Idempotent: reuses an existing
+ /// FingerIK on each root (GetComponent before AddComponent), so rediscovery in a posed
+ /// hand re-captures rest from the CURRENT pose only via Setup.
+ ///
+ [ContextMenu("Rediscover")]
+ public void Discover()
+ {
+ if (!handRoot) handRoot = FindHandRoot();
+ if (!handRoot)
+ {
+ Debug.LogWarning("HandIK: no handRoot; cannot discover fingers.", this);
+ return;
+ }
+
+ if (fingers == null || fingers.Length != RootNames.Length)
+ fingers = new FingerIK[RootNames.Length];
+
+ var chain = new List(8);
+ for (int f = 0; f < RootNames.Length; f++)
+ {
+ Transform root = FindDeepChild(handRoot, RootNames[f]);
+ if (!root)
+ {
+ Debug.LogWarning($"HandIK: finger root '{RootNames[f]}' not found under {handRoot.name}.", this);
+ fingers[f] = null;
+ continue;
+ }
+
+ // Walk the single-child spine root..*_end. Every bone before the _end leaf is a
+ // rotating joint; the _end leaf is the fingertip (no joint of its own).
+ chain.Clear();
+ Transform node = root;
+ Transform tipLeaf = null;
+ while (node)
+ {
+ if (node.name.EndsWith("_end"))
+ {
+ tipLeaf = node;
+ break;
+ }
+ chain.Add(node);
+ node = node.childCount > 0 ? node.GetChild(0) : null;
+ }
+ if (!tipLeaf && chain.Count > 1)
+ {
+ // No _end sentinel: treat the deepest bone as the tip so a non-conforming
+ // rig still solves with chain.Count-1 joints.
+ tipLeaf = chain[chain.Count - 1];
+ chain.RemoveAt(chain.Count - 1);
+ }
+
+ FingerIK ik = root.GetComponent();
+ if (!ik) ik = root.gameObject.AddComponent();
+
+ ik.Setup(chain, tipLeaf);
+ ConfigureFinger(ik, (Finger)f);
+ fingers[f] = ik;
+ }
+ }
+
+ // Applies the per-finger limits and shared solver settings. Thumb uses its 2-joint range.
+ private void ConfigureFinger(FingerIK ik, Finger f)
+ {
+ if (f == Finger.Thumb)
+ {
+ ik.SetLimits(ThumbMin, ThumbMax);
+ ik.SetSpreadLimits(-10f, 45f); // opposition is the thumb's primary base DOF
+ }
+ else
+ {
+ ik.SetLimits(FingerMin, FingerMax);
+ ik.SetSpreadLimits(-12f, 12f);
+ }
+ ik.SetHingeAxes(CurlHingeAxis); // rig curl direction (palm-ward); overrides stale serialized axes
+ ik.SetIterations(iterations);
+ ik.SetIkWeight(ikWeight);
+ ik.SolveInLateUpdate = false; // this manager drives the solve
+ }
+
+ private Transform FindHandRoot()
+ {
+ Transform found = FindDeepChild(transform, "Hand");
+ return found ? found : transform;
+ }
+
+ private static Transform FindDeepChild(Transform parent, string targetName)
+ {
+ for (int i = 0; i < parent.childCount; i++)
+ {
+ Transform c = parent.GetChild(i);
+ if (c.name == targetName) return c;
+ Transform deep = FindDeepChild(c, targetName);
+ if (deep) return deep;
+ }
+ return null;
+ }
+
+#if UNITY_EDITOR
+ private double _lastSig = double.NaN;
+
+ // Edit-mode live drive. LateUpdate is unreliable outside Play, so tick the solve from the
+ // editor loop; re-solve only when an input changed so an idle hand never re-dirties the scene.
+ private void OnEnable()
+ {
+ if (Application.isPlaying) return;
+ UnityEditor.EditorApplication.update -= EditorTick;
+ UnityEditor.EditorApplication.update += EditorTick;
+ if (!FingersResolved())
+ UnityEditor.EditorApplication.delayCall += DeferredDiscover;
+ }
+
+ private void OnDisable() => UnityEditor.EditorApplication.update -= EditorTick;
+
+ // AddComponent is illegal during Awake/OnEnable/OnValidate, so structural discovery is
+ // deferred to a delayCall (a legal point) and persisted by marking the scene dirty.
+ private void DeferredDiscover()
+ {
+ if (!this || Application.isPlaying || FingersResolved()) return;
+ Discover();
+ UnityEditor.EditorUtility.SetDirty(this);
+ if (gameObject.scene.IsValid())
+ UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
+ }
+
+ private void EditorTick()
+ {
+ if (Application.isPlaying) return;
+ if (!this) { UnityEditor.EditorApplication.update -= EditorTick; return; }
+ if (!FingersResolved()) return;
+ double sig = InputSignature();
+ if (sig == _lastSig) return;
+ _lastSig = sig;
+ SolveAll();
+ UnityEditor.SceneView.RepaintAll();
+ }
+
+ // Cheap ADDITIVE fold over everything that affects the solved pose; any change triggers one
+ // re-solve. NOT compounding (h*31 grows to ~1e38 over 5 fingers, so later fingers' sub-mm
+ // target drags vanish in double epsilon -> only the first targets refreshed live). Each
+ // finger contributes at the same scale via a distinct per-finger weight, so every finger's
+ // drag is detected and the chance of two independent edits cancelling is measure-zero.
+ private double InputSignature()
+ {
+ double h = 0.0;
+ for (int i = 0; i < fingers.Length; i++)
+ {
+ FingerIK ik = fingers[i];
+ double w = (i + 1) * 0.123;
+ if (!ik) { h += w * 2.0; continue; }
+ h += w * (ik.Curl * 1.7 + ik.IkWeight * 2.9 + ik.Spread * 0.013);
+ // Effective target mirrors SolveAll's resolution (slot overrides, else the finger's
+ // own target) so dragging a target assigned on EITHER surface triggers a re-solve.
+ Transform slot = (fingerTargets != null && i < fingerTargets.Length) ? fingerTargets[i] : null;
+ Transform t = slot ? slot : ik.Target;
+ if (t)
+ {
+ Vector3 p = t.position;
+ Quaternion q = t.rotation;
+ h += w * (p.x * 1.0 + p.y * 7.0 + p.z * 13.0);
+ h += w * (q.x * 3.0 + q.y * 5.0 + q.z * 11.0 + q.w * 17.0);
+ }
+ }
+ return h;
+ }
+#endif
+
+ private void OnDrawGizmos()
+ {
+ if (fingers == null) return;
+ foreach (var ik in fingers)
+ {
+ if (!ik || !ik.Tip) continue;
+ Gizmos.color = Color.green;
+ Gizmos.DrawWireSphere(ik.Tip.position, 0.04f);
+ if (ik.Target)
+ {
+ Gizmos.color = Color.yellow;
+ Gizmos.DrawLine(ik.Tip.position, ik.Target.position);
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/HandIK.cs.meta b/Assets/Scripts/HandIK.cs.meta
new file mode 100644
index 0000000..6bb2122
--- /dev/null
+++ b/Assets/Scripts/HandIK.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: cbe3de6a9f2cea544993dbddc6dedaa8
\ No newline at end of file