Files
stickman/scripts/body_part_panel.gd
T
2026-08-08 00:00:50 -04:00

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