1040 lines
31 KiB
GDScript
1040 lines
31 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)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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: PopupMenu = %ContextMenu
|
|
@onready var color_picker_popup: PopupPanel = %ColorPickerPopup
|
|
@onready var color_picker: ColorPicker = %ColorPicker
|
|
@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
|
|
|
|
# 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)
|
|
|
|
_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()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5: Clipboard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_has_clipboard(state: bool) -> void:
|
|
_has_clipboard = state
|
|
|
|
|
|
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()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 _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,
|
|
"Right-click to add shape",
|
|
HORIZONTAL_ALIGNMENT_CENTER,
|
|
-1,
|
|
14,
|
|
Color(0.5, 0.5, 0.5, 0.6)
|
|
)
|
|
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, Color.WHITE, 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]
|
|
|
|
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 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
|
|
return
|
|
|
|
# 2. Mouse wheel -> zoom
|
|
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
|
_zoom = clampf(_zoom * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
|
drawing_area.queue_redraw()
|
|
return
|
|
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
|
_zoom = clampf(_zoom / ZOOM_STEP, MIN_ZOOM, MAX_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) / _zoom
|
|
_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_item("Remove Point", 6)
|
|
context_menu.set_meta("vertex_index", hit_idx)
|
|
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
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_item("Remove Point", 6)
|
|
context_menu.set_meta("vertex_index", hit_idx)
|
|
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
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("Color...", 4)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Send Back", 7)
|
|
context_menu.add_item("Bring Forward", 8)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Copy", 9)
|
|
if _has_clipboard:
|
|
context_menu.add_item("Paste", 10)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Mirror X", 11)
|
|
context_menu.add_item("Mirror Y", 12)
|
|
context_menu.add_separator()
|
|
context_menu.add_item("Delete", 5)
|
|
|
|
context_menu.set_meta("click_position", world_pos)
|
|
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
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", 10)
|
|
context_menu.set_meta("click_position", world_pos)
|
|
context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
|
|
|
|
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()
|
|
return
|
|
|
|
if _dragging_vertex >= 0:
|
|
_dragging_vertex = -1
|
|
_drag_offset = Vector2.ZERO
|
|
drawing_area.queue_redraw()
|
|
_emit_changed()
|
|
|
|
|
|
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()
|
|
_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()
|