Files
stickman/scripts/stk_rig_adapter.gd
T
ryan 6b273c049c fix: address shape mounting issues in StkRigAdapter
- Implement anisotropic scaling for shape mounting to prevent distortion.
- Replace bounding-box midpoint anchors with joint-based anchors for correct rotation.
- Reset node transforms to ensure clean scaling and rotation before mounting shapes.
- Introduce new helper functions for computing bounding boxes, anchors, part lengths, and scales.
- Neutralize driver rotations to maintain a consistent frame of reference during shape mounting.
- Update documentation to reflect changes and provide detailed bugfix specifications.
2026-08-19 10:01:25 -04:00

400 lines
16 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",
]
## Parts whose primary (bone-aligned) axis is horizontal (X).
const X_AXIS_PARTS: PackedStringArray = [
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
]
## 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",
}
## RemoteTransform2D driver node paths (relative to rig root), keyed by part
## name. Each driver pushes its transform onto the matching Body/* visual node
## (see master_rig.tscn). We neutralize their rotation so the Body/* nodes stay
## in the clean unrotated frame the mount math assumes (position/scale pushes
## are preserved).
const DRIVER_PATHS: Dictionary = {
"head": "Skeleton2D/Torso/Head/RemoteTransform2D",
"torso": "Skeleton2D/Torso/RemoteTransform2D",
"left_upper_arm": "Skeleton2D/Torso/LeftUpperArm/RemoteTransform2D",
"left_lower_arm": "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm/RemoteTransform2D",
"right_upper_arm": "Skeleton2D/Torso/RightUpperArm/RemoteTransform2D",
"right_lower_arm": "Skeleton2D/Torso/RightUpperArm/RightLowerArm/RemoteTransform2D",
"left_upper_leg": "Skeleton2D/Torso/LeftUpperLeg/RemoteTransform2D",
"left_lower_leg": "Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg/RemoteTransform2D",
"right_upper_leg": "Skeleton2D/Torso/RightUpperLeg/RemoteTransform2D",
"right_lower_leg": "Skeleton2D/Torso/RightUpperLeg/RightLowerLeg/RemoteTransform2D",
}
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"
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
static func apply(stk_data: Dictionary, rig: Node2D) -> void:
_fit_bones(stk_data, rig)
_recalibrate_ik(stk_data, rig)
_neutralize_driver_rotations(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"]))
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)
_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)
# 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))
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)
# ---------------------------------------------------------------------------
# Driver neutralization
# ---------------------------------------------------------------------------
static func _neutralize_driver_rotations(rig: Node2D) -> void:
for part_name: String in DRIVER_PATHS:
var driver := rig.get_node_or_null(NodePath(DRIVER_PATHS[part_name])) as RemoteTransform2D
if driver == null:
push_warning("StkRigAdapter: missing RemoteTransform2D driver '%s' for part '%s'; skipped." % [DRIVER_PATHS[part_name], part_name])
continue
driver.update_rotation = false
# ---------------------------------------------------------------------------
# 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
# Reset the node's authored transform so the mount math starts from a
# clean unrotated, unit-scaled frame. Position is owned by the
# RemoteTransform2D driver and is left untouched.
_reset_node_transform(visual)
# 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)
var shapes: Array = []
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
# Recompute the joint anchor and part length from the shape bbox at
# mount time (the file's pivot/length fields are write-only metadata
# and are no longer trusted for anchoring/scaling).
var bbox := _compute_part_bbox(shapes)
if float(bbox["min_x"]) > float(bbox["max_x"]) or float(bbox["min_y"]) > float(bbox["max_y"]):
continue
var anchor := _compute_anchor(bbox, part_name)
var part_length := _compute_part_length(bbox, part_name)
var bone_length := _bone_length_for(part_name, proportions)
var scale := _compute_scale(part_name, part_length, bone_length)
for shape in shapes:
if shape is Dictionary:
_mount_shape(visual, shape as Dictionary, anchor, scale)
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 _compute_part_bbox(shapes: Array) -> Dictionary:
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
var pt := Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))
min_x = minf(min_x, pt.x)
min_y = minf(min_y, pt.y)
max_x = maxf(max_x, pt.x)
max_y = maxf(max_y, pt.y)
elif p is Vector2:
var pt := p as Vector2
min_x = minf(min_x, pt.x)
min_y = minf(min_y, pt.y)
max_x = maxf(max_x, pt.x)
max_y = maxf(max_y, pt.y)
elif pts_var is PackedVector2Array:
for pt in pts_var as PackedVector2Array:
min_x = minf(min_x, pt.x)
min_y = minf(min_y, pt.y)
max_x = maxf(max_x, pt.x)
max_y = maxf(max_y, pt.y)
return {
"min_x": min_x,
"min_y": min_y,
"max_x": max_x,
"max_y": max_y,
}
static func _compute_anchor(bbox: Dictionary, part_name: String) -> Vector2:
var cx := (float(bbox["min_x"]) + float(bbox["max_x"])) * 0.5
var cy := (float(bbox["min_y"]) + float(bbox["max_y"])) * 0.5
match part_name:
"head":
return Vector2(cx, float(bbox["max_y"])) # neck base
"left_upper_arm", "left_lower_arm":
return Vector2(float(bbox["max_x"]), cy) # shoulder at right end
"right_upper_arm", "right_lower_arm":
return Vector2(float(bbox["min_x"]), cy) # shoulder at left end
_: # torso + all legs
return Vector2(cx, float(bbox["min_y"])) # hip/neck top-center
static func _compute_part_length(bbox: Dictionary, part_name: String) -> float:
if X_AXIS_PARTS.has(part_name):
return float(bbox["max_x"]) - float(bbox["min_x"])
return float(bbox["max_y"]) - float(bbox["min_y"])
static func _compute_scale(part_name: String, part_length: float, bone_length: float) -> Vector2:
var primary := 1.0
if part_name != "head" and part_length > 0.0001:
primary = bone_length / part_length
if X_AXIS_PARTS.has(part_name):
return Vector2(primary, 1.0) # arms: X is primary
return Vector2(1.0, primary) # legs/torso/head: Y is primary (head → (1.0, 1.0))
static func _reset_node_transform(visual: Node) -> void:
if visual is Node2D:
(visual as Node2D).scale = Vector2.ONE
(visual as Node2D).rotation = 0.0
static func _mount_shape(visual: Node, shape: Dictionary, anchor: Vector2, scale: Vector2) -> void:
var pts := _transform_points(shape.get("points", []), anchor, scale)
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, anchor: Vector2, scale: Vector2) -> 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(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
elif p is Vector2:
var pt := p as Vector2
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
elif pts_var is PackedVector2Array:
for pt in pts_var as PackedVector2Array:
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
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()