1210 lines
37 KiB
GDScript
1210 lines
37 KiB
GDScript
extends PanelContainer
|
|
class_name BodyPartPanel
|
|
## BodyPartPanel - Individual editor panel for a stickman body part.
|
|
##
|
|
## Contains a title label and a drawing area where the user can place
|
|
## vector shapes via a right-click context menu (line, rectangle, circle).
|
|
##
|
|
## Phase 2: interactive vertex editing, per-panel zoom, shape selection,
|
|
## Create Point on edges, expanded shape data model with closed/vertex_flags.
|
|
## Phase 3: middle-mouse panning, background grid, snap-to-grid,
|
|
## ColorPicker integration, Delete shape, Remove Point (vertex delete).
|
|
## Phase 4: multiple shapes per panel, z-ordering (Send Back / Bring Forward).
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
signal shape_changed(shape_data: Array)
|
|
signal shape_selected()
|
|
signal shape_copy_requested(shape_dict: Dictionary)
|
|
signal shape_paste_requested(world_pos: Vector2)
|
|
signal color_selected(color: Color)
|
|
signal presentation_safe
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exported / public properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@export var part_name: String = "":
|
|
set(v):
|
|
part_name = v
|
|
if is_inside_tree() and title_label:
|
|
_update_title()
|
|
@export var display_name: String = "":
|
|
set(v):
|
|
display_name = v
|
|
if is_inside_tree() and title_label:
|
|
_update_title()
|
|
|
|
## Array of shape dictionaries:
|
|
## shape_type : String - "line", "rectangle", "circle", or "" (empty)
|
|
## points : PackedVector2Array - local positions in the drawing area
|
|
## color : String - hex colour e.g. "#000000"
|
|
## closed : bool - true for fill+closed outline, false for open outline
|
|
## vertex_flags : PackedInt32Array - 0=original, 1=user_created; same length as points
|
|
var shapes: Array[Dictionary] = []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const HANDLE_RADIUS: float = 3.0
|
|
const HANDLE_RADIUS_SELECTION: float = 8.0
|
|
const HANDLE_HOLLOW_SIZE: float = 6.0
|
|
const EDGE_HIT_THRESHOLD: float = 12.0
|
|
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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# On-ready node references
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@onready var title_label: Label = %TitleLabel
|
|
@onready var drawing_area: Control = %DrawingArea
|
|
@onready var context_menu: EditorContextMenu = %ContextMenu
|
|
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
|
|
@onready var color_picker: ColorPicker = %ColorPicker
|
|
@onready var color_picker_button_row: HBoxContainer = %ButtonRow
|
|
@onready var cancel_button: Button = %CancelButton
|
|
@onready var ok_button: Button = %OKButton
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var _drawing_area_initialized := false
|
|
var _selected_shape_idx: int = -1
|
|
var _dragging_vertex: int = -1
|
|
var _drag_offset: Vector2 = Vector2.ZERO
|
|
var _is_dragging_shape: bool = false
|
|
var _shape_drag_start: Vector2 = Vector2.ZERO
|
|
var _shape_drag_original_points: PackedVector2Array = PackedVector2Array()
|
|
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
|
|
var _restore_color: Color = Color.BLACK
|
|
var _grid_color: Color = GRID_COLOR
|
|
var _selection_core_color: Color = Color.WHITE
|
|
var _selection_halo_color: Color = Color(0.0, 0.0, 0.0, 0.85)
|
|
var _hint_color: Color = Color(0.67, 0.70, 0.75, 0.8)
|
|
var _button_target_height: float = 44.0
|
|
var _editor_visual_theme: EditorVisualTheme
|
|
|
|
# Phase 4: tracks which shape was right-clicked for context menu ops.
|
|
var _context_shape_idx: int = -1
|
|
|
|
# Phase 5: clipboard state
|
|
var _has_clipboard: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
_update_title()
|
|
|
|
if not drawing_area.draw.is_connected(_on_drawing_area_draw):
|
|
drawing_area.draw.connect(_on_drawing_area_draw)
|
|
if not drawing_area.gui_input.is_connected(_on_drawing_area_gui_input):
|
|
drawing_area.gui_input.connect(_on_drawing_area_gui_input)
|
|
if not context_menu.id_pressed.is_connected(_on_context_menu_id_pressed):
|
|
context_menu.id_pressed.connect(_on_context_menu_id_pressed)
|
|
|
|
# Phase 3: Wire ColorPicker signals.
|
|
if not color_picker.color_changed.is_connected(_on_color_picker_changed):
|
|
color_picker.color_changed.connect(_on_color_picker_changed)
|
|
if not ok_button.pressed.is_connected(_on_color_picker_ok):
|
|
ok_button.pressed.connect(_on_color_picker_ok)
|
|
if not cancel_button.pressed.is_connected(_on_color_picker_cancel):
|
|
cancel_button.pressed.connect(_on_color_picker_cancel)
|
|
if not context_menu.popup_hide.is_connected(_on_presentation_popup_hidden):
|
|
context_menu.popup_hide.connect(_on_presentation_popup_hidden)
|
|
if _editor_visual_theme != null:
|
|
context_menu.configure(_editor_visual_theme, &"stickman")
|
|
if not color_picker_popup.popup_hide.is_connected(_on_presentation_popup_hidden):
|
|
color_picker_popup.popup_hide.connect(_on_presentation_popup_hidden)
|
|
cancel_button.text = "Cancel"
|
|
cancel_button.custom_minimum_size.y = _button_target_height
|
|
ok_button.text = "Use Color"
|
|
ok_button.custom_minimum_size.y = _button_target_height
|
|
color_picker_button_row.add_theme_constant_override("separation", 12)
|
|
if _editor_visual_theme != null:
|
|
_editor_visual_theme.style_button_role(ok_button, &"primary", &"stickman")
|
|
_editor_visual_theme.style_button_role(cancel_button, &"secondary", &"stickman")
|
|
|
|
_drawing_area_initialized = true
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_shape_data(data: Variant) -> void:
|
|
## Import shape data. Accepts an Array[Dictionary] (v1.2) or a single
|
|
## Dictionary (v1.0/v1.1 backward compat, wrapped in a 1-element array).
|
|
shapes.clear()
|
|
|
|
var source: Array = _coerce_to_array(data)
|
|
for item in source:
|
|
if item is Dictionary:
|
|
var sd: Dictionary = _parse_single_shape(item as Dictionary)
|
|
shapes.append(sd)
|
|
|
|
_selected_shape_idx = -1
|
|
_dragging_vertex = -1
|
|
_zoom = 1.0
|
|
_pan_offset = Vector2.ZERO
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func _coerce_to_array(data: Variant) -> Array:
|
|
if data is Array:
|
|
return data
|
|
if data is Dictionary:
|
|
return [data]
|
|
return []
|
|
|
|
|
|
func _parse_single_shape(item: Dictionary) -> Dictionary:
|
|
var sd := {
|
|
"shape_type": item.get("shape_type", ""),
|
|
"color": item.get("color", "#000000"),
|
|
}
|
|
|
|
var pts_variant: Variant = item.get("points", [])
|
|
var pts := PackedVector2Array()
|
|
if pts_variant is Array:
|
|
for p in (pts_variant as Array):
|
|
if p is Dictionary:
|
|
var d := p as Dictionary
|
|
pts.append(Vector2(float(d.get("x", 0)), float(d.get("y", 0))))
|
|
elif p is Vector2:
|
|
pts.append(p as Vector2)
|
|
elif pts_variant is PackedVector2Array:
|
|
pts = pts_variant
|
|
sd["points"] = pts
|
|
|
|
if item.has("closed"):
|
|
sd["closed"] = bool(item.get("closed", false))
|
|
else:
|
|
var st: String = item.get("shape_type", "")
|
|
sd["closed"] = (st == "rectangle" or st == "circle")
|
|
|
|
var vf := PackedInt32Array()
|
|
if item.has("vertex_flags"):
|
|
var vf_variant: Variant = item.get("vertex_flags", [])
|
|
if vf_variant is Array:
|
|
var vf_arr: Array = vf_variant
|
|
vf.resize(vf_arr.size())
|
|
for i: int in vf_arr.size():
|
|
vf[i] = int(vf_arr[i])
|
|
elif vf_variant is PackedInt32Array:
|
|
vf = vf_variant
|
|
else:
|
|
vf.resize(pts.size())
|
|
sd["vertex_flags"] = vf
|
|
|
|
return sd
|
|
|
|
|
|
func get_shape_data() -> Array:
|
|
## Export all shapes as an Array of plain Dictionary.
|
|
var result: Array = []
|
|
for sd in shapes:
|
|
if sd is Dictionary:
|
|
var d := sd as Dictionary
|
|
var pts_arr: Array[Dictionary] = []
|
|
var d_pts: PackedVector2Array = d.get("points", PackedVector2Array())
|
|
for pt: Vector2 in d_pts:
|
|
pts_arr.append({"x": pt.x, "y": pt.y})
|
|
|
|
var vf_arr: Array = []
|
|
var d_vf: PackedInt32Array = d.get("vertex_flags", PackedInt32Array())
|
|
for i: int in d_vf.size():
|
|
vf_arr.append(d_vf[i])
|
|
|
|
result.append({
|
|
"shape_type": d.get("shape_type", ""),
|
|
"points": pts_arr,
|
|
"color": d.get("color", "#000000"),
|
|
"closed": d.get("closed", false),
|
|
"vertex_flags": vf_arr,
|
|
})
|
|
return result
|
|
|
|
|
|
func clear_shape() -> void:
|
|
## Reset the panel to an empty state (all shapes cleared).
|
|
shapes.clear()
|
|
_selected_shape_idx = -1
|
|
_dragging_vertex = -1
|
|
_context_shape_idx = -1
|
|
_zoom = 1.0
|
|
_pan_offset = Vector2.ZERO
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func select() -> void:
|
|
## Mark this panel as selected. Selects the topmost shape if any exist.
|
|
if shapes.size() > 0:
|
|
_selected_shape_idx = shapes.size() - 1
|
|
else:
|
|
_selected_shape_idx = -1
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func deselect() -> void:
|
|
## Clear selection and cancel any in-progress drag.
|
|
_selected_shape_idx = -1
|
|
_dragging_vertex = -1
|
|
_context_shape_idx = -1
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
# Phase 3 public API
|
|
|
|
func set_grid_size(size: int) -> void:
|
|
_grid_size = size
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func set_snap_enabled(enabled: bool) -> void:
|
|
_snap_enabled = enabled
|
|
|
|
|
|
func reset_view() -> void:
|
|
_zoom = 1.0
|
|
_pan_offset = Vector2.ZERO
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func can_switch_presentation() -> bool:
|
|
return not _is_panning \
|
|
and _dragging_vertex < 0 \
|
|
and not _is_dragging_shape \
|
|
and not context_menu.visible \
|
|
and not color_picker_popup.visible
|
|
|
|
|
|
func set_canvas_tokens(grid_color: Color, selection_core: Color, selection_halo: Color, hint_color: Color = Color(0.67, 0.70, 0.75, 0.8), button_target_height: float = 44.0, editor_visual_theme: EditorVisualTheme = null) -> void:
|
|
_grid_color = grid_color
|
|
_selection_core_color = selection_core
|
|
_selection_halo_color = selection_halo
|
|
_hint_color = hint_color
|
|
_button_target_height = button_target_height
|
|
_editor_visual_theme = editor_visual_theme
|
|
if context_menu != null and _editor_visual_theme != null:
|
|
context_menu.configure(_editor_visual_theme, &"stickman")
|
|
if drawing_area != null:
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func set_active_presentation(active: bool, surface: Color, border: Color, accent: Color, section_font_size: int = 22) -> void:
|
|
var style: StyleBoxFlat = StyleBoxFlat.new()
|
|
style.bg_color = surface
|
|
style.border_color = accent if active else border
|
|
style.set_border_width_all(2 if active else 1)
|
|
style.set_corner_radius_all(8)
|
|
style.content_margin_left = 6
|
|
style.content_margin_right = 6
|
|
style.content_margin_top = 4
|
|
style.content_margin_bottom = 4
|
|
add_theme_stylebox_override("panel", style)
|
|
title_label.add_theme_color_override("font_color", accent if active else _selection_core_color)
|
|
title_label.add_theme_font_size_override("font_size", section_font_size)
|
|
|
|
|
|
func add_shape(kind: StringName, at_world: Vector2) -> bool:
|
|
match kind:
|
|
&"line": _create_line(at_world)
|
|
&"rectangle": _create_rectangle(at_world)
|
|
&"circle": _create_circle(at_world)
|
|
_:
|
|
return false
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
return true
|
|
|
|
|
|
func selected_shape_index() -> int:
|
|
return _selected_shape_idx
|
|
|
|
|
|
func has_selected_shape() -> bool:
|
|
return _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size()
|
|
|
|
|
|
func open_selected_color_picker() -> void:
|
|
if not has_selected_shape():
|
|
return
|
|
_context_shape_idx = _selected_shape_idx
|
|
_show_color_picker()
|
|
|
|
|
|
func get_view_context() -> Dictionary:
|
|
return {"zoom": _zoom, "pan_offset": _pan_offset}
|
|
|
|
|
|
func drawing_center_world() -> Vector2:
|
|
return _screen_to_world(drawing_area.size * 0.5)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5: Clipboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_has_clipboard(state: bool) -> void:
|
|
_has_clipboard = state
|
|
|
|
|
|
func set_recent_colors(colors_hex: Array) -> void:
|
|
for preset in color_picker.get_presets():
|
|
color_picker.erase_preset(preset)
|
|
for hex_str in colors_hex:
|
|
color_picker.add_preset(Color.from_string(str(hex_str), Color.BLACK))
|
|
|
|
|
|
func paste_shape(source: Dictionary, target_pos: Vector2) -> void:
|
|
## Insert a deep copy of the clipboard shape at the given world position.
|
|
## The shape's points are translated so its center is at target_pos.
|
|
var new_sd: Dictionary = source.duplicate(true)
|
|
var src_pts: PackedVector2Array = new_sd.get("points", PackedVector2Array())
|
|
if src_pts.is_empty():
|
|
return
|
|
var min_x := INF; var min_y := INF; var max_x := -INF; var max_y := -INF
|
|
for pt: Vector2 in src_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)
|
|
var src_center := Vector2((min_x + max_x) * 0.5, (min_y + max_y) * 0.5)
|
|
var offset := target_pos - src_center
|
|
|
|
var new_pts := PackedVector2Array()
|
|
for pt: Vector2 in src_pts:
|
|
new_pts.append(pt + offset)
|
|
new_sd["points"] = new_pts
|
|
|
|
shapes.append(new_sd)
|
|
_selected_shape_idx = shapes.size() - 1
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func is_cursor_over_drawing(global_pos: Vector2) -> bool:
|
|
return drawing_area.get_global_rect().has_point(global_pos)
|
|
|
|
|
|
func global_to_world(global_pos: Vector2) -> Vector2:
|
|
return _screen_to_world(global_pos - drawing_area.global_position)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _selected_shape() -> Dictionary:
|
|
if _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
|
|
return shapes[_selected_shape_idx]
|
|
return {}
|
|
|
|
|
|
func _update_title() -> void:
|
|
if title_label:
|
|
title_label.text = display_name if not display_name.is_empty() else part_name
|
|
|
|
|
|
func _emit_changed() -> void:
|
|
shape_changed.emit(get_shape_data())
|
|
|
|
|
|
func _screen_to_world(pos: Vector2) -> Vector2:
|
|
return (pos - _pan_offset) / _zoom
|
|
|
|
|
|
func _global_point_target_rect(world_pos: Vector2) -> Rect2:
|
|
var screen_pos := drawing_area.global_position + _pan_offset + world_pos * _zoom
|
|
return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0))
|
|
|
|
|
|
func _global_shape_target_rect(sd: Dictionary) -> Rect2:
|
|
var points: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if points.is_empty():
|
|
return Rect2()
|
|
var min_point := points[0]
|
|
var max_point := points[0]
|
|
for point: Vector2 in points:
|
|
min_point = min_point.min(point)
|
|
max_point = max_point.max(point)
|
|
var screen_position := drawing_area.global_position + _pan_offset + min_point * _zoom
|
|
return Rect2(screen_position, (max_point - min_point) * _zoom).grow(4.0)
|
|
|
|
|
|
func _snap_to_grid(pos: Vector2) -> Vector2:
|
|
var gs := float(_grid_size)
|
|
if gs <= 0.0:
|
|
return pos
|
|
return Vector2(
|
|
round(pos.x / gs) * gs,
|
|
round(pos.y / gs) * gs
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Drawing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_drawing_area_draw() -> void:
|
|
drawing_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
|
|
|
|
_draw_grid()
|
|
|
|
if shapes.is_empty():
|
|
var rect := drawing_area.get_rect()
|
|
var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 80, rect.size.y * 0.5))
|
|
var font := drawing_area.get_theme_default_font()
|
|
if font:
|
|
drawing_area.draw_string(
|
|
font,
|
|
hint_pos,
|
|
"Use Add Shape above",
|
|
HORIZONTAL_ALIGNMENT_CENTER,
|
|
-1,
|
|
14,
|
|
_hint_color
|
|
)
|
|
return
|
|
|
|
# Draw shapes in z-order (array index = z-order, first = back, last = front)
|
|
for i: int in range(shapes.size()):
|
|
var sd: Dictionary = shapes[i]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
var count: int = pts.size()
|
|
if count < 2:
|
|
continue
|
|
|
|
var color: Color = Color.from_string(str(sd.get("color", "#000000")), Color.BLACK)
|
|
var closed: bool = bool(sd.get("closed", false))
|
|
|
|
# 1. Fill (if closed)
|
|
if closed:
|
|
drawing_area.draw_colored_polygon(pts, color)
|
|
|
|
# 2. Outline
|
|
_draw_polyline(pts, color, closed)
|
|
|
|
# 3. Selection highlight (only on selected shape)
|
|
if i == _selected_shape_idx:
|
|
_draw_polyline(pts, _selection_halo_color, closed, 5.0)
|
|
_draw_polyline(pts, _selection_core_color, closed, 2.5)
|
|
|
|
# 4. Vertex handles (only on selected shape)
|
|
for j: int in range(count):
|
|
var pt: Vector2 = pts[j]
|
|
var flag: int = 0
|
|
var sd_vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array())
|
|
if sd_vf.size() > j:
|
|
flag = sd_vf[j]
|
|
|
|
drawing_area.draw_circle(pt, (HANDLE_RADIUS + 2.0) / _zoom, _selection_halo_color)
|
|
var handle_color := color
|
|
if j == _dragging_vertex:
|
|
handle_color = Color.YELLOW
|
|
|
|
if flag == 0:
|
|
drawing_area.draw_circle(pt, HANDLE_RADIUS / _zoom, handle_color)
|
|
else:
|
|
var hs: float = HANDLE_HOLLOW_SIZE / _zoom * 0.5
|
|
drawing_area.draw_rect(Rect2(pt - Vector2(hs, hs), Vector2(hs * 2, hs * 2)), handle_color, false)
|
|
|
|
|
|
func _draw_grid() -> void:
|
|
var gs := float(_grid_size)
|
|
if gs <= 0:
|
|
return
|
|
|
|
var area_size := drawing_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:
|
|
drawing_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:
|
|
drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), _grid_color, line_width)
|
|
y += gs
|
|
|
|
|
|
func _draw_polyline(pts: PackedVector2Array, color: Color, closed: bool, width: float = 2.0) -> void:
|
|
for i: int in range(pts.size() - 1):
|
|
drawing_area.draw_line(pts[i], pts[i + 1], color, width)
|
|
if closed and pts.size() >= 2:
|
|
drawing_area.draw_line(pts[pts.size() - 1], pts[0], color, width)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Input / context menu
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _on_drawing_area_gui_input(event: InputEvent) -> void:
|
|
if event is InputEventMagnifyGesture:
|
|
var mag := event as InputEventMagnifyGesture
|
|
var cursor_local := drawing_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
|
|
drawing_area.queue_redraw()
|
|
return
|
|
|
|
if event is InputEventPanGesture:
|
|
var pan := event as InputEventPanGesture
|
|
_pan_offset -= pan.delta * 3.0
|
|
_dragging_vertex = -1
|
|
drawing_area.queue_redraw()
|
|
return
|
|
|
|
if event is InputEventMouseButton:
|
|
var mb := event as InputEventMouseButton
|
|
|
|
# 1. Middle mouse button -> panning
|
|
if mb.button_index == MOUSE_BUTTON_MIDDLE:
|
|
if mb.pressed:
|
|
_is_panning = true
|
|
_pan_start = mb.position
|
|
_dragging_vertex = -1
|
|
else:
|
|
_is_panning = false
|
|
_emit_presentation_safe()
|
|
return
|
|
|
|
# 2. 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
|
|
drawing_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
|
|
drawing_area.queue_redraw()
|
|
return
|
|
|
|
var world_pos := _screen_to_world(mb.position)
|
|
|
|
# 3. Right mouse button -> context menu
|
|
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
|
_handle_right_click(mb, world_pos)
|
|
return
|
|
|
|
# 4. Left mouse button -> vertex drag / shape selection
|
|
if mb.button_index == MOUSE_BUTTON_LEFT:
|
|
if mb.pressed:
|
|
_handle_left_press(world_pos)
|
|
else:
|
|
_handle_left_release()
|
|
|
|
elif event is InputEventMouseMotion:
|
|
var mm := event as InputEventMouseMotion
|
|
|
|
if _is_panning:
|
|
_pan_offset += (mm.position - _pan_start)
|
|
_pan_start = mm.position
|
|
drawing_area.queue_redraw()
|
|
return
|
|
|
|
if _dragging_vertex >= 0 and _selected_shape_idx >= 0:
|
|
var sd := _selected_shape()
|
|
var sd_pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if _dragging_vertex < sd_pts.size():
|
|
var world_pos := _screen_to_world(mm.position)
|
|
if _snap_enabled:
|
|
world_pos = _snap_to_grid(world_pos)
|
|
sd_pts[_dragging_vertex] = world_pos - _drag_offset
|
|
sd["points"] = sd_pts
|
|
drawing_area.queue_redraw()
|
|
|
|
if _is_dragging_shape and _selected_shape_idx >= 0:
|
|
var world_pos := _screen_to_world(mm.position)
|
|
if _snap_enabled:
|
|
world_pos = _snap_to_grid(world_pos)
|
|
var delta := world_pos - _shape_drag_start
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
var new_pts := PackedVector2Array()
|
|
for pt: Vector2 in _shape_drag_original_points:
|
|
new_pts.append(pt + delta)
|
|
sd["points"] = new_pts
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
|
_context_shape_idx = -1
|
|
|
|
# Step 1: Check if right-click is on a vertex of any shape (selected first).
|
|
# Check selected shape first, then others in reverse z-order (front to back).
|
|
if _selected_shape_idx >= 0:
|
|
var sd := _selected_shape()
|
|
var hit_idx := _hit_test_vertex_in_shape(sd, world_pos)
|
|
if hit_idx >= 0:
|
|
_context_shape_idx = _selected_shape_idx
|
|
context_menu.clear()
|
|
context_menu.add_destructive_item("Remove Point", 6)
|
|
context_menu.set_meta("vertex_index", hit_idx)
|
|
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
|
|
return
|
|
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
if i == _selected_shape_idx:
|
|
continue
|
|
var sd: Dictionary = shapes[i]
|
|
var hit_idx := _hit_test_vertex_in_shape(sd, world_pos)
|
|
if hit_idx >= 0:
|
|
_context_shape_idx = i
|
|
context_menu.clear()
|
|
context_menu.add_destructive_item("Remove Point", 6)
|
|
context_menu.set_meta("vertex_index", hit_idx)
|
|
context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos))
|
|
return
|
|
|
|
# Step 2: Check if right-click is over any shape (reverse order = front first).
|
|
var found_shape_idx := -1
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
var sd: Dictionary = shapes[i]
|
|
if _is_point_in_shape(sd, world_pos):
|
|
found_shape_idx = i
|
|
break
|
|
|
|
if found_shape_idx >= 0:
|
|
# Select the shape that was right-clicked
|
|
_selected_shape_idx = found_shape_idx
|
|
_context_shape_idx = found_shape_idx
|
|
drawing_area.queue_redraw()
|
|
|
|
context_menu.clear()
|
|
|
|
var found_sd: Dictionary = shapes[found_shape_idx]
|
|
if _is_point_near_outline(found_sd, world_pos):
|
|
context_menu.add_item("Create Point", 3)
|
|
context_menu.add_separator()
|
|
|
|
context_menu.add_item("Change Color…", 4)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Copy Shape", 9)
|
|
if _has_clipboard:
|
|
context_menu.add_item("Paste Shape", 10)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Flip Left-Right", 11)
|
|
context_menu.add_item("Flip Up-Down", 12)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Bring Forward", 8)
|
|
context_menu.add_item("Send Back", 7)
|
|
context_menu.add_separator()
|
|
context_menu.add_destructive_item("Erase Shape", 5)
|
|
|
|
context_menu.set_meta("click_position", world_pos)
|
|
context_menu.popup_at(mb.global_position, _global_shape_target_rect(found_sd))
|
|
return
|
|
|
|
# Step 3: No shape under mouse -> shape creation menu.
|
|
context_menu.clear()
|
|
context_menu.add_item("Line", 0)
|
|
context_menu.add_item("Rectangle", 1)
|
|
context_menu.add_item("Circle", 2)
|
|
if _has_clipboard:
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Paste Shape", 10)
|
|
context_menu.set_meta("click_position", world_pos)
|
|
context_menu.popup_at(mb.global_position)
|
|
|
|
|
|
func _handle_left_press(world_pos: Vector2) -> void:
|
|
_context_shape_idx = -1
|
|
|
|
# Check vertex hit on selected shape first.
|
|
if _selected_shape_idx >= 0:
|
|
var sd := _selected_shape()
|
|
var hit_idx := _hit_test_vertex_in_shape(sd, world_pos)
|
|
if hit_idx >= 0:
|
|
_dragging_vertex = hit_idx
|
|
var sd_pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
_drag_offset = world_pos - sd_pts[hit_idx]
|
|
drawing_area.queue_redraw()
|
|
return
|
|
|
|
# Check shape hit (reverse order = front first)
|
|
var hit_shape_idx := -1
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
var sd: Dictionary = shapes[i]
|
|
if _is_point_in_shape(sd, world_pos):
|
|
hit_shape_idx = i
|
|
break
|
|
|
|
if hit_shape_idx >= 0:
|
|
_selected_shape_idx = hit_shape_idx
|
|
# Start shape translation drag
|
|
_is_dragging_shape = true
|
|
_shape_drag_start = world_pos
|
|
var sd: Dictionary = shapes[hit_shape_idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
_shape_drag_original_points = PackedVector2Array(pts)
|
|
drawing_area.queue_redraw()
|
|
shape_selected.emit()
|
|
else:
|
|
_selected_shape_idx = -1
|
|
_dragging_vertex = -1
|
|
drawing_area.queue_redraw()
|
|
shape_selected.emit()
|
|
|
|
|
|
func _handle_left_release() -> void:
|
|
if _is_dragging_shape:
|
|
_is_dragging_shape = false
|
|
_shape_drag_original_points = PackedVector2Array()
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
_emit_presentation_safe()
|
|
return
|
|
|
|
if _dragging_vertex >= 0:
|
|
_dragging_vertex = -1
|
|
_drag_offset = Vector2.ZERO
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
_emit_presentation_safe()
|
|
return
|
|
_emit_presentation_safe()
|
|
|
|
|
|
func _on_presentation_popup_hidden() -> void:
|
|
_emit_presentation_safe()
|
|
|
|
|
|
func _emit_presentation_safe() -> void:
|
|
call_deferred("_emit_presentation_safe_if_idle")
|
|
|
|
|
|
func _emit_presentation_safe_if_idle() -> void:
|
|
if can_switch_presentation():
|
|
presentation_safe.emit()
|
|
|
|
|
|
func _on_context_menu_id_pressed(id: int) -> void:
|
|
match id:
|
|
0: # Line
|
|
_create_line_from_context()
|
|
1: # Rectangle
|
|
_create_rectangle_from_context()
|
|
2: # Circle
|
|
_create_circle_from_context()
|
|
3: # Create Point
|
|
_create_point_from_context()
|
|
4: # Color...
|
|
_show_color_picker()
|
|
5: # Delete shape
|
|
_delete_selected_shape()
|
|
6: # Remove Point
|
|
_remove_vertex_from_context()
|
|
7: # Send Back
|
|
_send_back()
|
|
8: # Bring Forward
|
|
_bring_forward()
|
|
9: # Copy
|
|
_copy_selected_shape()
|
|
10: # Paste
|
|
_paste_from_context()
|
|
11: # Mirror X
|
|
_mirror_shape_x()
|
|
12: # Mirror Y
|
|
_mirror_shape_y()
|
|
|
|
|
|
func _create_line_from_context() -> void:
|
|
var world_pos := _get_context_world_pos()
|
|
_create_line(world_pos)
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func _create_rectangle_from_context() -> void:
|
|
var world_pos := _get_context_world_pos()
|
|
_create_rectangle(world_pos)
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func _create_circle_from_context() -> void:
|
|
var world_pos := _get_context_world_pos()
|
|
_create_circle(world_pos)
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func _create_point_from_context() -> void:
|
|
var world_pos := _get_context_world_pos()
|
|
_create_point_on_edge(world_pos)
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func _get_context_world_pos() -> Vector2:
|
|
var click_pos: Variant = context_menu.get_meta("click_position", Vector2(100, 60))
|
|
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2(100, 60)
|
|
|
|
var world_size := drawing_area.size / _zoom
|
|
if world_pos.x < 0 or world_pos.y < 0 or world_pos.x > world_size.x or world_pos.y > world_size.y:
|
|
return _screen_to_world(drawing_area.size * 0.5)
|
|
return world_pos
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shape creation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _make_shape_dict() -> Dictionary:
|
|
return {
|
|
"shape_type": "",
|
|
"points": PackedVector2Array(),
|
|
"color": "#000000",
|
|
"closed": false,
|
|
"vertex_flags": PackedInt32Array(),
|
|
}
|
|
|
|
|
|
func _create_line(center: Vector2) -> void:
|
|
var sd := _make_shape_dict()
|
|
sd["shape_type"] = "line"
|
|
sd["points"] = PackedVector2Array([
|
|
center + Vector2(-60, 0),
|
|
center + Vector2(60, 0),
|
|
])
|
|
sd["closed"] = false
|
|
sd["vertex_flags"] = PackedInt32Array()
|
|
sd["vertex_flags"].resize(2)
|
|
shapes.append(sd)
|
|
_selected_shape_idx = shapes.size() - 1
|
|
|
|
|
|
func _create_rectangle(center: Vector2) -> void:
|
|
var hw := 50.0
|
|
var hh := 30.0
|
|
var sd := _make_shape_dict()
|
|
sd["shape_type"] = "rectangle"
|
|
sd["points"] = PackedVector2Array([
|
|
center + Vector2(-hw, -hh),
|
|
center + Vector2(hw, -hh),
|
|
center + Vector2(hw, hh),
|
|
center + Vector2(-hw, hh),
|
|
])
|
|
sd["closed"] = true
|
|
sd["vertex_flags"] = PackedInt32Array()
|
|
sd["vertex_flags"].resize(4)
|
|
shapes.append(sd)
|
|
_selected_shape_idx = shapes.size() - 1
|
|
|
|
|
|
func _create_circle(center: Vector2) -> void:
|
|
var radius := 40.0
|
|
var segments := 12
|
|
var pts := PackedVector2Array()
|
|
for i: int in range(segments):
|
|
var angle: float = TAU * float(i) / float(segments) - (PI / 2.0)
|
|
pts.append(center + Vector2(cos(angle), sin(angle)) * radius)
|
|
var sd := _make_shape_dict()
|
|
sd["shape_type"] = "circle"
|
|
sd["points"] = pts
|
|
sd["closed"] = true
|
|
sd["vertex_flags"] = PackedInt32Array()
|
|
sd["vertex_flags"].resize(segments)
|
|
shapes.append(sd)
|
|
_selected_shape_idx = shapes.size() - 1
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shape hit-test helpers (operate on a single shape dict)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _hit_test_vertex_in_shape(sd: Dictionary, world_pos: Vector2) -> int:
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
var threshold: float = HANDLE_RADIUS_SELECTION / _zoom
|
|
for i: int in range(pts.size()):
|
|
if world_pos.distance_to(pts[i]) <= threshold:
|
|
return i
|
|
return -1
|
|
|
|
|
|
func _is_point_in_shape(sd: Dictionary, world_pos: Vector2) -> bool:
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if pts.size() < 2:
|
|
return false
|
|
var closed: bool = bool(sd.get("closed", false))
|
|
if closed:
|
|
return Geometry2D.is_point_in_polygon(world_pos, pts)
|
|
else:
|
|
return _distance_to_shape_pts(pts, closed, world_pos) <= EDGE_HIT_THRESHOLD / _zoom
|
|
|
|
|
|
func _is_point_near_outline(sd: Dictionary, world_pos: Vector2) -> bool:
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if pts.size() < 2:
|
|
return false
|
|
return _distance_to_shape_pts(pts, bool(sd.get("closed", false)), world_pos) <= EDGE_HIT_THRESHOLD / _zoom
|
|
|
|
|
|
func _distance_to_shape_pts(pts: PackedVector2Array, closed: bool, world_pos: Vector2) -> float:
|
|
var min_dist: float = INF
|
|
for i: int in range(pts.size() - 1):
|
|
var d: float = _point_to_segment_distance(world_pos, pts[i], pts[i + 1])
|
|
min_dist = minf(min_dist, d)
|
|
if closed and pts.size() >= 2:
|
|
var d: float = _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0])
|
|
min_dist = minf(min_dist, d)
|
|
return min_dist
|
|
|
|
|
|
func _point_to_segment_distance(point: Vector2, a: Vector2, b: Vector2) -> float:
|
|
var ab: Vector2 = b - a
|
|
var ap: Vector2 = point - a
|
|
var ab_len_sq: float = ab.length_squared()
|
|
if ab_len_sq < 0.0001:
|
|
return ap.length()
|
|
var t: float = clampf(ap.dot(ab) / ab_len_sq, 0.0, 1.0)
|
|
var closest: Vector2 = a + ab * t
|
|
return point.distance_to(closest)
|
|
|
|
|
|
func _create_point_on_edge(world_pos: Vector2) -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
return
|
|
var sd: Dictionary = shapes[idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if pts.size() < 2:
|
|
return
|
|
|
|
var closed: bool = bool(sd.get("closed", false))
|
|
var best_edge: int = 0
|
|
var best_dist: float = INF
|
|
|
|
for i: int in range(pts.size() - 1):
|
|
var seg_dist: float = _point_to_segment_distance(world_pos, pts[i], pts[i + 1])
|
|
if seg_dist < best_dist:
|
|
best_dist = seg_dist
|
|
best_edge = i
|
|
|
|
if closed and pts.size() >= 2:
|
|
var seg_dist: float = _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0])
|
|
if seg_dist < best_dist:
|
|
best_dist = seg_dist
|
|
best_edge = pts.size() - 1
|
|
|
|
var a: Vector2 = pts[best_edge]
|
|
var b: Vector2 = pts[(best_edge + 1) % pts.size()]
|
|
var midpoint: Vector2 = (a + b) * 0.5
|
|
|
|
var insert_idx: int = best_edge + 1
|
|
if closed and best_edge == pts.size() - 1:
|
|
insert_idx = pts.size()
|
|
|
|
pts.insert(insert_idx, midpoint)
|
|
sd["points"] = pts
|
|
|
|
var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array())
|
|
vf.insert(insert_idx, 1)
|
|
sd["vertex_flags"] = vf
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3: Color picker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _show_color_picker() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
return
|
|
var sd: Dictionary = shapes[idx]
|
|
_restore_color = Color.from_string(str(sd.get("color", "#000000")), Color.BLACK)
|
|
color_picker.color = _restore_color
|
|
color_picker_popup.popup_centered()
|
|
|
|
|
|
func _on_color_picker_changed(new_color: Color) -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
return
|
|
var sd: Dictionary = shapes[idx]
|
|
sd["color"] = new_color.to_html()
|
|
drawing_area.queue_redraw()
|
|
|
|
|
|
func _on_color_picker_ok() -> void:
|
|
color_picker_popup.hide()
|
|
color_selected.emit(color_picker.color)
|
|
_emit_changed()
|
|
|
|
|
|
func _on_color_picker_cancel() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
color_picker_popup.hide()
|
|
return
|
|
var sd: Dictionary = shapes[idx]
|
|
sd["color"] = _restore_color.to_html()
|
|
color_picker_popup.hide()
|
|
drawing_area.queue_redraw()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3: Delete shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _delete_selected_shape() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0 or idx >= shapes.size():
|
|
return
|
|
shapes.remove_at(idx)
|
|
if shapes.is_empty():
|
|
_selected_shape_idx = -1
|
|
_dragging_vertex = -1
|
|
else:
|
|
_selected_shape_idx = clampi(idx - 1, 0, shapes.size() - 1)
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 3: Remove vertex
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _remove_vertex_from_context() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
return
|
|
var vertex_index: Variant = context_menu.get_meta("vertex_index", -1)
|
|
_remove_vertex_at(idx, int(vertex_index) if vertex_index is int else -1)
|
|
|
|
|
|
func _remove_vertex_at(shape_idx: int, vertex_idx: int) -> void:
|
|
if shape_idx < 0 or shape_idx >= shapes.size():
|
|
return
|
|
var sd: Dictionary = shapes[shape_idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if vertex_idx < 0 or vertex_idx >= pts.size():
|
|
return
|
|
|
|
pts.remove_at(vertex_idx)
|
|
sd["points"] = pts
|
|
|
|
var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array())
|
|
vf.remove_at(vertex_idx)
|
|
sd["vertex_flags"] = vf
|
|
|
|
if pts.size() == 2:
|
|
sd["closed"] = false
|
|
|
|
if pts.size() < 2:
|
|
shapes.remove_at(shape_idx)
|
|
if shapes.is_empty():
|
|
_selected_shape_idx = -1
|
|
else:
|
|
_selected_shape_idx = clampi(shape_idx - 1, 0, shapes.size() - 1)
|
|
_dragging_vertex = -1
|
|
else:
|
|
drawing_area.queue_redraw()
|
|
|
|
_emit_changed()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 4: Z-ordering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _get_context_shape_idx() -> int:
|
|
return _context_shape_idx if _context_shape_idx >= 0 else _selected_shape_idx
|
|
|
|
|
|
func _send_back() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx <= 0 or idx >= shapes.size():
|
|
return
|
|
var sd := shapes[idx]
|
|
shapes.remove_at(idx)
|
|
shapes.insert(idx - 1, sd)
|
|
_selected_shape_idx = idx - 1
|
|
_context_shape_idx = idx - 1
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func _bring_forward() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0 or idx >= shapes.size() - 1:
|
|
return
|
|
var sd := shapes[idx]
|
|
shapes.remove_at(idx)
|
|
shapes.insert(idx + 1, sd)
|
|
_selected_shape_idx = idx + 1
|
|
_context_shape_idx = idx + 1
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5: Copy, paste, and mirror
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _copy_selected_shape() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0 or idx >= shapes.size():
|
|
return
|
|
shape_copy_requested.emit(shapes[idx])
|
|
|
|
|
|
func _paste_from_context() -> void:
|
|
var world_pos := _get_context_world_pos()
|
|
shape_paste_requested.emit(world_pos)
|
|
|
|
|
|
func _mirror_shape_x() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
return
|
|
var sd: Dictionary = shapes[idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if pts.is_empty():
|
|
return
|
|
var min_x := INF; var max_x := -INF
|
|
for pt: Vector2 in pts:
|
|
min_x = min(min_x, pt.x)
|
|
max_x = max(max_x, pt.x)
|
|
var center_x: float = (min_x + max_x) * 0.5
|
|
for i: int in range(pts.size()):
|
|
pts[i] = Vector2(center_x * 2.0 - pts[i].x, pts[i].y)
|
|
sd["points"] = pts
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
func _mirror_shape_y() -> void:
|
|
var idx := _get_context_shape_idx()
|
|
if idx < 0:
|
|
return
|
|
var sd: Dictionary = shapes[idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if pts.is_empty():
|
|
return
|
|
var min_y := INF; var max_y := -INF
|
|
for pt: Vector2 in pts:
|
|
min_y = min(min_y, pt.y)
|
|
max_y = max(max_y, pt.y)
|
|
var center_y: float = (min_y + max_y) * 0.5
|
|
for i: int in range(pts.size()):
|
|
pts[i] = Vector2(pts[i].x, center_y * 2.0 - pts[i].y)
|
|
sd["points"] = pts
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|