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
+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()