Add StkRigAdapter for runtime skeleton fitting and shape mounting
- Implemented StkRigAdapter class to adapt a master rig to a loaded .stk dictionary. - Added methods for fitting bone lengths, recalibrating IK targets, and mounting vector shapes. - Defined constants for default proportions and bone paths. - Included error handling for missing nodes and invalid data structures.
This commit is contained in:
+99
-12
@@ -48,10 +48,23 @@ const DEFAULT_POSITIONS: Dictionary = {
|
||||
"right_lower_leg": Vector2(165, 210),
|
||||
}
|
||||
|
||||
const FILE_VERSION := "1.3"
|
||||
const FILE_VERSION := "1.4"
|
||||
const FILE_FILTER := "*.stk ; Stickman Files"
|
||||
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3"]
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4"]
|
||||
|
||||
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2).
|
||||
const 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 X_AXIS_PARTS: PackedStringArray = [
|
||||
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
|
||||
]
|
||||
|
||||
const SETTINGS_PATH := "user://settings.json"
|
||||
const SETTINGS_VERSION := "1.0"
|
||||
@@ -89,7 +102,11 @@ var _shape_clipboard: Dictionary = {}
|
||||
# Phase 6: recent colors
|
||||
var _recent_colors: Array[String] = []
|
||||
|
||||
# Phase 7: pose silhouette guide (default ON per user decision)
|
||||
var _show_guide: bool = true
|
||||
|
||||
var _edit_menu: PopupMenu
|
||||
var _view_menu: PopupMenu
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -156,9 +173,13 @@ func _setup_menu_bar() -> void:
|
||||
var view_menu: PopupMenu = PopupMenu.new()
|
||||
view_menu.name = "ViewMenu"
|
||||
view_menu.add_item("Reset Views", 0)
|
||||
view_menu.add_separator()
|
||||
view_menu.add_item(_guide_menu_label(), 1)
|
||||
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
|
||||
view_menu.about_to_popup.connect(_on_view_menu_about_to_popup)
|
||||
_menu_bar.add_child(view_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
|
||||
_view_menu = view_menu
|
||||
|
||||
|
||||
func _setup_dialogs() -> void:
|
||||
@@ -225,7 +246,9 @@ func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
1: # Snap to Grid (checkable)
|
||||
_snap_enabled = not _snap_enabled
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_snap_status_label()
|
||||
@@ -233,15 +256,38 @@ func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
|
||||
func _on_edit_menu_about_to_popup() -> void:
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
|
||||
|
||||
func _on_view_menu_id_pressed(id: int) -> void:
|
||||
if id == 0: # Reset Views
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).reset_view()
|
||||
_whole_preview.reset_view()
|
||||
match id:
|
||||
0: # Reset Views
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).reset_view()
|
||||
_whole_preview.reset_view()
|
||||
1: # Hide/Show Pose Guide
|
||||
_show_guide = not _show_guide
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _on_view_menu_about_to_popup() -> void:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _update_guide_menu_item() -> void:
|
||||
if _view_menu:
|
||||
var idx := _view_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_view_menu.set_item_text(idx, _guide_menu_label())
|
||||
|
||||
|
||||
func _guide_menu_label() -> String:
|
||||
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
|
||||
|
||||
|
||||
func _on_grid_config_confirmed() -> void:
|
||||
@@ -327,6 +373,36 @@ func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
|
||||
# Data collection / JSON helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _compute_part_pivot_length(shapes_arr: Array, part_name: String) -> Dictionary:
|
||||
var min_x := INF
|
||||
var min_y := INF
|
||||
var max_x := -INF
|
||||
var max_y := -INF
|
||||
for sd in shapes_arr:
|
||||
if not sd is Dictionary:
|
||||
continue
|
||||
for p in (sd as Dictionary).get("points", []):
|
||||
if not p is Dictionary:
|
||||
continue
|
||||
var px: float = float((p as Dictionary).get("x", 0.0))
|
||||
var py: float = float((p as Dictionary).get("y", 0.0))
|
||||
min_x = minf(min_x, px)
|
||||
min_y = minf(min_y, py)
|
||||
max_x = maxf(max_x, px)
|
||||
max_y = maxf(max_y, py)
|
||||
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return { "pivot": { "x": 0.0, "y": 0.0 }, "length": 0.0 }
|
||||
|
||||
var pivot := { "x": (min_x + max_x) * 0.5, "y": (min_y + max_y) * 0.5 }
|
||||
var length: float
|
||||
if X_AXIS_PARTS.has(part_name):
|
||||
length = max_x - min_x
|
||||
else:
|
||||
length = max_y - min_y
|
||||
return { "pivot": pivot, "length": length }
|
||||
|
||||
|
||||
func _collect_all_shape_data() -> Dictionary:
|
||||
var all_data: Dictionary = {}
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
@@ -336,11 +412,14 @@ func _collect_all_shape_data() -> Dictionary:
|
||||
var pos := _whole_preview.get_part_position(part_name)
|
||||
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)
|
||||
all_data[part_name] = {
|
||||
"shapes": shapes_arr,
|
||||
"position": {"x": pos.x, "y": pos.y},
|
||||
"rotation": rot,
|
||||
"scale": {"x": scl.x, "y": scl.y}
|
||||
"scale": {"x": scl.x, "y": scl.y},
|
||||
"pivot": pl["pivot"],
|
||||
"length": pl["length"],
|
||||
}
|
||||
return all_data
|
||||
|
||||
@@ -353,6 +432,7 @@ func _build_json_data() -> Dictionary:
|
||||
"version": FILE_VERSION,
|
||||
"stickman_name": _stickman_name_edit.text.strip_edges(),
|
||||
"part_order": _whole_preview.get_part_order(),
|
||||
"proportions": PROPORTIONS.duplicate(),
|
||||
"body_parts": body_parts,
|
||||
"metadata": {
|
||||
"created_at": time_str,
|
||||
@@ -369,7 +449,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', or '1.3')" % version
|
||||
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', or '1.4')" % version
|
||||
|
||||
_stickman_name_edit.text = dict.get("stickman_name", "")
|
||||
|
||||
@@ -527,6 +607,7 @@ func _load_settings() -> void:
|
||||
var d := json as Dictionary
|
||||
_grid_size = int(d.get("grid_size", DEFAULT_GRID_SIZE))
|
||||
_snap_enabled = bool(d.get("snap_to_grid", false))
|
||||
_show_guide = bool(d.get("show_pose_guide", true))
|
||||
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||||
|
||||
var raw_colors: Variant = d.get("recent_colors", [])
|
||||
@@ -539,7 +620,11 @@ func _load_settings() -> void:
|
||||
_recent_colors.pop_back()
|
||||
|
||||
if _edit_menu:
|
||||
_edit_menu.set_item_text(1, _snap_menu_label())
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
if _view_menu:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
@@ -548,6 +633,7 @@ func _save_settings() -> void:
|
||||
"grid_size": _grid_size,
|
||||
"snap_to_grid": _snap_enabled,
|
||||
"recent_colors": _recent_colors,
|
||||
"show_pose_guide": _show_guide,
|
||||
}
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||||
if file:
|
||||
@@ -563,6 +649,7 @@ func _broadcast_settings() -> void:
|
||||
p.set_snap_enabled(_snap_enabled)
|
||||
_whole_preview.set_grid_size(_grid_size)
|
||||
_whole_preview.set_snap_enabled(_snap_enabled)
|
||||
_whole_preview.set_show_guide(_show_guide)
|
||||
|
||||
|
||||
func _snap_menu_label() -> String:
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
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",
|
||||
]
|
||||
|
||||
## 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",
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"]))
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
var shapes: Array = []
|
||||
var pivot := Vector2.ZERO
|
||||
var part_length := 0.0
|
||||
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
|
||||
var pivot_var: Variant = pd.get("pivot", {})
|
||||
if pivot_var is Dictionary:
|
||||
var pv := pivot_var as Dictionary
|
||||
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0)))
|
||||
part_length = float(pd.get("length", 0.0))
|
||||
|
||||
# Head: prefer setting the circle script's exports.
|
||||
if part_name == "head" and ("radius" in visual):
|
||||
_mount_head_circle(visual, shapes)
|
||||
continue
|
||||
|
||||
var bone_length := _bone_length_for(part_name, proportions)
|
||||
var scale_factor := 1.0
|
||||
if part_name != "head" and part_length > 0.0001:
|
||||
scale_factor = bone_length / part_length
|
||||
|
||||
_reset_own_geometry(visual)
|
||||
_clear_visual_children(visual)
|
||||
|
||||
for shape in shapes:
|
||||
if shape is Dictionary:
|
||||
_mount_shape(visual, shape as Dictionary, pivot, scale_factor)
|
||||
|
||||
|
||||
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 _mount_head_circle(visual: Node, shapes: Array) -> void:
|
||||
_clear_visual_children(visual)
|
||||
if shapes.is_empty():
|
||||
visual.set("radius", 0.0)
|
||||
return
|
||||
|
||||
var radius := 0.0
|
||||
var bbox := _compute_shapes_bbox(shapes)
|
||||
if bbox.size.x > 0.0001 or bbox.size.y > 0.0001:
|
||||
var diameter: float = bbox.size.y if bbox.size.y > 0.0001 else bbox.size.x
|
||||
radius = diameter * 0.5
|
||||
visual.set("radius", radius)
|
||||
visual.set("color", _first_shape_color(shapes))
|
||||
|
||||
|
||||
static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void:
|
||||
var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
|
||||
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, pivot: Vector2, scale_factor: float) -> 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((pt - pivot) * scale_factor)
|
||||
elif p is Vector2:
|
||||
out.append(((p as Vector2) - pivot) * scale_factor)
|
||||
elif pts_var is PackedVector2Array:
|
||||
for pt in pts_var as PackedVector2Array:
|
||||
out.append((pt - pivot) * scale_factor)
|
||||
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()
|
||||
|
||||
|
||||
static func _compute_shapes_bbox(shapes: Array) -> Rect2:
|
||||
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
|
||||
min_x = minf(min_x, float(d.get("x", 0.0)))
|
||||
min_y = minf(min_y, float(d.get("y", 0.0)))
|
||||
max_x = maxf(max_x, float(d.get("x", 0.0)))
|
||||
max_y = maxf(max_y, float(d.get("y", 0.0)))
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return Rect2()
|
||||
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
|
||||
|
||||
|
||||
static func _first_shape_color(shapes: Array) -> Color:
|
||||
for shape in shapes:
|
||||
if shape is Dictionary:
|
||||
return Color.from_string(str((shape as Dictionary).get("color", "#ffffff")), Color.WHITE)
|
||||
return Color.WHITE
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxdghe86wml5e
|
||||
@@ -42,6 +42,56 @@ const DEFAULT_PART_ORDER: PackedStringArray = [
|
||||
"right_upper_leg", "right_lower_leg"
|
||||
]
|
||||
|
||||
# Phase 7: pose silhouette guide
|
||||
const GUIDE_JOINT_RADIUS: float = 6.0 # screen-space joint dot radius (px)
|
||||
const GUIDE_SCALE: float = 1.0 # master_rig -> preview world scale (1:1, per user decision)
|
||||
const GUIDE_FIGURE_CENTER: Vector2 = Vector2(0.0, -93.75) # master-space bbox center of the 1:1 guide figure
|
||||
const GUIDE_HEAD_RADIUS: float = 100.0 # master-space head circle radius
|
||||
|
||||
const GUIDE_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.45) # cyan-blue, semi
|
||||
const GUIDE_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.45) # orange-red, semi
|
||||
const GUIDE_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.45) # white, semi
|
||||
|
||||
const GUIDE_JOINT_COLOR_LEFT: Color = Color(0.35, 0.70, 1.00, 0.65)
|
||||
const GUIDE_JOINT_COLOR_RIGHT: Color = Color(1.00, 0.50, 0.20, 0.65)
|
||||
const GUIDE_JOINT_COLOR_CENTRAL: Color = Color(1.00, 1.00, 1.00, 0.65)
|
||||
|
||||
const GUIDE_JOINTS: Dictionary = {
|
||||
"Hips": Vector2(0.0, 0.0),
|
||||
"Neck": Vector2(0.0, -391.5),
|
||||
"Head": Vector2(0.0, -463.5),
|
||||
"LeftShoulder": Vector2(0.0, -248.0),
|
||||
"RightShoulder": Vector2(0.0, -248.0),
|
||||
"LeftElbow": Vector2(-168.0, -256.0),
|
||||
"RightElbow": Vector2(168.0, -256.0),
|
||||
"LeftWrist": Vector2(-168.0, -456.0),
|
||||
"RightWrist": Vector2(168.0, -456.0),
|
||||
"LeftKnee": Vector2(-95.0, 176.0),
|
||||
"RightKnee": Vector2(95.0, 176.0),
|
||||
"LeftAnkle": Vector2(-96.0, 376.0),
|
||||
"RightAnkle": Vector2(96.0, 376.0),
|
||||
}
|
||||
|
||||
const GUIDE_LEFT_JOINTS: PackedStringArray = [
|
||||
"LeftShoulder", "LeftElbow", "LeftWrist", "LeftKnee", "LeftAnkle"
|
||||
]
|
||||
const GUIDE_RIGHT_JOINTS: PackedStringArray = [
|
||||
"RightShoulder", "RightElbow", "RightWrist", "RightKnee", "RightAnkle"
|
||||
]
|
||||
# any joint not in LEFT/RIGHT is central (Hips, Neck, Head)
|
||||
|
||||
const GUIDE_BONES: Array = [
|
||||
["Hips", "Neck"], # spine (central)
|
||||
["LeftShoulder", "LeftElbow"], # left upper arm
|
||||
["LeftElbow", "LeftWrist"], # left forearm
|
||||
["RightShoulder", "RightElbow"], # right upper arm
|
||||
["RightElbow", "RightWrist"], # right forearm
|
||||
["Hips", "LeftKnee"], # left thigh
|
||||
["LeftKnee", "LeftAnkle"], # left shin
|
||||
["Hips", "RightKnee"], # right thigh
|
||||
["RightKnee", "RightAnkle"], # right shin
|
||||
]
|
||||
|
||||
enum Interaction { NONE, TRANSLATE, ROTATE, SCALE }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,6 +145,9 @@ var _context_menu: PopupMenu
|
||||
# Phase 6: selection gizmos rendered on top
|
||||
var _selected_gizmo_bounds: Rect2 = Rect2()
|
||||
|
||||
# Phase 7: pose silhouette guide (default ON per user decision)
|
||||
var _show_guide: bool = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -212,6 +265,12 @@ func set_snap_enabled(enabled: bool) -> void:
|
||||
_snap_enabled = enabled
|
||||
|
||||
|
||||
# Phase 7
|
||||
func set_show_guide(enabled: bool) -> void:
|
||||
_show_guide = enabled
|
||||
preview_area.queue_redraw()
|
||||
|
||||
|
||||
func reset_view() -> void:
|
||||
_zoom = 1.0
|
||||
_pan_offset = Vector2.ZERO
|
||||
@@ -323,6 +382,9 @@ func _on_preview_draw() -> void:
|
||||
if bounds.has_area():
|
||||
_selected_gizmo_bounds = bounds
|
||||
|
||||
# Phase 7: ghost the silhouette guide IN FRONT of parts (below selection affordances)
|
||||
_draw_silhouette_guide()
|
||||
|
||||
# Highlight dragged part
|
||||
if not _dragging_part.is_empty() and _interaction == Interaction.TRANSLATE:
|
||||
var bounds: Variant = _part_bounds.get(_dragging_part)
|
||||
@@ -373,6 +435,58 @@ func _draw_grid() -> void:
|
||||
y += gs
|
||||
|
||||
|
||||
func _draw_silhouette_guide() -> void:
|
||||
if not _show_guide:
|
||||
return
|
||||
|
||||
var lw: float = 1.5 / _zoom # match _draw_polyline_preview stroke
|
||||
var jr: float = GUIDE_JOINT_RADIUS / _zoom # constant 6px screen size
|
||||
|
||||
# 1. Limb / spine segments (semi-transparent)
|
||||
for bone: Array in GUIDE_BONES:
|
||||
var a: String = bone[0] as String
|
||||
var b: String = bone[1] as String
|
||||
if not (GUIDE_JOINTS.has(a) and GUIDE_JOINTS.has(b)):
|
||||
continue
|
||||
var pa: Vector2 = _guide_to_preview(GUIDE_JOINTS[a])
|
||||
var pb: Vector2 = _guide_to_preview(GUIDE_JOINTS[b])
|
||||
preview_area.draw_line(pa, pb, _guide_color(a), lw)
|
||||
|
||||
# 2. Head outline circle (central, semi-transparent)
|
||||
var head_center: Vector2 = _guide_to_preview(GUIDE_JOINTS["Head"])
|
||||
preview_area.draw_arc(
|
||||
head_center,
|
||||
GUIDE_HEAD_RADIUS * GUIDE_SCALE,
|
||||
0.0, TAU, 48,
|
||||
GUIDE_COLOR_CENTRAL, lw
|
||||
)
|
||||
|
||||
# 3. Joint anchors (constant screen size)
|
||||
for joint: String in GUIDE_JOINTS:
|
||||
var p: Vector2 = _guide_to_preview(GUIDE_JOINTS[joint])
|
||||
preview_area.draw_circle(p, jr, _guide_joint_color(joint))
|
||||
|
||||
|
||||
func _guide_to_preview(master_pos: Vector2) -> Vector2:
|
||||
return (master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5
|
||||
|
||||
|
||||
func _guide_color(joint_name: String) -> Color:
|
||||
if GUIDE_LEFT_JOINTS.has(joint_name):
|
||||
return GUIDE_COLOR_LEFT
|
||||
if GUIDE_RIGHT_JOINTS.has(joint_name):
|
||||
return GUIDE_COLOR_RIGHT
|
||||
return GUIDE_COLOR_CENTRAL
|
||||
|
||||
|
||||
func _guide_joint_color(joint_name: String) -> Color:
|
||||
if GUIDE_LEFT_JOINTS.has(joint_name):
|
||||
return GUIDE_JOINT_COLOR_LEFT
|
||||
if GUIDE_RIGHT_JOINTS.has(joint_name):
|
||||
return GUIDE_JOINT_COLOR_RIGHT
|
||||
return GUIDE_JOINT_COLOR_CENTRAL
|
||||
|
||||
|
||||
func _draw_polyline_preview(pts: PackedVector2Array, color: Color, closed: bool) -> void:
|
||||
for i: int in range(pts.size() - 1):
|
||||
preview_area.draw_line(pts[i], pts[i + 1], color, 1.5)
|
||||
|
||||
Reference in New Issue
Block a user