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:
@@ -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
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user