Files
stickman/scripts/whole_stickman_preview.gd
T
ryan 28e8798021 Implement Phase 6 features for Stickman Editor
- Updated BUGS.md with new issues related to selection bounding box, touchpad panning speed, and zooming behavior.
- Enhanced PROJECT.md with detailed specifications for touchpad controls, recent colors in the color picker, and a status bar for project information.
- Modified project.godot to update compatibility version to 4.7.
- Added a status bar in stickman_editor.tscn to display cursor coordinates and snap status.
- Updated body_part_panel.gd to handle recent colors and cursor detection over drawing areas.
- Enhanced stickman_editor.gd to manage cursor coordinates display and recent colors tracking.
- Improved whole_stickman_preview.gd to render selection gizmos on top of other parts.
- Created master_rig.tscn for the stickman rig structure with bones and IK targets.
- Developed master_rig_builder.gd to automate the rig building process with remote transforms and IK modifications.
2026-08-14 21:49:06 -04:00

904 lines
28 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"
]
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()
# ---------------------------------------------------------------------------
# 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
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
# 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_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()