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. Consumed by a future ## runtime pipeline, never referenced by the editor. # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const DEFAULT_LINE_WIDTH := 16.0 const ELBOW_REST_Y := -256.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", ] ## 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", "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 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). 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" # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- 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) # --------------------------------------------------------------------------- # 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)) 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) # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- 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 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_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 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 # 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). 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) for shape in shapes: if shape is Dictionary: _mount_shape(visual, shape as Dictionary, anchor, scale) 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, } 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 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)) 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, anchor: Vector2, scale: Vector2) -> void: var pts := _transform_points(shape.get("points", []), anchor, scale) if pts.size() < 2: return var color := Color.from_string(str(shape.get("color", "#ffffff")), Color.WHITE) var closed := bool(shape.get("closed", false)) 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 line.width = DEFAULT_LINE_WIDTH line.default_color = color visual.add_child(line) static func _transform_points(pts_var: Variant, anchor: Vector2, scale: 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)) elif p is Vector2: var pt := p as Vector2 out.append(Vector2((pt.x - anchor.x) * scale.x, (pt.y - anchor.y) * scale.y)) 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)) return out 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()