- Introduced StageGizmos for hover highlighting, selection outlines, and rotation handles in the sandbox stage builder. - Added StageGrid for an optional world-space grid overlay that adjusts with camera panning and zooming. - Implemented StageSelection for geometric hit-testing and selection management of nodes in the sandbox. - Created StageSpawner as a registry-driven factory for spawning terrain, props, and stickmen, allowing for dynamic template management. - Each script includes necessary constants, state management, and public API methods for interaction.
558 lines
23 KiB
GDScript
558 lines
23 KiB
GDScript
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/Pivot/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/Pivot/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
|
||
if pd.has("shapes"):
|
||
var shapes_var: Variant = pd.get("shapes")
|
||
if shapes_var is Array:
|
||
shapes = shapes_var as Array
|
||
elif pd.has("points"):
|
||
# v1.0/v1.1 single-shape format: the part dict is itself one
|
||
# shape (wrapped like the editor's load path).
|
||
shapes = [pd]
|
||
# 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()
|