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:
+228
-75
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user