Files
stickman/scripts/stk_rig_adapter.gd
T
ryan ab5c79ab6a 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.
2026-08-21 12:39:31 -04:00

553 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 := 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,
"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",
}
## RemoteTransform2D driver node paths (relative to rig root), keyed by part
## 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",
"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"
const HEAD_DRIVER_PATH := "Skeleton2D/Torso/Head/RemoteTransform2D"
# ---------------------------------------------------------------------------
# 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"]))
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))
# 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))
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
# 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
# @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 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
# 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 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, mt)
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,
}
## 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,
}
## 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:
if visual is Node2D:
(visual as Node2D).scale = Vector2.ONE
(visual as Node2D).rotation = 0.0
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)
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, 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(_map_point(pt, anchor, theta, s, center, part_rotation, part_scale, offset))
elif p is Vector2:
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(_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()
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()