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