- Implemented StkRigAdapter class to adapt a master rig to a loaded .stk dictionary. - Added methods for fitting bone lengths, recalibrating IK targets, and mounting vector shapes. - Defined constants for default proportions and bone paths. - Included error handling for missing nodes and invalid data structures.
320 lines
12 KiB
GDScript
320 lines
12 KiB
GDScript
class_name StkRigAdapter
|
|
extends RefCounted
|
|
## StkRigAdapter - Standalone runtime adapter (Phase 8).
|
|
##
|
|
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits
|
|
## the skeleton bone lengths, recalibrates the IK targets, and mounts the
|
|
## .stk vector shapes onto the rig's Body/ visual nodes. Consumed by a future
|
|
## runtime pipeline, never referenced by the editor.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_LINE_WIDTH := 16.0
|
|
const ELBOW_REST_Y := -256.0
|
|
|
|
const DEFAULT_PROPORTIONS: Dictionary = {
|
|
"upper_arm_length": 168.0,
|
|
"lower_arm_length": 200.0,
|
|
"upper_leg_length": 200.0,
|
|
"lower_leg_length": 200.0,
|
|
"torso_length": 391.5,
|
|
}
|
|
|
|
const PART_KEYS: PackedStringArray = [
|
|
"head", "torso",
|
|
"left_upper_arm", "left_lower_arm",
|
|
"right_upper_arm", "right_lower_arm",
|
|
"left_upper_leg", "left_lower_leg",
|
|
"right_upper_leg", "right_lower_leg",
|
|
]
|
|
|
|
## Bone node paths (relative to rig root), keyed by part name.
|
|
const BONE_PATHS: Dictionary = {
|
|
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm",
|
|
"left_lower_arm": "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm",
|
|
"right_upper_arm": "Skeleton2D/Torso/RightUpperArm",
|
|
"right_lower_arm": "Skeleton2D/Torso/RightUpperArm/RightLowerArm",
|
|
"left_upper_leg": "Skeleton2D/Torso/LeftUpperLeg",
|
|
"left_lower_leg": "Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg",
|
|
"right_upper_leg": "Skeleton2D/Torso/RightUpperLeg",
|
|
"right_lower_leg": "Skeleton2D/Torso/RightUpperLeg/RightLowerLeg",
|
|
}
|
|
|
|
## Body visual node paths (relative to rig root), keyed by part name.
|
|
const BODY_PATHS: Dictionary = {
|
|
"head": "Body/Head",
|
|
"torso": "Body/Body",
|
|
"left_upper_arm": "Body/LeftUpperArm",
|
|
"left_lower_arm": "Body/LeftLowerArm",
|
|
"right_upper_arm": "Body/RightUpperArm",
|
|
"right_lower_arm": "Body/RightLowerArm",
|
|
"left_upper_leg": "Body/LeftUpperLeg",
|
|
"left_lower_leg": "Body/LeftLowerLeg",
|
|
"right_upper_leg": "Body/RightUpperLeg",
|
|
"right_lower_leg": "Body/RightLowerLeg",
|
|
}
|
|
|
|
const IK_LEFT_HAND := "IK_Targets/Left_Hand"
|
|
const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
|
|
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
|
|
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
|
|
_fit_bones(stk_data, rig)
|
|
_recalibrate_ik(stk_data, rig)
|
|
_mount_shapes(stk_data, rig)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Proportions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
static func _get_proportions(stk_data: Dictionary) -> Dictionary:
|
|
var raw: Variant = stk_data.get("proportions", {})
|
|
if raw is Dictionary:
|
|
return raw as Dictionary
|
|
return {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bone fitting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
|
var proportions := _get_proportions(stk_data)
|
|
var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
|
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
|
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
|
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
|
|
|
# Arms — upper length + lower-bone origin on X.
|
|
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
|
|
_set_prop(rig, BONE_PATHS["left_lower_arm"], "position", Vector2(-ua, 0.0))
|
|
_set_prop(rig, BONE_PATHS["left_lower_arm"], "length", la)
|
|
_set_prop(rig, BONE_PATHS["right_upper_arm"], "length", ua)
|
|
_set_prop(rig, BONE_PATHS["right_lower_arm"], "position", Vector2(ua, 0.0))
|
|
_set_prop(rig, BONE_PATHS["right_lower_arm"], "length", la)
|
|
|
|
# Legs — upper length + lower-bone origin on Y.
|
|
_set_prop(rig, BONE_PATHS["left_upper_leg"], "length", ul)
|
|
_set_prop(rig, BONE_PATHS["left_lower_leg"], "position", Vector2(0.0, ul))
|
|
_set_prop(rig, BONE_PATHS["left_lower_leg"], "length", ll)
|
|
_set_prop(rig, BONE_PATHS["right_upper_leg"], "length", ul)
|
|
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
|
|
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
|
|
|
|
|
|
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
|
var node := rig.get_node_or_null(NodePath(path))
|
|
if node == null:
|
|
push_warning("StkRigAdapter: missing node '%s'; skipped '%s'." % [path, prop])
|
|
return
|
|
node.set(prop, value)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IK target recalibration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void:
|
|
var proportions := _get_proportions(stk_data)
|
|
var ua := float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
|
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
|
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
|
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
|
|
|
var left_leg := rig.get_node_or_null(NodePath(IK_LEFT_LEG)) as Node2D
|
|
if left_leg != null:
|
|
left_leg.position = Vector2(left_leg.position.x, ul + ll)
|
|
else:
|
|
push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_LEG)
|
|
|
|
var right_leg := rig.get_node_or_null(NodePath(IK_RIGHT_LEG)) as Node2D
|
|
if right_leg != null:
|
|
right_leg.position = Vector2(right_leg.position.x, ul + ll)
|
|
else:
|
|
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_LEG)
|
|
|
|
var left_hand := rig.get_node_or_null(NodePath(IK_LEFT_HAND)) as Node2D
|
|
if left_hand != null:
|
|
left_hand.position = Vector2(-ua, ELBOW_REST_Y - la)
|
|
else:
|
|
push_warning("StkRigAdapter: missing IK target '%s'." % IK_LEFT_HAND)
|
|
|
|
var right_hand := rig.get_node_or_null(NodePath(IK_RIGHT_HAND)) as Node2D
|
|
if right_hand != null:
|
|
right_hand.position = Vector2(ua, ELBOW_REST_Y - la)
|
|
else:
|
|
push_warning("StkRigAdapter: missing IK target '%s'." % IK_RIGHT_HAND)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Visual shape mount
|
|
# ---------------------------------------------------------------------------
|
|
|
|
static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
|
|
var body_parts_var: Variant = stk_data.get("body_parts", {})
|
|
if not body_parts_var is Dictionary:
|
|
push_warning("StkRigAdapter: 'body_parts' missing or invalid; no shapes mounted.")
|
|
return
|
|
var body_parts: Dictionary = body_parts_var as Dictionary
|
|
var proportions := _get_proportions(stk_data)
|
|
|
|
for part_name: String in PART_KEYS:
|
|
var visual := rig.get_node_or_null(NodePath(BODY_PATHS[part_name]))
|
|
if visual == null:
|
|
push_warning("StkRigAdapter: missing Body node for part '%s'; skipped." % part_name)
|
|
continue
|
|
|
|
var shapes: Array = []
|
|
var pivot := Vector2.ZERO
|
|
var part_length := 0.0
|
|
var part_data: Variant = body_parts.get(part_name, {})
|
|
if part_data is Dictionary:
|
|
var pd := part_data as Dictionary
|
|
var shapes_var: Variant = pd.get("shapes", [])
|
|
if shapes_var is Array:
|
|
shapes = shapes_var as Array
|
|
var pivot_var: Variant = pd.get("pivot", {})
|
|
if pivot_var is Dictionary:
|
|
var pv := pivot_var as Dictionary
|
|
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0)))
|
|
part_length = float(pd.get("length", 0.0))
|
|
|
|
# Head: prefer setting the circle script's exports.
|
|
if part_name == "head" and ("radius" in visual):
|
|
_mount_head_circle(visual, shapes)
|
|
continue
|
|
|
|
var bone_length := _bone_length_for(part_name, proportions)
|
|
var scale_factor := 1.0
|
|
if part_name != "head" and part_length > 0.0001:
|
|
scale_factor = bone_length / part_length
|
|
|
|
_reset_own_geometry(visual)
|
|
_clear_visual_children(visual)
|
|
|
|
for shape in shapes:
|
|
if shape is Dictionary:
|
|
_mount_shape(visual, shape as Dictionary, pivot, scale_factor)
|
|
|
|
|
|
static func _bone_length_for(part_name: String, proportions: Dictionary) -> float:
|
|
match part_name:
|
|
"left_upper_arm", "right_upper_arm":
|
|
return float(proportions.get("upper_arm_length", DEFAULT_PROPORTIONS["upper_arm_length"]))
|
|
"left_lower_arm", "right_lower_arm":
|
|
return float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
|
"left_upper_leg", "right_upper_leg":
|
|
return float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
|
"left_lower_leg", "right_lower_leg":
|
|
return float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
|
"torso":
|
|
return float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
|
|
_:
|
|
return 1.0
|
|
|
|
|
|
static func _mount_head_circle(visual: Node, shapes: Array) -> void:
|
|
_clear_visual_children(visual)
|
|
if shapes.is_empty():
|
|
visual.set("radius", 0.0)
|
|
return
|
|
|
|
var radius := 0.0
|
|
var bbox := _compute_shapes_bbox(shapes)
|
|
if bbox.size.x > 0.0001 or bbox.size.y > 0.0001:
|
|
var diameter: float = bbox.size.y if bbox.size.y > 0.0001 else bbox.size.x
|
|
radius = diameter * 0.5
|
|
visual.set("radius", radius)
|
|
visual.set("color", _first_shape_color(shapes))
|
|
|
|
|
|
static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void:
|
|
var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
|
|
if pts.size() < 2:
|
|
return
|
|
|
|
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
|
|
var closed := bool(shape.get("closed", false))
|
|
|
|
if closed:
|
|
var poly := Polygon2D.new()
|
|
poly.polygon = pts
|
|
poly.color = color
|
|
visual.add_child(poly)
|
|
|
|
var outline := Line2D.new()
|
|
outline.points = pts
|
|
outline.closed = true
|
|
outline.width = DEFAULT_LINE_WIDTH
|
|
outline.default_color = color
|
|
visual.add_child(outline)
|
|
else:
|
|
var line := Line2D.new()
|
|
line.points = pts
|
|
line.width = DEFAULT_LINE_WIDTH
|
|
line.default_color = color
|
|
visual.add_child(line)
|
|
|
|
|
|
static func _transform_points(pts_var: Variant, pivot: Vector2, scale_factor: float) -> PackedVector2Array:
|
|
var out := PackedVector2Array()
|
|
if pts_var is Array:
|
|
for p in pts_var as Array:
|
|
if p is Dictionary:
|
|
var d := p as Dictionary
|
|
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
|
|
out.append((pt - pivot) * scale_factor)
|
|
elif p is Vector2:
|
|
out.append(((p as Vector2) - pivot) * scale_factor)
|
|
elif pts_var is PackedVector2Array:
|
|
for pt in pts_var as PackedVector2Array:
|
|
out.append((pt - pivot) * scale_factor)
|
|
return out
|
|
|
|
|
|
static func _reset_own_geometry(visual: Node) -> void:
|
|
if visual is Line2D:
|
|
(visual as Line2D).points = PackedVector2Array()
|
|
elif visual is Polygon2D:
|
|
(visual as Polygon2D).polygon = PackedVector2Array()
|
|
|
|
|
|
static func _clear_visual_children(visual: Node) -> void:
|
|
for child in visual.get_children():
|
|
if child is Line2D or child is Polygon2D:
|
|
visual.remove_child(child)
|
|
child.queue_free()
|
|
|
|
|
|
static func _compute_shapes_bbox(shapes: Array) -> Rect2:
|
|
var min_x := INF
|
|
var min_y := INF
|
|
var max_x := -INF
|
|
var max_y := -INF
|
|
for shape in shapes:
|
|
if not shape is Dictionary:
|
|
continue
|
|
var pts_var: Variant = (shape as Dictionary).get("points", [])
|
|
if pts_var is Array:
|
|
for p in pts_var as Array:
|
|
if p is Dictionary:
|
|
var d := p as Dictionary
|
|
min_x = minf(min_x, float(d.get("x", 0.0)))
|
|
min_y = minf(min_y, float(d.get("y", 0.0)))
|
|
max_x = maxf(max_x, float(d.get("x", 0.0)))
|
|
max_y = maxf(max_y, float(d.get("y", 0.0)))
|
|
if min_x > max_x or min_y > max_y:
|
|
return Rect2()
|
|
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
|
|
|
|
|
|
static func _first_shape_color(shapes: Array) -> Color:
|
|
for shape in shapes:
|
|
if shape is Dictionary:
|
|
return Color.from_string(str((shape as Dictionary).get("color", "#ffffff")), Color.WHITE)
|
|
return Color.WHITE
|