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