feat: Implement draggable torso and head IK targets in the test harness

- Added draggable handles for the torso and head to the test harness.
- Updated `IK_HANDLE_PATHS` to include new entries for "Head" and "Torso".
- Implemented distinct colors for the torso (magenta) and head (yellow) markers.
- Added a visual aid (aim line) to indicate the head's LookAt target direction.
- Ensured that dragging the torso moves only the torso marker, allowing for limb stretching towards stationary targets.
This commit is contained in:
2026-08-21 12:39:31 -04:00
parent 6b273c049c
commit ab5c79ab6a
14 changed files with 1459 additions and 174 deletions
+23 -3
View File
@@ -48,10 +48,10 @@ const DEFAULT_POSITIONS: Dictionary = {
"right_lower_leg": Vector2(165, 210),
}
const FILE_VERSION := "1.4"
const FILE_VERSION := "1.5"
const FILE_FILTER := "*.stk ; Stickman Files"
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"]
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2).
const PROPORTIONS: Dictionary = {
@@ -66,6 +66,22 @@ const X_AXIS_PARTS: PackedStringArray = [
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
]
# Phase 9 Round 5: map each body part to its silhouette-guide joint (see
# whole_stickman_preview.gd GUIDE_JOINTS). Used to compute the per-part
# guide_offset (bbox center guide joint, preview space) at save time.
const GUIDE_JOINT_FOR_PART: Dictionary = {
"head": "Neck",
"torso": "Hips",
"left_upper_arm": "LeftShoulder",
"left_lower_arm": "LeftElbow",
"right_upper_arm": "RightShoulder",
"right_lower_arm": "RightElbow",
"left_upper_leg": "Hips",
"left_lower_leg": "LeftKnee",
"right_upper_leg": "Hips",
"right_lower_leg": "RightKnee",
}
const SETTINGS_PATH := "user://settings.json"
const SETTINGS_VERSION := "1.0"
const DEFAULT_GRID_SIZE := 15
@@ -413,6 +429,9 @@ func _collect_all_shape_data() -> Dictionary:
var rot := _whole_preview.get_part_rotation(part_name)
var scl := _whole_preview.get_part_scale(part_name)
var pl := _compute_part_pivot_length(shapes_arr, part_name)
var pivot_vector := Vector2(float(pl["pivot"]["x"]), float(pl["pivot"]["y"]))
var joint_preview := _whole_preview.get_guide_joint_preview(GUIDE_JOINT_FOR_PART[part_name])
var guide_offset := (pos + pivot_vector) - joint_preview
all_data[part_name] = {
"shapes": shapes_arr,
"position": {"x": pos.x, "y": pos.y},
@@ -420,6 +439,7 @@ func _collect_all_shape_data() -> Dictionary:
"scale": {"x": scl.x, "y": scl.y},
"pivot": pl["pivot"],
"length": pl["length"],
"guide_offset": {"x": guide_offset.x, "y": guide_offset.y},
}
return all_data
@@ -449,7 +469,7 @@ func _apply_json_data(json: Variant) -> String:
var version: String = dict.get("version", "")
if version not in SUPPORTED_VERSIONS:
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', or '1.4')" % version
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', '1.4', or '1.5')" % version
_stickman_name_edit.text = dict.get("stickman_name", "")
+228 -75
View File
@@ -4,16 +4,26 @@ extends RefCounted
##
## 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.
## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging"
## convention (joint end at the local origin, far end along +Y). The
## RemoteTransform2D drivers keep `update_rotation = true`, so each mounted
## part rotates to follow its bone in every pose (IK flexing included).
## Consumed by a future runtime pipeline, never referenced by the editor.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_LINE_WIDTH := 16.0
const DEFAULT_LINE_WIDTH := 2.0
const ELBOW_REST_Y := -256.0
## Head chin drop (px): the editor's pose guide draws the head as a circle of
## radius 100 centered at the Head joint (0, -463.5), so its bottom sits at
## -363.5; the neck (Head bone origin) is at -391.5. The mounted head's chin
## (its local origin) therefore drops 28 px below the neck so the head overlaps
## the torso the same way the guide circle does.
const HEAD_CHIN_DROP := 28.0
const DEFAULT_PROPORTIONS: Dictionary = {
"upper_arm_length": 168.0,
"lower_arm_length": 200.0,
@@ -30,11 +40,6 @@ const PART_KEYS: PackedStringArray = [
"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",
@@ -62,10 +67,9 @@ const BODY_PATHS: Dictionary = {
}
## 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).
## name. Each driver pushes the matching Body/* node's global transform from
## its bone; its `global_rotation` is the bone frame used (Phase 9 Round 5) to
## convert the master-space guide_offset into a bone-relative placement.
const DRIVER_PATHS: Dictionary = {
"head": "Skeleton2D/Torso/Head/RemoteTransform2D",
"torso": "Skeleton2D/Torso/RemoteTransform2D",
@@ -85,6 +89,7 @@ const IK_LEFT_LEG := "IK_Targets/Left_Leg"
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
const HEAD_DRIVER_PATH := "Skeleton2D/Torso/Head/RemoteTransform2D"
# ---------------------------------------------------------------------------
# Public API
@@ -93,7 +98,6 @@ const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
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)
# ---------------------------------------------------------------------------
@@ -140,6 +144,11 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
if head_bone is Node2D:
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
# Head driver — zero the RemoteTransform2D local position so Body/Head sits
# on the neck joint (the authored offset centered the old 100-px circle
# 72 px above the neck; the mounted head's chin is its local origin).
_set_prop(rig, HEAD_DRIVER_PATH, "position", Vector2.ZERO)
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
var node := rig.get_node_or_null(NodePath(path))
@@ -183,18 +192,6 @@ static func _recalibrate_ik(stk_data: Dictionary, rig: Node2D) -> void:
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
# ---------------------------------------------------------------------------
@@ -213,9 +210,10 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
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 the node's authored scale/rotation so the mount math starts from
# a unit-scaled, unrotated frame (position is owned by the
# RemoteTransform2D driver and is left untouched; the driver overwrites
# scale/rotation each frame anyway).
_reset_node_transform(visual)
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
@@ -228,27 +226,51 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
_clear_visual_children(visual)
var shapes: Array = []
var rotation_deg := 0.0
var part_scale := Vector2.ONE
var guide_offset := Vector2.ZERO
var has_guide_offset := false
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
# Phase 9 Round 3: the preview's per-part rotation (degrees) and
# scale about the bbox center are applied to the mounted geometry.
rotation_deg = float(pd.get("rotation", 0.0))
var scale_var: Variant = pd.get("scale", {})
if scale_var is Dictionary:
var sd := scale_var as Dictionary
part_scale = Vector2(float(sd.get("x", 1.0)), float(sd.get("y", 1.0)))
# Phase 9 Round 5: guide-relative placement (write-only metadata;
# absent on old files → offset 0).
var go_var: Variant = pd.get("guide_offset")
if go_var is Dictionary:
var god := go_var as Dictionary
guide_offset = Vector2(float(god.get("x", 0.0)), float(god.get("y", 0.0)))
has_guide_offset = true
# 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).
# Phase 9 Round 5: the part's driver rotation (bone frame) used to
# convert the master-space guide offset into a bone-relative
# translation. Null-guarded; fallback 0.0.
var c_node := 0.0
var driver := rig.get_node_or_null(NodePath(DRIVER_PATHS[part_name]))
if driver is Node2D:
c_node = (driver as Node2D).global_rotation
# Recompute the mount transform (part rotation/scale applied first, then
# the fitted anchor, alignment rotation and bone-fit scale) from the
# shape bbox at mount time. The file's pivot/length fields are
# write-only metadata and are never trusted for mounting.
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)
var mt := _compute_mount_transform(bbox, part_name, proportions, rotation_deg, part_scale, guide_offset, has_guide_offset, c_node)
for shape in shapes:
if shape is Dictionary:
_mount_shape(visual, shape as Dictionary, anchor, scale)
_mount_shape(visual, shape as Dictionary, mt)
static func _bone_length_for(part_name: String, proportions: Dictionary) -> float:
@@ -305,33 +327,150 @@ static func _compute_part_bbox(shapes: Array) -> Dictionary:
}
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
## Computes the mount transform for a part: `{ "anchor": Vector2, "theta":
## float, "s": float, "center": Vector2, "part_rotation": float,
## "part_scale": Vector2, "offset": Vector2 }`.
##
## Pipeline (spec §2c):
## 1. The raw bbox center C, raw joint end J_raw, and raw far point F_pt_raw
## come from the Round 2 family rules (head/torso bottom-center →
## top-center; horizontally-drawn limbs end-to-end; vertical limbs
## top-center → bottom-center).
## 2. The preview's part transform E(P) = C + R(rot)·S·(P C) is applied to
## the anchor and far point: J' = E(J_raw), F_pt' = E(F_pt_raw),
## F' = F_pt' J'.
## 3. Phase 9 Round 6: when `guide_offset` is present, the anchor end is
## whichever transformed end (J' or F_pt') is nearest the guide joint
## (C guide_offset): A = F_pt', V = F' if the far end is nearer, else
## A = J', V = F'. Old files (no guide_offset) fall back to the 180° flip
## heuristic (|wrapf(rot)| > 0.75·π attaches the drawn far end at the
## joint; otherwise A = J', V = F').
## 4. Alignment θ = V.normalized().angle_to(Vector2.DOWN) rotates the fitted
## long axis onto the hanging frame; the bone-fit scale s = bone_length/|V|
## is measured on the transformed extent. The head mounts upright,
## unscaled (θ = 0, s = 1) but still applies E, plus a rig-space
## translation offset (head only) that drops the chin below the neck.
## 5. Phase 9 Round 5: when `guide_offset` is present, the offset becomes
## t = (guide_offset + (A C)).rotated(c_node) — the anchor's placement
## relative to the guide joint, converted to the driver's bone frame —
## which reproduces the editor's guide-relative placement (and, for the
## head, subsumes the HEAD_CHIN_DROP fallback).
static func _compute_mount_transform(bbox: Dictionary, part_name: String, proportions: Dictionary, rotation_deg: float, part_scale: Vector2, guide_offset: Vector2, has_guide_offset: bool, c_node: float) -> Dictionary:
var min_x := float(bbox["min_x"])
var min_y := float(bbox["min_y"])
var max_x := float(bbox["max_x"])
var max_y := float(bbox["max_y"])
var cx := (min_x + max_x) * 0.5
var cy := (min_y + max_y) * 0.5
var width := max_x - min_x
var height := max_y - min_y
var center := Vector2(cx, cy)
# Raw joint end (J_raw) and far point (F_pt_raw), per the Round 2 family
# rules. Head and torso mount bottom-center (chin / hip end) with the far
# point at top-center (cap / neck end); limbs auto-detect the drawn long
# axis (horizontal: end-to-end; vertical: top-center → bottom-center).
var j_raw := Vector2.ZERO
var f_pt_raw := Vector2.ZERO
if part_name == "head" or part_name == "torso":
j_raw = Vector2(cx, max_y)
f_pt_raw = Vector2(cx, min_y)
else:
var long_axis_is_x := width >= height
if part_name.begins_with("left_"):
if long_axis_is_x:
j_raw = Vector2(max_x, cy)
f_pt_raw = Vector2(min_x, cy)
else:
j_raw = Vector2(cx, min_y)
f_pt_raw = Vector2(cx, max_y)
else:
if long_axis_is_x:
j_raw = Vector2(min_x, cy)
f_pt_raw = Vector2(max_x, cy)
else:
j_raw = Vector2(cx, min_y)
f_pt_raw = Vector2(cx, max_y)
# Apply the preview's part transform to the anchor and far point.
var rot_rad := deg_to_rad(rotation_deg)
var j_prime := _apply_part_transform(j_raw, center, part_scale, rot_rad)
var f_pt_prime := _apply_part_transform(f_pt_raw, center, part_scale, rot_rad)
var f_prime := f_pt_prime - j_prime
# Phase 9 Round 6: when guide_offset is present, the stored guide placement is
# the ground truth for which drawn end is the joint — pick whichever
# transformed end (j_prime or f_pt_prime) is nearest the guide joint
# (center - guide_offset). This replaces the family-side + 180° flip heuristic
# for that case and naturally reproduces the flip (a flipped part's far end
# lands near the joint). Old files (no guide_offset) keep the flip heuristic.
var anchor: Vector2
var v: Vector2
if has_guide_offset:
var joint_pos := center - guide_offset
var d_joint := j_prime.distance_to(joint_pos)
var d_far := f_pt_prime.distance_to(joint_pos)
if d_far < d_joint:
anchor = f_pt_prime
v = -f_prime
else:
anchor = j_prime
v = f_prime
else:
# 180° flips attach the drawn far end at the joint (the end the user rotated
# into the joint position), making the rotation visibly applied.
var flipped := absf(wrapf(rot_rad, -PI, PI)) > PI * 0.75
if flipped:
anchor = f_pt_prime
v = -f_prime
else:
anchor = j_prime
v = f_prime
var v_len := v.length()
var theta := 0.0
if v_len > 0.0001:
theta = v.normalized().angle_to(Vector2.DOWN)
var s := 1.0
var offset := Vector2.ZERO
# Phase 9 Round 5: guide-relative placement. delta = guide_offset + (A C)
# is the anchor's offset from its guide joint in master space; t rotates it
# into the driver's (bone) frame so the placement stays bone-relative as the
# rig flexes. Applied only when the key is present (old files keep the
# offset-0 / chin-drop behavior).
if has_guide_offset:
offset = (guide_offset + (anchor - center)).rotated(-c_node)
if part_name == "head":
# Head mounts upright, unscaled — a bone-fit scale would double-scale the
# face; E already applied the user's part scale. The chin (local origin)
# is dropped below the neck by HEAD_CHIN_DROP so the head overlaps the
# torso like the editor's pose guide. That drop is the old-file fallback;
# when guide_offset is present it is subsumed by the computed offset.
theta = 0.0
s = 1.0
if not has_guide_offset:
offset = Vector2(0.0, HEAD_CHIN_DROP)
elif v_len > 0.0001:
s = _bone_length_for(part_name, proportions) / v_len
return {
"anchor": anchor,
"theta": theta,
"s": s,
"center": center,
"part_rotation": rot_rad,
"part_scale": part_scale,
"offset": offset,
}
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))
## Applies the preview's part transform E(P) = C + R(rot)·S·(P C): scale
## about the bbox center, then rotate about the bbox center.
static func _apply_part_transform(pt: Vector2, center: Vector2, part_scale: Vector2, rot_rad: float) -> Vector2:
return center + Vector2((pt.x - center.x) * part_scale.x, (pt.y - center.y) * part_scale.y).rotated(rot_rad)
static func _reset_node_transform(visual: Node) -> void:
@@ -340,26 +479,30 @@ static func _reset_node_transform(visual: Node) -> void:
(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)
static func _mount_shape(visual: Node, shape: Dictionary, mt: Dictionary) -> void:
var anchor: Vector2 = mt["anchor"]
var theta: float = mt["theta"]
var s: float = mt["s"]
var center: Vector2 = mt["center"]
var part_rotation: float = mt["part_rotation"]
var part_scale: Vector2 = mt["part_scale"]
var offset: Vector2 = mt["offset"]
var pts := _transform_points(shape.get("points", []), anchor, theta, s, center, part_rotation, part_scale, offset)
if pts.size() < 2:
return
var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE)
var closed := bool(shape.get("closed", false))
# Phase 9 Round 3: one node per shape — closed shapes mount as a single
# Polygon2D (fill only, no outline Line2D); open shapes mount as a single
# Line2D.
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
@@ -368,23 +511,33 @@ static func _mount_shape(visual: Node, shape: Dictionary, anchor: Vector2, scale
visual.add_child(line)
static func _transform_points(pts_var: Variant, anchor: Vector2, scale: Vector2) -> PackedVector2Array:
static func _transform_points(pts_var: Variant, anchor: Vector2, theta: float, s: float, center: Vector2, part_rotation: float, part_scale: Vector2, offset: 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))
out.append(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
elif p is Vector2:
var pt := p as Vector2
out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y))
out.append(_map_point(p as Vector2, anchor, theta, s, center, part_rotation, part_scale, offset))
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))
out.append(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
return out
## Maps one drawn point into the rig's hanging frame:
## Q = E(P) = C + R(rot)·S·(P C) (the preview's part transform)
## v = R(θ)·(Q A); v.y *= s (align + bone-fit scale along the axis)
## v += offset (rig-space translation, head chin drop)
static func _map_point(pt: Vector2, anchor: Vector2, theta: float, s: float, center: Vector2, part_rotation: float, part_scale: Vector2, offset: Vector2) -> Vector2:
var q := _apply_part_transform(pt, center, part_scale, part_rotation)
var v := (q - anchor).rotated(theta)
v.y *= s
return v + offset
static func _reset_own_geometry(visual: Node) -> void:
if visual is Line2D:
(visual as Line2D).points = PackedVector2Array()
+72 -4
View File
@@ -24,6 +24,8 @@ const BONE_COLOR_RIGHT := Color(1.00, 0.50, 0.20)
const BONE_COLOR_CENTRAL := Color(1.00, 1.00, 1.00)
const HANDLE_COLOR_HAND := Color(0.00, 1.00, 0.00) # green
const HANDLE_COLOR_FOOT := Color(0.00, 0.50, 1.00) # blue
const HANDLE_COLOR_HEAD := Color(1.00, 1.00, 0.00) # yellow
const HANDLE_COLOR_TORSO := Color(1.00, 0.00, 1.00) # magenta
const SKELETON_PATH := "Skeleton2D"
@@ -35,11 +37,28 @@ const QUICK_LOADS: Array = [
]
## IK handle node paths (relative to rig root), keyed by handle name.
## The four limb targets drive TwoBoneIK; "Head" is the LookAt aim point and
## "Torso" is the hip anchor (dragging it translates the whole rig via its
## RemoteTransform2D — no target-following logic).
const IK_HANDLE_PATHS: Dictionary = {
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
"Head": "IK_Targets/Head",
"Torso": "IK_Targets/Torso",
}
## Leaf bone → IK target node path (relative to rig root), keyed by bone name.
## Used by the bone overlay to draw the forearm/shin segments out to their
## wrist/ankle targets. The Head leaf is NOT listed here — its IK target is a
## LookAt aim point, so it draws along its own bone direction instead (see
## _draw_bones' leaf fallback).
const LEAF_BONE_IK_PATHS: Dictionary = {
"LeftLowerArm": "IK_Targets/Left_Hand",
"RightLowerArm": "IK_Targets/Right_Hand",
"LeftLowerLeg": "IK_Targets/Left_Leg",
"RightLowerLeg": "IK_Targets/Right_Leg",
}
# ---------------------------------------------------------------------------
@@ -282,9 +301,35 @@ func _draw_bones() -> void:
var origin := bone.global_position
_debug_overlay.draw_circle(origin, dot_r, color)
var parent := bone.get_parent()
if parent is Bone2D:
_debug_overlay.draw_line((parent as Bone2D).global_position, origin, color, line_w)
# Collect the bone's Bone2D children (true bone segments, including the
# nested lower bones).
var bone_children: Array[Bone2D] = []
for child in bone.get_children():
if child is Bone2D:
bone_children.append(child as Bone2D)
if bone_children.is_empty():
# Leaf bone — draw out to its IK target (forearm/shin), falling
# back to the bone's own direction (bone_angle + rotation) when
# there is no target (e.g. the Head, whose target is a LookAt aim
# point, not a joint).
var target := _leaf_bone_target(bone)
if target != null:
_debug_overlay.draw_line(origin, target.global_position, color, line_w)
else:
var fallback := origin + Vector2(bone.get_length(), 0.0).rotated(deg_to_rad(bone.bone_angle)).rotated(bone.global_rotation)
_debug_overlay.draw_line(origin, fallback, color, line_w)
else:
for child_bone in bone_children:
_debug_overlay.draw_line(origin, child_bone.global_position, color, line_w)
func _leaf_bone_target(bone: Bone2D) -> Node2D:
if not LEAF_BONE_IK_PATHS.has(bone.name):
return null
if _rig == null or not is_instance_valid(_rig):
return null
return _rig.get_node_or_null(NodePath(str(LEAF_BONE_IK_PATHS[bone.name]))) as Node2D
func _draw_ik_handles() -> void:
@@ -296,11 +341,34 @@ func _draw_ik_handles() -> void:
var handle := _ik_handles[handle_name] as Marker2D
if handle == null or not is_instance_valid(handle):
continue
var color := HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
var color := _handle_color(handle_name)
var pos := handle.global_position
_debug_overlay.draw_circle(pos, r, color)
_debug_overlay.draw_arc(pos, r, 0.0, TAU, 24, Color(1.0, 1.0, 1.0, 0.8), outline_w)
# Head aim line — visual aid showing what the head bone is aiming at.
var head_handle: Marker2D = _ik_handles.get("Head", null) as Marker2D
if head_handle != null and is_instance_valid(head_handle) \
and _skeleton != null and is_instance_valid(_skeleton):
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head_bone != null and is_instance_valid(head_bone):
_debug_overlay.draw_line(
head_bone.global_position,
head_handle.global_position,
Color(1.0, 1.0, 0.0, 0.5),
1.5 / zoom
)
func _handle_color(handle_name: String) -> Color:
match handle_name:
"Head":
return HANDLE_COLOR_HEAD
"Torso":
return HANDLE_COLOR_TORSO
_:
return HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
func _bone_color(bone_name: String) -> Color:
if bone_name.begins_with("Left"):
+10
View File
@@ -271,6 +271,16 @@ func set_show_guide(enabled: bool) -> void:
preview_area.queue_redraw()
## Returns the preview-space position of a guide joint (a master-space
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
## unknown joint names.
func get_guide_joint_preview(joint_name: String) -> Vector2:
if not GUIDE_JOINTS.has(joint_name):
push_warning("WholeStickmanPreview: unknown guide joint '%s'." % joint_name)
return Vector2.ZERO
return _guide_to_preview(GUIDE_JOINTS[joint_name])
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO