- Added draggable handles for the torso and head to the test harness. - Updated `IK_HANDLE_PATHS` to include new entries for "Head" and "Torso". - Implemented distinct colors for the torso (magenta) and head (yellow) markers. - Added a visual aid (aim line) to indicate the head's LookAt target direction. - Ensured that dragging the torso moves only the torso marker, allowing for limb stretching towards stationary targets.
1028 lines
33 KiB
GDScript
1028 lines
33 KiB
GDScript
extends PanelContainer
|
|
class_name WholeStickmanPreview
|
|
## WholeStickmanPreview - Renders all body-part shapes assembled together.
|
|
##
|
|
## The user can drag individual body parts around the preview area
|
|
## to reposition them relative to one another.
|
|
##
|
|
## Phase 2: Zoom support (mouse wheel + draw_set_transform), closed-based
|
|
## rendering instead of shape_type-based, input coordinate transforms.
|
|
## Phase 3: Middle-mouse panning, background grid, snap-to-grid on part drag,
|
|
## Reset Views, settings broadcast integration.
|
|
## Phase 4: Selection with white bounding box, rotation gizmo (circle below),
|
|
## scale gizmo (corner crosses), Ctrl snap for rotation/scale, multi-shape
|
|
## rendering per part.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
signal part_moved(part_name: String, new_position: Vector2)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
|
|
const MIN_ZOOM: float = 0.3
|
|
const MAX_ZOOM: float = 3.0
|
|
const ZOOM_STEP: float = 1.10
|
|
|
|
const ROTATION_CIRCLE_RADIUS: float = 5.0
|
|
const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0
|
|
const ROTATION_CIRCLE_OFFSET: float = 26.0
|
|
const SCALE_CROSS_SIZE: float = 5.0
|
|
const SCALE_CROSS_HIT_RADIUS: float = 12.0
|
|
|
|
const DEFAULT_PART_ORDER: 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"
|
|
]
|
|
|
|
# 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 }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# On-ready node references
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@onready var preview_area: Control = %PreviewArea
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _part_shapes: Dictionary = {}
|
|
var _part_positions: Dictionary = {}
|
|
var _part_rotations: Dictionary = {}
|
|
var _part_scales: Dictionary = {}
|
|
var _part_bounds: Dictionary = {}
|
|
|
|
# Translation drag
|
|
var _dragging_part: String = ""
|
|
var _drag_offset: Vector2 = Vector2.ZERO
|
|
|
|
# Phase 4 interaction state
|
|
var _selected_part: String = ""
|
|
var _interaction: int = Interaction.NONE
|
|
|
|
# Rotation state
|
|
var _rotate_start_rotation: float = 0.0
|
|
var _rotate_total_delta: float = 0.0
|
|
var _rotate_prev_angle: float = 0.0
|
|
|
|
# Scale state
|
|
var _scale_corner: int = -1
|
|
var _scale_anchor: Vector2 = Vector2.ZERO
|
|
var _scale_original_size: Vector2 = Vector2.ZERO
|
|
|
|
# Zoom + pan
|
|
var _zoom: float = 1.0
|
|
|
|
# Phase 3 state
|
|
var _pan_offset: Vector2 = Vector2.ZERO
|
|
var _grid_size: int = 15
|
|
var _snap_enabled: bool = false
|
|
var _is_panning: bool = false
|
|
var _pan_start: Vector2 = Vector2.ZERO
|
|
|
|
# Phase 5: z-ordering
|
|
var _part_order: Array[String] = []
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_body_parts(parts_data: Dictionary) -> void:
|
|
## parts_data: { part_name: { shapes: Array, position?, rotation?, scale? } }
|
|
for part_name: String in parts_data:
|
|
var entry: Variant = parts_data[part_name]
|
|
if entry is Dictionary:
|
|
var d := entry as Dictionary
|
|
# Phase 4: extract shapes array
|
|
if d.has("shapes"):
|
|
_part_shapes[part_name] = _variant_to_shapes_array(d["shapes"])
|
|
else:
|
|
# Backward compat: single shape dict (v1.0/v1.1 data shape)
|
|
_part_shapes[part_name] = [_variant_to_single_shape(d)]
|
|
|
|
# Position
|
|
var pos_v: Variant = d.get("position")
|
|
if pos_v is Dictionary:
|
|
var pd := pos_v as Dictionary
|
|
_part_positions[part_name] = Vector2(
|
|
float(pd.get("x", 0)), float(pd.get("y", 0)))
|
|
elif not _part_positions.has(part_name):
|
|
_part_positions[part_name] = Vector2.ZERO
|
|
|
|
# Rotation (Phase 4)
|
|
_part_rotations[part_name] = float(d.get("rotation", 0.0))
|
|
|
|
# Scale (Phase 4)
|
|
var scl_v: Variant = d.get("scale")
|
|
if scl_v is Dictionary:
|
|
var sd := scl_v as Dictionary
|
|
_part_scales[part_name] = Vector2(
|
|
float(sd.get("x", 1.0)), float(sd.get("y", 1.0)))
|
|
else:
|
|
_part_scales[part_name] = Vector2(1.0, 1.0)
|
|
else:
|
|
_part_shapes[part_name] = []
|
|
_part_positions[part_name] = Vector2.ZERO
|
|
_part_rotations[part_name] = 0.0
|
|
_part_scales[part_name] = Vector2(1.0, 1.0)
|
|
|
|
if _part_order.is_empty():
|
|
_part_order = []
|
|
_part_order.assign(DEFAULT_PART_ORDER)
|
|
|
|
for pn: String in DEFAULT_PART_ORDER:
|
|
if _part_order.find(pn) == -1:
|
|
_part_order.append(pn)
|
|
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func get_part_position(part_name: String) -> Vector2:
|
|
return _part_positions.get(part_name, Vector2.ZERO)
|
|
|
|
|
|
func set_part_position(part_name: String, pos: Vector2) -> void:
|
|
_part_positions[part_name] = pos
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func set_all_part_positions(positions: Dictionary) -> void:
|
|
for part_name: String in positions:
|
|
_part_positions[part_name] = positions[part_name]
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func get_part_rotation(part_name: String) -> float:
|
|
return _part_rotations.get(part_name, 0.0)
|
|
|
|
|
|
func set_part_rotation(part_name: String, rot: float) -> void:
|
|
_part_rotations[part_name] = rot
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func get_part_scale(part_name: String) -> Vector2:
|
|
return _part_scales.get(part_name, Vector2(1.0, 1.0))
|
|
|
|
|
|
func set_part_scale(part_name: String, scl: Vector2) -> void:
|
|
_part_scales[part_name] = scl
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func clear_all() -> void:
|
|
_part_shapes.clear()
|
|
_part_positions.clear()
|
|
_part_rotations.clear()
|
|
_part_scales.clear()
|
|
_part_bounds.clear()
|
|
_selected_part = ""
|
|
_interaction = Interaction.NONE
|
|
_pan_offset = Vector2.ZERO
|
|
_zoom = 1.0
|
|
_part_order.clear()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
# Phase 3 public API
|
|
|
|
func set_grid_size(size: int) -> void:
|
|
_grid_size = size
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
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()
|
|
|
|
|
|
## Returns the preview-space position of a guide joint (a master-space
|
|
## GUIDE_JOINTS entry mapped through _guide_to_preview). Guarded against
|
|
## unknown joint names.
|
|
func get_guide_joint_preview(joint_name: String) -> Vector2:
|
|
if not GUIDE_JOINTS.has(joint_name):
|
|
push_warning("WholeStickmanPreview: unknown guide joint '%s'." % joint_name)
|
|
return Vector2.ZERO
|
|
return _guide_to_preview(GUIDE_JOINTS[joint_name])
|
|
|
|
|
|
func reset_view() -> void:
|
|
_zoom = 1.0
|
|
_pan_offset = Vector2.ZERO
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func is_cursor_over_preview(global_pos: Vector2) -> bool:
|
|
return preview_area.get_global_rect().has_point(global_pos)
|
|
|
|
|
|
func global_to_world(global_pos: Vector2) -> Vector2:
|
|
return _screen_to_world(global_pos - preview_area.global_position)
|
|
|
|
|
|
func get_part_order() -> Array:
|
|
return _part_order.duplicate()
|
|
|
|
|
|
func set_part_order(order: Array) -> void:
|
|
_part_order.clear()
|
|
for pn in order:
|
|
if pn is String:
|
|
_part_order.append(pn as String)
|
|
preview_area.queue_redraw()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
if not preview_area.draw.is_connected(_on_preview_draw):
|
|
preview_area.draw.connect(_on_preview_draw)
|
|
if not preview_area.gui_input.is_connected(_on_preview_gui_input):
|
|
preview_area.gui_input.connect(_on_preview_gui_input)
|
|
|
|
_context_menu = PopupMenu.new()
|
|
_context_menu.name = "PreviewContextMenu"
|
|
add_child(_context_menu)
|
|
_context_menu.id_pressed.connect(_on_context_menu_id_pressed)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Drawing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_preview_draw() -> void:
|
|
preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
|
|
|
|
_draw_grid()
|
|
_selected_gizmo_bounds = Rect2()
|
|
|
|
for part_name: String in _part_order:
|
|
if not _part_shapes.has(part_name):
|
|
continue
|
|
var shapes_array: Variant = _part_shapes.get(part_name, [])
|
|
if not shapes_array is Array:
|
|
continue
|
|
var arr: Array = shapes_array as Array
|
|
if arr.is_empty():
|
|
continue
|
|
|
|
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
|
|
var rot_deg: float = _part_rotations.get(part_name, 0.0)
|
|
var scl: Vector2 = _part_scales.get(part_name, Vector2(1.0, 1.0))
|
|
|
|
# Collect all points (translated by position) to find center
|
|
var all_raw: PackedVector2Array = PackedVector2Array()
|
|
for sd in arr:
|
|
if sd is Dictionary:
|
|
var sd_pts := _variant_to_points((sd as Dictionary).get("points", []))
|
|
for pt: Vector2 in sd_pts:
|
|
all_raw.append(pt + pos)
|
|
|
|
if all_raw.is_empty():
|
|
continue
|
|
|
|
var center: Vector2 = _compute_center(all_raw)
|
|
|
|
# Draw each shape
|
|
for sd in arr:
|
|
if not sd is Dictionary:
|
|
continue
|
|
var sdd := sd as Dictionary
|
|
var pts := _variant_to_points(sdd.get("points", []))
|
|
if pts.size() < 2:
|
|
continue
|
|
|
|
var color := Color.from_string(str(sdd.get("color", "#000000")), Color.BLACK)
|
|
var closed: bool = bool(sdd.get("closed", false))
|
|
|
|
var transformed := PackedVector2Array()
|
|
for pt: Vector2 in pts:
|
|
transformed.append(_transform_point(pt, pos, center, rot_deg, scl))
|
|
|
|
if closed:
|
|
preview_area.draw_colored_polygon(transformed, color)
|
|
_draw_polyline_preview(transformed, color, closed)
|
|
|
|
# Draw label
|
|
var font := preview_area.get_theme_default_font()
|
|
var label := _short_label(part_name)
|
|
if font:
|
|
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
|
|
var label_pos := Vector2(bounds.position.x, bounds.position.y - 14)
|
|
preview_area.draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color.WHITE)
|
|
|
|
# Store bounds for selected part (gizmos drawn on top later)
|
|
if part_name == _selected_part:
|
|
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
|
|
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)
|
|
if bounds is Rect2:
|
|
preview_area.draw_rect(bounds as Rect2, Color(1.0, 0.8, 0.0, 0.35), false, 1.5)
|
|
|
|
# Draw selection gizmos on top of all parts
|
|
if _selected_gizmo_bounds.has_area():
|
|
var b: Rect2 = _selected_gizmo_bounds
|
|
preview_area.draw_rect(b, Color.WHITE, false, 1.5 / _zoom)
|
|
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
|
|
preview_area.draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, Color.WHITE)
|
|
var cross_half := SCALE_CROSS_SIZE / _zoom
|
|
for i: int in range(4):
|
|
var corner := _get_corner_position(b, i)
|
|
preview_area.draw_line(
|
|
corner + Vector2(-cross_half, 0),
|
|
corner + Vector2(cross_half, 0),
|
|
Color.WHITE, 1.5 / _zoom)
|
|
preview_area.draw_line(
|
|
corner + Vector2(0, -cross_half),
|
|
corner + Vector2(0, cross_half),
|
|
Color.WHITE, 1.5 / _zoom)
|
|
|
|
|
|
func _draw_grid() -> void:
|
|
var gs := float(_grid_size)
|
|
if gs <= 0:
|
|
return
|
|
|
|
var area_size := preview_area.size
|
|
var world_origin := -_pan_offset / _zoom
|
|
var world_size := area_size / _zoom
|
|
var world_end := world_origin + world_size
|
|
|
|
var start_x: float = floor(world_origin.x / gs) * gs
|
|
var start_y: float = floor(world_origin.y / gs) * gs
|
|
var line_width: float = 1.0 / _zoom
|
|
|
|
var x: float = start_x
|
|
while x <= world_end.x:
|
|
preview_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), GRID_COLOR, line_width)
|
|
x += gs
|
|
|
|
var y: float = start_y
|
|
while y <= world_end.y:
|
|
preview_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), GRID_COLOR, line_width)
|
|
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)
|
|
if closed and pts.size() >= 2:
|
|
preview_area.draw_line(pts[pts.size() - 1], pts[0], color, 1.5)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transform helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _transform_point(pt: Vector2, pos: Vector2, center: Vector2, rot_deg: float, scl: Vector2) -> Vector2:
|
|
var wp := pt + pos
|
|
wp = wp - center
|
|
wp = Vector2(wp.x * scl.x, wp.y * scl.y)
|
|
wp = wp.rotated(deg_to_rad(rot_deg))
|
|
wp = wp + center
|
|
return wp
|
|
|
|
|
|
func _compute_center(pts: PackedVector2Array) -> Vector2:
|
|
var min_x := INF
|
|
var min_y := INF
|
|
var max_x := -INF
|
|
var max_y := -INF
|
|
for pt: Vector2 in pts:
|
|
min_x = min(min_x, pt.x)
|
|
min_y = min(min_y, pt.y)
|
|
max_x = max(max_x, pt.x)
|
|
max_y = max(max_y, pt.y)
|
|
return Vector2((min_x + max_x) * 0.5, (min_y + max_y) * 0.5)
|
|
|
|
|
|
func _compute_transformed_bounds_from_points(raw_pts: PackedVector2Array, center: Vector2, rot_deg: float, scl: Vector2) -> Rect2:
|
|
if raw_pts.is_empty():
|
|
return Rect2()
|
|
var min_x := INF
|
|
var min_y := INF
|
|
var max_x := -INF
|
|
var max_y := -INF
|
|
for pt: Vector2 in raw_pts:
|
|
var wp := pt - center
|
|
wp = Vector2(wp.x * scl.x, wp.y * scl.y)
|
|
wp = wp.rotated(deg_to_rad(rot_deg))
|
|
wp = wp + center
|
|
min_x = min(min_x, wp.x)
|
|
min_y = min(min_y, wp.y)
|
|
max_x = max(max_x, wp.x)
|
|
max_y = max(max_y, wp.y)
|
|
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))
|
|
|
|
|
|
func _get_corner_position(bounds: Rect2, corner: int) -> Vector2:
|
|
match corner:
|
|
0: return bounds.position # top-left
|
|
1: return Vector2(bounds.end.x, bounds.position.y) # top-right
|
|
2: return bounds.end # bottom-right
|
|
3: return Vector2(bounds.position.x, bounds.end.y) # bottom-left
|
|
return bounds.position
|
|
|
|
|
|
func _get_opposite_corner(bounds: Rect2, corner: int) -> Vector2:
|
|
match corner:
|
|
0: return bounds.end
|
|
1: return Vector2(bounds.position.x, bounds.end.y)
|
|
2: return bounds.position
|
|
3: return Vector2(bounds.end.x, bounds.position.y)
|
|
return bounds.position
|
|
|
|
|
|
func _compute_original_bounds_size(part_name: String) -> Vector2:
|
|
var shapes_array: Variant = _part_shapes.get(part_name, [])
|
|
if not shapes_array is Array:
|
|
return Vector2.ZERO
|
|
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
|
|
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
|
|
for sd in (shapes_array as Array):
|
|
if sd is Dictionary:
|
|
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
|
|
var wp := pt + pos
|
|
min_x = min(min_x, wp.x); min_y = min(min_y, wp.y)
|
|
max_x = max(max_x, wp.x); max_y = max(max_y, wp.y)
|
|
if min_x > max_x or min_y > max_y:
|
|
return Vector2.ZERO
|
|
return Vector2(max_x - min_x, max_y - min_y)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GUI Input
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_preview_gui_input(event: InputEvent) -> void:
|
|
if event is InputEventMagnifyGesture:
|
|
var mag := event as InputEventMagnifyGesture
|
|
var cursor_local := preview_area.get_local_mouse_position()
|
|
var world := _screen_to_world(cursor_local)
|
|
_zoom = clampf(_zoom * mag.factor, MIN_ZOOM, MAX_ZOOM)
|
|
_pan_offset = cursor_local - world * _zoom
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
if event is InputEventPanGesture:
|
|
var pan := event as InputEventPanGesture
|
|
_pan_offset -= pan.delta * 3.0
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
if event is InputEventMouseButton:
|
|
var mb := event as InputEventMouseButton
|
|
|
|
# Middle mouse -> panning
|
|
if mb.button_index == MOUSE_BUTTON_MIDDLE:
|
|
if mb.pressed:
|
|
_is_panning = true
|
|
_pan_start = mb.position
|
|
_end_interaction()
|
|
else:
|
|
_is_panning = false
|
|
return
|
|
|
|
# Mouse wheel -> zoom
|
|
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
|
var cursor_local := mb.position
|
|
var world := _screen_to_world(cursor_local)
|
|
_zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
|
_pan_offset = cursor_local - world * _zoom
|
|
preview_area.queue_redraw()
|
|
return
|
|
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
|
var cursor_local := mb.position
|
|
var world := _screen_to_world(cursor_local)
|
|
_zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
|
_pan_offset = cursor_local - world * _zoom
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
# Left mouse button
|
|
if mb.button_index == MOUSE_BUTTON_LEFT:
|
|
if mb.pressed:
|
|
var world_pos := _screen_to_world(mb.position)
|
|
_try_start_interaction(world_pos)
|
|
else:
|
|
_end_interaction()
|
|
|
|
# Right mouse button -> context menu
|
|
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
|
_handle_right_click(mb)
|
|
return
|
|
|
|
elif event is InputEventMouseMotion:
|
|
var mm := event as InputEventMouseMotion
|
|
|
|
if _is_panning:
|
|
_pan_offset += (mm.position - _pan_start)
|
|
_pan_start = mm.position
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
match _interaction:
|
|
Interaction.TRANSLATE:
|
|
_handle_translate_drag(mm)
|
|
Interaction.ROTATE:
|
|
_handle_rotate_drag(mm)
|
|
Interaction.SCALE:
|
|
_handle_scale_drag(mm)
|
|
|
|
|
|
func _screen_to_world(pos: Vector2) -> Vector2:
|
|
return (pos - _pan_offset) / _zoom
|
|
|
|
|
|
func _try_start_interaction(world_pos: Vector2) -> void:
|
|
_interaction = Interaction.NONE
|
|
|
|
# Check gizmos on selected part first
|
|
if not _selected_part.is_empty():
|
|
var shapes_array: Variant = _part_shapes.get(_selected_part, [])
|
|
if shapes_array is Array and not (shapes_array as Array).is_empty():
|
|
var all_raw := _collect_all_raw_points(_selected_part)
|
|
if not all_raw.is_empty():
|
|
var pos: Vector2 = _part_positions.get(_selected_part, Vector2.ZERO)
|
|
var rot_deg: float = _part_rotations.get(_selected_part, 0.0)
|
|
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
|
|
var center := _compute_center(all_raw)
|
|
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
|
|
|
|
if bounds.has_area():
|
|
# Rotation circle hit
|
|
var rot_center := Vector2(bounds.position.x + bounds.size.x * 0.5, bounds.end.y + ROTATION_CIRCLE_OFFSET)
|
|
if world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom:
|
|
_interaction = Interaction.ROTATE
|
|
_rotate_start_rotation = _part_rotations[_selected_part]
|
|
_rotate_total_delta = 0.0
|
|
var bounds_center := bounds.position + bounds.size * 0.5
|
|
_rotate_prev_angle = rad_to_deg(bounds_center.angle_to_point(world_pos))
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
# Scale cross hit
|
|
for i: int in range(4):
|
|
var corner := _get_corner_position(bounds, i)
|
|
if world_pos.distance_to(corner) <= SCALE_CROSS_HIT_RADIUS / _zoom:
|
|
_interaction = Interaction.SCALE
|
|
_scale_corner = i
|
|
_scale_anchor = _get_opposite_corner(bounds, i)
|
|
_scale_original_size = _compute_original_bounds_size(_selected_part)
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
# Check part hit-test for translation (reverse order: front to back)
|
|
for i: int in range(_part_order.size() - 1, -1, -1):
|
|
var part_name: String = _part_order[i]
|
|
if not _part_bounds.has(part_name):
|
|
continue
|
|
var bounds: Variant = _part_bounds[part_name]
|
|
if bounds is Rect2:
|
|
var rect: Rect2 = bounds as Rect2
|
|
if rect.grow(8.0).has_point(world_pos):
|
|
_selected_part = part_name
|
|
_interaction = Interaction.TRANSLATE
|
|
_dragging_part = part_name
|
|
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
|
|
_drag_offset = world_pos - pos
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
# Clicked on empty space -> deselect
|
|
_selected_part = ""
|
|
_interaction = Interaction.NONE
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _end_interaction() -> void:
|
|
if _interaction != Interaction.NONE:
|
|
if _interaction == Interaction.TRANSLATE and not _dragging_part.is_empty():
|
|
_dragging_part = ""
|
|
_drag_offset = Vector2.ZERO
|
|
_interaction = Interaction.NONE
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _handle_translate_drag(mm: InputEventMouseMotion) -> void:
|
|
if _dragging_part.is_empty():
|
|
return
|
|
var world_pos := _screen_to_world(mm.position)
|
|
var new_pos := world_pos - _drag_offset
|
|
_part_positions[_dragging_part] = new_pos
|
|
_rebuild_bounds()
|
|
|
|
if _snap_enabled:
|
|
var bounds: Variant = _part_bounds.get(_dragging_part)
|
|
if bounds is Rect2:
|
|
var bb_top_left := (bounds as Rect2).position
|
|
var gs := float(_grid_size)
|
|
if gs > 0.0:
|
|
var snapped_tl := Vector2(
|
|
round(bb_top_left.x / gs) * gs,
|
|
round(bb_top_left.y / gs) * gs
|
|
)
|
|
new_pos += snapped_tl - bb_top_left
|
|
_part_positions[_dragging_part] = new_pos
|
|
_rebuild_bounds()
|
|
|
|
preview_area.queue_redraw()
|
|
part_moved.emit(_dragging_part, new_pos)
|
|
|
|
|
|
func _handle_rotate_drag(mm: InputEventMouseMotion) -> void:
|
|
if _selected_part.is_empty():
|
|
return
|
|
|
|
var all_raw := _collect_all_raw_points(_selected_part)
|
|
if all_raw.is_empty():
|
|
return
|
|
var center := _compute_center(all_raw)
|
|
var rot_deg: float = _part_rotations.get(_selected_part, 0.0)
|
|
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
|
|
var bounds := _compute_transformed_bounds_from_points(all_raw, center, rot_deg, scl)
|
|
var bounds_center := bounds.position + bounds.size * 0.5
|
|
|
|
var world_pos := _screen_to_world(mm.position)
|
|
var current_angle := rad_to_deg(bounds_center.angle_to_point(world_pos))
|
|
var frame_delta := current_angle - _rotate_prev_angle
|
|
if frame_delta > 180.0:
|
|
frame_delta -= 360.0
|
|
elif frame_delta < -180.0:
|
|
frame_delta += 360.0
|
|
_rotate_total_delta += frame_delta
|
|
_rotate_prev_angle = current_angle
|
|
|
|
var new_rotation := _rotate_start_rotation + _rotate_total_delta
|
|
if Input.is_key_pressed(KEY_CTRL):
|
|
new_rotation = round(new_rotation / 15.0) * 15.0
|
|
_part_rotations[_selected_part] = new_rotation
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _handle_scale_drag(mm: InputEventMouseMotion) -> void:
|
|
if _selected_part.is_empty() or _scale_corner < 0:
|
|
return
|
|
if _scale_original_size.x <= 0 or _scale_original_size.y <= 0:
|
|
return
|
|
|
|
var all_raw := _collect_all_raw_points(_selected_part)
|
|
if all_raw.is_empty():
|
|
return
|
|
var center := _compute_center(all_raw)
|
|
|
|
var world_pos := _screen_to_world(mm.position)
|
|
if _snap_enabled:
|
|
var gs := float(_grid_size)
|
|
if gs > 0.0:
|
|
world_pos.x = round(world_pos.x / gs) * gs
|
|
world_pos.y = round(world_pos.y / gs) * gs
|
|
|
|
var rot_rad := deg_to_rad(_part_rotations.get(_selected_part, 0.0))
|
|
|
|
var mouse_rel := world_pos - center
|
|
var anchor_rel := _scale_anchor - center
|
|
var mouse_unrot := mouse_rel.rotated(-rot_rad)
|
|
var anchor_unrot := anchor_rel.rotated(-rot_rad)
|
|
|
|
var new_scale := Vector2(
|
|
abs(mouse_unrot.x - anchor_unrot.x) / _scale_original_size.x,
|
|
abs(mouse_unrot.y - anchor_unrot.y) / _scale_original_size.y
|
|
)
|
|
new_scale = Vector2(max(new_scale.x, 0.01), max(new_scale.y, 0.01))
|
|
|
|
if Input.is_key_pressed(KEY_CTRL):
|
|
var uniform := maxf(new_scale.x, new_scale.y)
|
|
new_scale = Vector2(uniform, uniform)
|
|
|
|
_part_scales[_selected_part] = new_scale
|
|
preview_area.queue_redraw()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _collect_all_raw_points(part_name: String) -> PackedVector2Array:
|
|
var shapes_array: Variant = _part_shapes.get(part_name, [])
|
|
if not shapes_array is Array:
|
|
return PackedVector2Array()
|
|
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
|
|
var result := PackedVector2Array()
|
|
for sd in (shapes_array as Array):
|
|
if sd is Dictionary:
|
|
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
|
|
result.append(pt + pos)
|
|
return result
|
|
|
|
|
|
func _variant_to_shapes_array(data: Variant) -> Array:
|
|
if data is Array:
|
|
var arr: Array = []
|
|
for item in (data as Array):
|
|
if item is Dictionary:
|
|
arr.append(_variant_to_single_shape(item))
|
|
return arr
|
|
return []
|
|
|
|
|
|
func _variant_to_single_shape(d: Dictionary) -> Dictionary:
|
|
return {
|
|
"shape_type": d.get("shape_type", ""),
|
|
"points": _variant_to_points(d.get("points", [])),
|
|
"color": d.get("color", "#000000"),
|
|
"closed": d.get("closed", false),
|
|
"vertex_flags": _variant_to_int_array(d.get("vertex_flags", [])),
|
|
}
|
|
|
|
|
|
func _variant_to_points(pts_variant: Variant) -> PackedVector2Array:
|
|
if pts_variant is PackedVector2Array:
|
|
return pts_variant as PackedVector2Array
|
|
if pts_variant is Array:
|
|
var arr: Array = pts_variant as Array
|
|
var out := PackedVector2Array()
|
|
for item in arr:
|
|
if item is Dictionary:
|
|
var d: Dictionary = item as Dictionary
|
|
out.append(Vector2(float(d.get("x", 0)), float(d.get("y", 0))))
|
|
elif item is Vector2:
|
|
out.append(item as Vector2)
|
|
return out
|
|
return PackedVector2Array()
|
|
|
|
|
|
func _variant_to_int_array(vf_variant: Variant) -> PackedInt32Array:
|
|
if vf_variant is PackedInt32Array:
|
|
return vf_variant as PackedInt32Array
|
|
if vf_variant is Array:
|
|
var arr: Array = vf_variant as Array
|
|
var out := PackedInt32Array()
|
|
out.resize(arr.size())
|
|
for i: int in arr.size():
|
|
out[i] = int(arr[i])
|
|
return out
|
|
return PackedInt32Array()
|
|
|
|
|
|
func _rebuild_bounds() -> void:
|
|
_part_bounds.clear()
|
|
for part_name: String in _part_order:
|
|
if not _part_shapes.has(part_name):
|
|
continue
|
|
var shapes_array: Variant = _part_shapes.get(part_name, [])
|
|
if not shapes_array is Array:
|
|
continue
|
|
var pos: Vector2 = _part_positions.get(part_name, Vector2.ZERO)
|
|
var rot_deg: float = _part_rotations.get(part_name, 0.0)
|
|
var scl: Vector2 = _part_scales.get(part_name, Vector2(1.0, 1.0))
|
|
|
|
var all_raw := PackedVector2Array()
|
|
for sd in (shapes_array as Array):
|
|
if sd is Dictionary:
|
|
for pt: Vector2 in _variant_to_points((sd as Dictionary).get("points", [])):
|
|
all_raw.append(pt + pos)
|
|
|
|
if all_raw.is_empty():
|
|
continue
|
|
|
|
var center := _compute_center(all_raw)
|
|
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
|
|
for pt: Vector2 in all_raw:
|
|
var transformed := _transform_point(pt, Vector2.ZERO, center, rot_deg, scl)
|
|
min_x = min(min_x, transformed.x)
|
|
min_y = min(min_y, transformed.y)
|
|
max_x = max(max_x, transformed.x)
|
|
max_y = max(max_y, transformed.y)
|
|
|
|
if min_x <= max_x and min_y <= max_y:
|
|
_part_bounds[part_name] = Rect2(
|
|
Vector2(min_x, min_y),
|
|
Vector2(max_x - min_x, max_y - min_y)
|
|
)
|
|
|
|
|
|
func _short_label(part_name: String) -> String:
|
|
match part_name:
|
|
"head": return "H"
|
|
"torso": return "T"
|
|
"left_upper_arm": return "LUA"
|
|
"left_lower_arm": return "LLA"
|
|
"right_upper_arm": return "RUA"
|
|
"right_lower_arm": return "RLA"
|
|
"left_upper_leg": return "LUL"
|
|
"left_lower_leg": return "LLL"
|
|
"right_upper_leg": return "RUL"
|
|
"right_lower_leg": return "RLL"
|
|
return part_name.left(3)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5: Context menu, z-ordering, mirroring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _handle_right_click(mb: InputEventMouseButton) -> void:
|
|
var world_pos := _screen_to_world(mb.position)
|
|
|
|
for i: int in range(_part_order.size() - 1, -1, -1):
|
|
var part_name: String = _part_order[i]
|
|
if not _part_bounds.has(part_name):
|
|
continue
|
|
var bounds: Variant = _part_bounds[part_name]
|
|
if bounds is Rect2:
|
|
var rect: Rect2 = bounds as Rect2
|
|
if rect.has_point(world_pos):
|
|
_selected_part = part_name
|
|
_context_menu.clear()
|
|
_context_menu.add_item("Send Back", 0)
|
|
_context_menu.add_item("Bring Forward", 1)
|
|
_context_menu.add_separator()
|
|
_context_menu.add_item("Mirror X", 2)
|
|
_context_menu.add_item("Mirror Y", 3)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
preview_area.queue_redraw()
|
|
return
|
|
|
|
_selected_part = ""
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _on_context_menu_id_pressed(id: int) -> void:
|
|
match id:
|
|
0: _send_back_part()
|
|
1: _bring_forward_part()
|
|
2: _mirror_part_x()
|
|
3: _mirror_part_y()
|
|
|
|
|
|
func _send_back_part() -> void:
|
|
if _selected_part.is_empty():
|
|
return
|
|
var idx := _part_order.find(_selected_part)
|
|
if idx <= 0 or idx >= _part_order.size():
|
|
return
|
|
_part_order.remove_at(idx)
|
|
_part_order.insert(idx - 1, _selected_part)
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _bring_forward_part() -> void:
|
|
if _selected_part.is_empty():
|
|
return
|
|
var idx := _part_order.find(_selected_part)
|
|
if idx < 0 or idx >= _part_order.size() - 1:
|
|
return
|
|
_part_order.remove_at(idx)
|
|
_part_order.insert(idx + 1, _selected_part)
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _mirror_part_x() -> void:
|
|
if _selected_part.is_empty():
|
|
return
|
|
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
|
|
_part_scales[_selected_part] = Vector2(-scl.x, scl.y)
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|
|
|
|
|
|
func _mirror_part_y() -> void:
|
|
if _selected_part.is_empty():
|
|
return
|
|
var scl: Vector2 = _part_scales.get(_selected_part, Vector2(1.0, 1.0))
|
|
_part_scales[_selected_part] = Vector2(scl.x, -scl.y)
|
|
_rebuild_bounds()
|
|
preview_area.queue_redraw()
|