feat: Implement Stickman Factory and Test Harness for runtime rigging

- Added StickmanFactory to load and instantiate .stk files into master_rig.tscn.
- Created a Test Harness scene for debugging bone scales, vector drawing offsets, and IK limits.
- Updated stk_rig_adapter to fit head bone and mount head shapes as full geometry.
- Enhanced README and PROJECT documentation to reflect new features and usage.
- Introduced UI elements for file selection and visual debugging in the Test Harness.
- Refactored code to improve clarity and maintainability, including removal of unused functions.
This commit is contained in:
2026-08-18 21:44:56 -04:00
parent 48d98ce0fe
commit 1252728c06
10 changed files with 618 additions and 95 deletions
+15 -49
View File
@@ -61,6 +61,8 @@ const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -90,6 +92,7 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
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 torso := float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
# Arms — upper length + lower-bone origin on X.
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
@@ -107,6 +110,12 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
# Head — position the head bone at the top of the torso (preserve the
# existing x so the rig's ~ -0.1288 x-offset is retained).
var head_bone := rig.get_node_or_null(NodePath(HEAD_BONE_PATH))
if head_bone is Node2D:
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
var node := rig.get_node_or_null(NodePath(path))
@@ -183,16 +192,17 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
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
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
# @tool circle-drawing script; clear it so the head is mounted with the
# same full-geometry path as every other part.
if part_name == "head":
visual.set_script(null)
_reset_own_geometry(visual)
_clear_visual_children(visual)
@@ -217,21 +227,6 @@ static func _bone_length_for(part_name: String, proportions: Dictionary) -> floa
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:
@@ -288,32 +283,3 @@ static func _clear_visual_children(visual: Node) -> void:
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