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