@tool
class_name ChestIK extends RefCounted
## Overgrowth-style chest IK: spring-damper look-at rotation.
## Rotates chest toward look direction with spring physics.
##
## Based on DoChestIK() from aschar.as line 14857.
## Uses pre-IK bone snapshot to prevent feedback loops.
## Reference frame: skeleton root transform (not bone axes).
const CHEST_BONE := "DEF-spine.003" # Upper chest in Rigify
const SPINE_BONE := "DEF-spine.002" # Mid spine (abdomen)
const LOWER_SPINE_BONE := "DEF-spine.001" # Lower spine, just above the pelvis
# Spring-damper parameters (Overgrowth defaults)
var damping: float = 0.93
var stiffness: float = 0.5
var idle_damping: float = 0.99
var idle_stiffness: float = 0.03
# State
var _chest_angle: Vector2 = Vector2.ZERO # Current rotation (yaw, pitch)
var _chest_angle_vel: Vector2 = Vector2.ZERO # Angular velocity
var _target_chest_angle: Vector2 = Vector2.ZERO
var _old_chest_angle: float = 0.0
# Look target (set by controller)
var look_direction: Vector3 = Vector3.ZERO
var idle_amount: float = 0.0 ## 0 = combat, 1 = idle
## Apply chest IK rotation.
## pre_ik_globals: { bone_name: Transform3D } snapshot from before IK.
func apply(skeleton: Skeleton3D, delta: float,
pre_ik_globals: Dictionary) -> Quaternion:
if skeleton == null:
return Quaternion.IDENTITY
var chest_idx: int = skeleton.find_bone(CHEST_BONE)
if chest_idx < 0:
return Quaternion.IDENTITY
var gx: Transform3D = skeleton.global_transform
# Reference frame: use the skeleton's root transform axes.
# This is stable — it's the character's body orientation,
# not the bone's local axes which vary per rig.
var ref_forward: Vector3 = -gx.basis.z.normalized() # Character visual forward (skeleton has 180° Y flip from glTF)
var ref_up: Vector3 = gx.basis.y.normalized()
var ref_right: Vector3 = gx.basis.x.normalized()
# No look target → default to character forward (zero angle = no rotation)
var look: Vector3 = look_direction if look_direction.length_squared() > 0.001 else ref_forward
# Compute yaw: angle between look and character forward on the XZ plane
var look_flat := Vector2.ZERO
look_flat.x = look.dot(ref_right)
look_flat.y = look.dot(ref_forward)
var angle: float = atan2(-look_flat.x, look_flat.y)
# Anti-flip when looking straight back (Overgrowth line 14915)
var head_range: float = 1.0
if angle > 2.2 and _old_chest_angle <= -head_range:
angle = -head_range
elif angle < -2.2 and _old_chest_angle >= head_range:
angle = head_range
angle = clampf(angle, -head_range, head_range)
_old_chest_angle = angle
# Pitch from look direction
var pitch: float = asin(clampf(look.normalized().dot(ref_up), -1.0, 1.0))
var look_amount: float = 1.0 if look_direction.length_squared() > 0.001 else 0.0
_target_chest_angle.x = angle * look_amount
_target_chest_angle.y = pitch * look_amount * idle_amount
# Subtle breathing shake (Overgrowth line 14928)
var shake: float = 0.005
_target_chest_angle.x += randf_range(-shake, shake)
_target_chest_angle.y += randf_range(-shake, shake)
# Spring-damper update (Overgrowth line 14932-14934)
var d: float = lerpf(damping, idle_damping, idle_amount)
var s: float = lerpf(stiffness, idle_stiffness, idle_amount)
var frames: float = delta * 60.0
_chest_angle_vel *= pow(d, frames)
_chest_angle_vel += (_target_chest_angle - _chest_angle) * s * frames
_chest_angle += _chest_angle_vel * delta
# Build rotation in world space using character axes
var yaw_rot := Quaternion(ref_up, _chest_angle.x)
var pitch_rot := Quaternion(ref_right, _chest_angle.y)
var chest_rotate: Quaternion = yaw_rot * pitch_rot
# SPREAD DOWN THE SPINE, a third per bone. Overgrowth's rig has hip -> abdomen ->
# chest and DrawBody blends the abdomen 50% between hip and chest, so a turn is
# shared by every joint above the pelvis. Ours has THREE bones there
# (DEF-spine.001/.002/.003); the port put half on .002 and half on .003 and left
# .001 straight, which reads as the torso turning at one kink in the middle of
# the back (gate_spine_look: 0% / 50% / 50%).
var third_rot: Quaternion = Quaternion.IDENTITY.slerp(chest_rotate, 1.0 / 3.0)
for bone_name: String in [LOWER_SPINE_BONE, SPINE_BONE]:
var idx: int = skeleton.find_bone(bone_name)
if idx >= 0:
_apply_local_rotation(skeleton, gx, idx, third_rot, pre_ik_globals)
_apply_local_rotation(skeleton, gx, chest_idx, third_rot, pre_ik_globals)
return chest_rotate
func _apply_local_rotation(skeleton: Skeleton3D, gx: Transform3D,
bone_idx: int, world_rot: Quaternion,
pre_ik_globals: Dictionary) -> void:
var parent_idx: int = skeleton.get_bone_parent(bone_idx)
if parent_idx < 0:
return
var parent_name: String = skeleton.get_bone_name(parent_idx)
var parent_global: Transform3D = pre_ik_globals.get(parent_name, gx * skeleton.get_bone_global_pose(parent_idx))
var parent_basis: Basis = parent_global.basis
var local_rot: Quaternion = (parent_basis.inverse() * Basis(world_rot) * parent_basis).get_rotation_quaternion()
var current: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
skeleton.set_bone_pose_rotation(bone_idx, local_rot * current)