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