class_name PropCanvas extends Control ## PropCanvas - Multi-shape vector canvas for the Prop Editor. ## ## Mirrors BodyPartPanel's proven patterns (cursor-centered zoom, pinch/pan ## gestures, middle-mouse pan, vertex drag with live reshape, right-click ## context menu, z-order) and adds prop-specific needs: per-shape fill/outline ## colors + outline width, polygon/circle/line primitives, translate/rotate/ ## scale gizmos (Ctrl = 15 degree rotation snap, Ctrl = aspect-locked scale), ## mirror X/Y, an in-memory clipboard, and grid-snap sanitization on commit. # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- signal shape_changed(shapes: Array[Dictionary]) signal shape_selected(idx: int) ## Emitted when the user copies a shape via the context menu; the editor owns ## the clipboard slot and calls set_has_clipboard / paste_shape. signal shape_copy_requested(shape: Dictionary) ## Emitted when Paste is chosen; the editor replies via paste_shape(). signal paste_from_context_requested(world_pos: Vector2) ## Emitted when a vertex is created via Create Point (shape index, vertex index). signal vertex_created(shape_idx: int, vertex_idx: int) ## Emitted only when the edit mode actually changes. signal mode_changed(mode: int) signal anchors_changed(anchors: Dictionary) signal anchor_selected(anchor_id: String) signal whole_prop_resize_requested(factor: float) signal whole_prop_move_requested(delta: Vector2) signal whole_prop_rotate_requested(angle_rad: float) signal stage_size_cancel_requested # --------------------------------------------------------------------------- # Enums / constants # --------------------------------------------------------------------------- enum ShapeType { POLYGON, CIRCLE, LINE } enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 } enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE } enum WholePropDrag { NONE, MOVE, ROTATE, SCALE } ## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points" ## (vertex handles + Create/Remove Point). Gates input and drawing only — the ## public math APIs stay mode-agnostic. enum EditMode { TRANSFORM, POINTS, ATTACH } 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 # Rotation dot below the bbox bottom-center (mirrors whole_stickman_preview). const ROTATION_CIRCLE_RADIUS: float = 5.0 const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0 const ROTATION_CIRCLE_OFFSET: float = 26.0 const SCALE_CROSS_SIZE: float = 5.0 const SCALE_CROSS_HIT_RADIUS: float = 12.0 const ROTATION_SNAP_DEG: float = 15.0 const FLASH_DURATION: float = 0.6 const FLASH_RING_RADIUS: float = 12.0 const CLIPBOARD_PASTE_OFFSET := Vector2(16.0, 16.0) const DEFAULT_CIRCLE_SEGMENTS: int = 12 const MIN_ZOOM: float = 0.3 const MAX_ZOOM: float = 3.0 const ZOOM_STEP: float = 1.10 const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15) const ORIGIN_MARKER_DEFAULT_COLOR := Color(0.6, 0.6, 0.6, 0.7) const TerrainUtilsScript := preload("res://scripts/terrain/terrain_utils.gd") const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd") const PropAnchorOverlayScript := preload("res://scripts/props/prop_anchor_overlay.gd") const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd") const WholeStickmanPreviewScript := preload("res://scripts/editors/stickman/whole_stickman_preview.gd") const EditorContextMenuScript := preload("res://scripts/shared/editor_ui/editor_context_menu.gd") const SELECTION_CORE_COLOR := Color.WHITE const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85) const FLASH_COLOR := Color.YELLOW # Primitive defaults mirror PropUtils.create_* generator defaults. const DEFAULT_FILL_HEX: String = "#8C6133" const DEFAULT_OUTLINE_HEX: String = "#40290F" const DEFAULT_OUTLINE_WIDTH: float = 2.0 const DEFAULT_BOX_SIZE := Vector2(48.0, 48.0) const DEFAULT_BALL_RADIUS: float = 24.0 const DEFAULT_PLANK_LENGTH: float = 160.0 const DEFAULT_PLANK_THICKNESS: float = 16.0 const DEFAULT_TRIANGLE_BASE: float = 56.0 const DEFAULT_TRIANGLE_HEIGHT: float = 48.0 const DEFAULT_LINE_LENGTH: float = 120.0 const DEFAULT_CIRCLE_RADIUS: float = 40.0 # Context menu item ids. const MENU_REMOVE_POINT := 1 const MENU_CREATE_POINT := 2 const MENU_COPY := 3 const MENU_PASTE := 4 const MENU_DELETE := 5 const MENU_MIRROR_X := 6 const MENU_MIRROR_Y := 7 const MENU_BRING_FORWARD := 8 const MENU_SEND_BACK := 9 const MENU_ADD_BOX := 10 const MENU_ADD_PLANK := 12 const MENU_ADD_TRIANGLE := 13 const MENU_ADD_LINE := 14 const MENU_ADD_CIRCLE := 15 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var shapes: Array[Dictionary] = [] var _selected_shape_idx: int = -1 var _zoom: float = 1.0 var _pan_offset: Vector2 = Vector2.ZERO var _grid_size: int = 15 var _snap_enabled: bool = false var _has_clipboard: bool = false var _create_point_armed: bool = false var _context_menu: EditorContextMenu var _edit_mode: EditMode = EditMode.TRANSFORM var _anchor_overlay: RefCounted var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR var _show_origin_marker: bool = true var _show_stickman_guide: bool = false var _whole_prop_resize_enabled: bool = false var _whole_prop_dragging: bool = false var _whole_prop_start_shapes: Array[Dictionary] = [] var _whole_prop_preview_shapes: Array[Dictionary] = [] var _whole_prop_start_anchors: Dictionary = {} var _whole_prop_preview_anchors: Dictionary = {} var _whole_prop_start_bounds: Rect2 = Rect2() var _whole_prop_start_pointer: Vector2 = Vector2.ZERO var _whole_prop_corner: Vector2 = Vector2.ZERO var _whole_prop_factor: float = 1.0 var _whole_prop_drag: WholePropDrag = WholePropDrag.NONE var _whole_prop_delta: Vector2 = Vector2.ZERO var _whole_prop_total_angle: float = 0.0 var _whole_prop_previous_angle: float = 0.0 var _whole_prop_preview_angle: float = 0.0 var _stage_size_preview_active: bool = false var _stage_size_start_shapes: Array[Dictionary] = [] var _stage_size_preview_shapes: Array[Dictionary] = [] var _stage_size_start_anchors: Dictionary = {} var _stage_size_preview_anchors: Dictionary = {} var _stage_size_start_pan: Vector2 = Vector2.ZERO var _stage_size_start_zoom: float = 1.0 var _stage_size_factor: float = 1.0 const SCALE_COORDINATE_LIMIT: float = 1000000.0 const WHOLE_PROP_HANDLE_HIT_RADIUS: float = 16.0 const WHOLE_PROP_FACTOR_MIN: float = 0.01 const WHOLE_PROP_FACTOR_MAX: float = 10.0 # Drag state var _drag_mode: DragMode = DragMode.NONE var _dragging_vertex: int = -1 var _drag_offset: Vector2 = Vector2.ZERO var _shape_drag_start: Vector2 = Vector2.ZERO var _shape_drag_original_points: PackedVector2Array = PackedVector2Array() var _rotate_center: Vector2 = Vector2.ZERO var _rotate_total_delta: float = 0.0 var _rotate_prev_angle: float = 0.0 var _scale_start_world: Vector2 = Vector2.ZERO var _scale_corner: int = -1 var _gizmo_base_points: PackedVector2Array = PackedVector2Array() var _is_panning: bool = false var _pan_start: Vector2 = Vector2.ZERO # Create Point flash feedback var _flash_vertex: int = -1 var _flash_remaining: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP if not draw.is_connected(_on_canvas_draw): draw.connect(_on_canvas_draw) if not gui_input.is_connected(_on_canvas_gui_input): gui_input.connect(_on_canvas_gui_input) _anchor_overlay = PropAnchorOverlayScript.new() _anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw()) _anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw()) _context_menu = EditorContextMenuScript.new() as EditorContextMenu _context_menu.name = "ContextMenu" add_child(_context_menu) if not _context_menu.id_pressed.is_connected(_on_context_menu_id_pressed): _context_menu.id_pressed.connect(_on_context_menu_id_pressed) func _process(delta: float) -> void: if _flash_remaining > 0.0: _flash_remaining = maxf(_flash_remaining - delta, 0.0) queue_redraw() if _flash_remaining <= 0.0: _flash_vertex = -1 # --------------------------------------------------------------------------- # Public API — edit mode (Round 2) # --------------------------------------------------------------------------- func set_edit_mode(mode: int) -> void: var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM if m == _edit_mode: return if _stage_size_preview_active: cancel_stage_size_preview() _edit_mode = m if _edit_mode != EditMode.POINTS: _create_point_armed = false if _edit_mode != EditMode.TRANSFORM: set_whole_prop_resize_enabled(false) _drag_mode = DragMode.NONE _dragging_vertex = -1 queue_redraw() mode_changed.emit(_edit_mode) func get_edit_mode() -> int: return _edit_mode func arm_create_point() -> void: set_edit_mode(EditMode.POINTS) _create_point_armed = true queue_redraw() func cancel_create_point() -> void: _create_point_armed = false queue_redraw() func is_create_point_armed() -> bool: return _create_point_armed func set_anchor_data(data: Variant) -> void: if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new() _anchor_overlay.set_anchor_data(data) queue_redraw() func get_anchor_data() -> Dictionary: return _anchor_overlay.get_anchor_data() if _anchor_overlay != null else {} func select_anchor(anchor_id: String) -> void: if _anchor_overlay != null: _anchor_overlay.select_anchor(anchor_id); queue_redraw() func get_selected_anchor_id() -> String: return _anchor_overlay.get_selected_anchor_id() if _anchor_overlay != null else "" func set_show_inactive_anchors(enabled: bool) -> void: if _anchor_overlay != null: _anchor_overlay.set_show_inactive(enabled); queue_redraw() func set_show_origin_marker(enabled: bool) -> void: _show_origin_marker = enabled queue_redraw() func set_whole_prop_resize_enabled(enabled: bool) -> void: if _whole_prop_resize_enabled == enabled: return cancel_whole_prop_resize() _whole_prop_resize_enabled = enabled and has_shape_geometry() and _edit_mode == EditMode.TRANSFORM queue_redraw() func set_whole_prop_transform_enabled(enabled: bool) -> void: set_whole_prop_resize_enabled(enabled) func is_whole_prop_resize_enabled() -> bool: return _whole_prop_resize_enabled func is_whole_prop_transform_enabled() -> bool: return _whole_prop_resize_enabled func is_whole_prop_resize_dragging() -> bool: return _whole_prop_dragging func is_whole_prop_transform_dragging() -> bool: return _whole_prop_dragging func cancel_whole_prop_resize() -> void: _whole_prop_dragging = false _whole_prop_preview_shapes.clear() _whole_prop_preview_anchors.clear() _whole_prop_start_shapes.clear() _whole_prop_start_anchors.clear() _whole_prop_factor = 1.0 _whole_prop_drag = WholePropDrag.NONE _whole_prop_delta = Vector2.ZERO _whole_prop_total_angle = 0.0 _whole_prop_preview_angle = 0.0 queue_redraw() func cancel_whole_prop_transform() -> void: cancel_whole_prop_resize() func set_show_stickman_guide(enabled: bool) -> void: _show_stickman_guide = enabled queue_redraw() func get_show_stickman_guide() -> bool: return _show_stickman_guide func begin_stage_size_preview(anchors: Dictionary) -> bool: if _stage_size_preview_active or _whole_prop_resize_enabled or _edit_mode != EditMode.TRANSFORM or not has_shape_geometry(): return false var bounds: Rect2 = get_visible_shapes_bounds() var extent: float = maxf(bounds.size.x, bounds.size.y) if not _valid_rect(bounds) or not is_finite(extent) or extent <= 0.0: return false _stage_size_start_shapes = _duplicate_shapes(shapes) _stage_size_preview_shapes = _duplicate_shapes(shapes) _stage_size_start_anchors = anchors.duplicate(true) _stage_size_preview_anchors = anchors.duplicate(true) _stage_size_start_pan = _pan_offset _stage_size_start_zoom = _zoom _stage_size_factor = 1.0 _stage_size_preview_active = true queue_redraw() return true func preview_stage_size(factor: float) -> bool: if not _stage_size_preview_active or not is_finite(factor) or factor <= 0.0: return false var candidate: Array[Dictionary] = _scaled_stage_size_shapes(_stage_size_start_shapes, factor) if candidate.is_empty() or not _shapes_are_representable(candidate): return false var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_stage_size_start_anchors, factor) if not bool(anchor_result.get("ok", false)): return false var candidate_bounds: Rect2 = _visible_bounds_for_shapes(candidate) if not _valid_rect(candidate_bounds): return false _stage_size_factor = factor _stage_size_preview_shapes = candidate _stage_size_preview_anchors = (anchor_result.get("anchors", {}) as Dictionary).duplicate(true) queue_redraw() return true func commit_stage_size_preview() -> Dictionary: if not _stage_size_preview_active: return {"changed": false, "anchors": get_anchor_data()} var changed: bool = not is_equal_approx(_stage_size_factor, 1.0) var committed_anchors: Dictionary = _stage_size_preview_anchors.duplicate(true) if changed: shapes = _duplicate_shapes(_stage_size_preview_shapes) set_anchor_data(committed_anchors) _end_stage_size_preview() if changed: shape_changed.emit(get_shape_data()) return {"changed": changed, "anchors": committed_anchors} func cancel_stage_size_preview() -> void: if _stage_size_preview_active: _end_stage_size_preview() func is_stage_size_preview_active() -> bool: return _stage_size_preview_active func get_visible_shapes_bounds() -> Rect2: return _visible_bounds_for_shapes(_stage_size_preview_shapes if _stage_size_preview_active else shapes) func get_stage_size_comparison_bounds() -> Rect2: var artwork_bounds: Rect2 = _visible_bounds_for_shapes(_stage_size_preview_shapes if _stage_size_preview_active else shapes) if not _valid_rect(artwork_bounds): return Rect2() return artwork_bounds.merge(_stage_size_guide_bounds(artwork_bounds)) func fit_stage_size_view(bounds: Rect2, padding_px: float, force: bool = true) -> void: if not _stage_size_preview_active or not _valid_rect(bounds): return if not force and _stage_size_view_contains(bounds, padding_px): return var available: Vector2 = size - Vector2.ONE * maxf(0.0, padding_px) * 2.0 if available.x <= 0.0 or available.y <= 0.0: return var fit_x: float = available.x / maxf(bounds.size.x, 1.0) var fit_y: float = available.y / maxf(bounds.size.y, 1.0) _zoom = clampf(minf(fit_x, fit_y), 0.02, MAX_ZOOM) _pan_offset = size * 0.5 - bounds.get_center() * _zoom queue_redraw() func _end_stage_size_preview() -> void: _stage_size_preview_active = false _stage_size_start_shapes.clear() _stage_size_preview_shapes.clear() _stage_size_start_anchors.clear() _stage_size_preview_anchors.clear() _stage_size_factor = 1.0 _pan_offset = _stage_size_start_pan _zoom = _stage_size_start_zoom queue_redraw() func _stage_size_view_contains(bounds: Rect2, padding_px: float) -> bool: var padding: float = maxf(0.0, padding_px) var available: Vector2 = size - Vector2.ONE * padding * 2.0 if available.x <= 0.0 or available.y <= 0.0: return false var world_origin: Vector2 = -_pan_offset / maxf(_zoom, 0.001) var world_size: Vector2 = size / maxf(_zoom, 0.001) var world_padding: float = padding / maxf(_zoom, 0.001) var visible_bounds: Rect2 = Rect2(world_origin + Vector2.ONE * world_padding, world_size - Vector2.ONE * world_padding * 2.0) return visible_bounds.encloses(bounds) func _visible_bounds_for_shapes(source: Array[Dictionary]) -> Rect2: var result: Rect2 = Rect2() var found: bool = false for shape: Dictionary in source: if not _shape_has_geometry(shape): continue var bounds: Rect2 = _shape_bbox(shape).grow(maxf(0.0, float(shape.get("outline_width", 0.0))) * 0.5) if not found: result = bounds found = true else: result = result.merge(bounds) return result if found else Rect2() func _stage_size_guide_bounds(artwork_bounds: Rect2) -> Rect2: var head_center: Vector2 = WholeStickmanPreviewScript.GUIDE_JOINTS["Head"] var ankle: Vector2 = WholeStickmanPreviewScript.GUIDE_JOINTS["LeftAnkle"] var height: float = ankle.y - (head_center.y - WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS) var guide_half_width: float = 170.0 var figure_center_x: float if artwork_bounds.size.x <= guide_half_width * 2.0 + 40.0: figure_center_x = artwork_bounds.position.x + minf(artwork_bounds.size.x * 0.25, 40.0) else: figure_center_x = artwork_bounds.position.x - guide_half_width - 32.0 var floor_y: float = artwork_bounds.end.y return Rect2(Vector2(figure_center_x - guide_half_width, floor_y - height), Vector2(guide_half_width * 2.0, height)) func _shapes_are_representable(source: Array[Dictionary]) -> bool: if source.is_empty(): return false for shape: Dictionary in source: var points: PackedVector2Array = shape.get("points", PackedVector2Array()) for point: Vector2 in points: if not is_finite(point.x) or not is_finite(point.y) or absf(point.x) > SCALE_COORDINATE_LIMIT or absf(point.y) > SCALE_COORDINATE_LIMIT: return false var radius: float = float(shape.get("radius", 0.0)) var outline_width: float = float(shape.get("outline_width", 0.0)) if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT: return false if not is_finite(outline_width) or outline_width < 0.0 or outline_width > SCALE_COORDINATE_LIMIT: return false return true func _scaled_stage_size_shapes(source: Array[Dictionary], factor: float) -> Array[Dictionary]: var result: Array[Dictionary] = [] for source_shape: Dictionary in source: var shape: Dictionary = source_shape.duplicate(true) var points: PackedVector2Array = shape.get("points", PackedVector2Array()) for index: int in points.size(): points[index] *= factor shape["points"] = points shape["radius"] = float(shape.get("radius", 0.0)) * factor shape["outline_width"] = float(shape.get("outline_width", 0.0)) * factor result.append(shape) return result func _duplicate_shapes(source: Array[Dictionary]) -> Array[Dictionary]: var result: Array[Dictionary] = [] for shape: Dictionary in source: result.append(shape.duplicate(true)) return result func _valid_rect(rect: Rect2) -> bool: return rect.position.is_finite() and rect.size.is_finite() and rect.size.x >= 0.0 and rect.size.y >= 0.0 and (rect.size.x > 0.0 or rect.size.y > 0.0) func get_shapes_bounds() -> Rect2: var found: bool = false; var result: Rect2 = Rect2() for shape: Dictionary in shapes: if not _shape_has_geometry(shape): continue var rect: Rect2 = _shape_bbox(shape) if not found: result = rect; found = true else: result = result.merge(rect) return result func has_shape_geometry() -> bool: for shape: Dictionary in shapes: if _shape_has_geometry(shape): return true return false func can_scale_all_shapes(factor: float) -> bool: if not is_finite(factor) or factor <= 0.0 or is_equal_approx(factor, 1.0) or not has_shape_geometry(): return false for shape: Dictionary in shapes: var points: PackedVector2Array = shape.get("points", PackedVector2Array()) for point: Vector2 in points: var scaled: Vector2 = point * factor if not is_finite(scaled.x) or not is_finite(scaled.y) or absf(scaled.x) > SCALE_COORDINATE_LIMIT or absf(scaled.y) > SCALE_COORDINATE_LIMIT: return false if str(shape.get("shape_type", "")) == "circle": var radius: float = float(shape.get("radius", 0.0)) * factor if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT: return false return true func can_translate_all_shapes(delta: Vector2) -> bool: if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false for shape: Dictionary in shapes: for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array): var moved: Vector2 = point + delta if not is_finite(moved.x) or not is_finite(moved.y) or absf(moved.x) > SCALE_COORDINATE_LIMIT or absf(moved.y) > SCALE_COORDINATE_LIMIT: return false return true func can_rotate_all_shapes(angle_rad: float) -> bool: if not has_shape_geometry() or not is_finite(angle_rad) or is_zero_approx(angle_rad): return false for shape: Dictionary in shapes: for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array): var rotated: Vector2 = point.rotated(angle_rad) if not is_finite(rotated.x) or not is_finite(rotated.y) or absf(rotated.x) > SCALE_COORDINATE_LIMIT or absf(rotated.y) > SCALE_COORDINATE_LIMIT: return false return true func rotate_all_shapes(angle_rad: float) -> bool: if not can_rotate_all_shapes(angle_rad): return false for i: int in shapes.size(): var sd: Dictionary = shapes[i].duplicate(true); var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) for j: int in pts.size(): pts[j] = pts[j].rotated(angle_rad) sd["points"] = pts; shapes[i] = sd queue_redraw(); shape_changed.emit(get_shape_data()); return true func scale_all_shapes(factor: float) -> bool: if not can_scale_all_shapes(factor): return false for i: int in shapes.size(): var shape: Dictionary = shapes[i].duplicate(true) var points: PackedVector2Array = shape.get("points", PackedVector2Array()) for j: int in points.size(): points[j] *= factor shape["points"] = points if str(shape.get("shape_type", "")) == "circle": shape["radius"] = float(shape.get("radius", 0.0)) * factor shapes[i] = shape queue_redraw() shape_changed.emit(get_shape_data()) return true func translate_all_shapes(delta: Vector2) -> bool: if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false for i: int in shapes.size(): var shape: Dictionary = shapes[i].duplicate(true) var points: PackedVector2Array = shape.get("points", PackedVector2Array()) for j: int in points.size(): points[j] += delta shape["points"] = points shapes[i] = shape queue_redraw() shape_changed.emit(get_shape_data()) return true func focus_world_point(world_point: Vector2) -> void: if not is_finite(world_point.x) or not is_finite(world_point.y): return _pan_offset = size * 0.5 - world_point * _zoom queue_redraw() func set_origin_marker_color(color: Color) -> void: _origin_marker_color = color queue_redraw() # --------------------------------------------------------------------------- # Public API — shape data # --------------------------------------------------------------------------- func set_shape_data(data: Variant) -> void: ## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y} ## format) or a single Dictionary. cancel_stage_size_preview() set_whole_prop_resize_enabled(false) shapes.clear() if data is Array: for item: Variant in data as Array: if item is Dictionary: shapes.append(_coerce_shape(item as Dictionary)) elif data is Dictionary: shapes.append(_coerce_shape(data as Dictionary)) _selected_shape_idx = -1 _drag_mode = DragMode.NONE _dragging_vertex = -1 reset_view() queue_redraw() func get_shape_data() -> Array[Dictionary]: ## Export all shapes as plain dictionaries ({x,y} points, flag arrays). var result: Array[Dictionary] = [] for sd: Dictionary in shapes: result.append(_export_shape(sd)) return result func clear() -> void: ## Remove all shapes without emitting shape_changed. cancel_stage_size_preview() set_whole_prop_resize_enabled(false) shapes.clear() _selected_shape_idx = -1 _drag_mode = DragMode.NONE _dragging_vertex = -1 reset_view() func select(idx: int) -> void: _selected_shape_idx = clampi(idx, -1, shapes.size() - 1) queue_redraw() func deselect() -> void: _selected_shape_idx = -1 _drag_mode = DragMode.NONE _dragging_vertex = -1 queue_redraw() func get_selected_index() -> int: return _selected_shape_idx func delete_selected() -> void: if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size(): return shapes.remove_at(_selected_shape_idx) _selected_shape_idx = clampi(_selected_shape_idx - 1, -1, shapes.size() - 1) queue_redraw() _emit_changed() # --------------------------------------------------------------------------- # Public API — primitives # --------------------------------------------------------------------------- func add_primitive(primitive: String, center: Vector2, params: Dictionary = {}) -> Dictionary: ## Append a primitive ("box"|"ball"|"plank"|"triangle"|"line"|"circle") at ## `center`. Optional params: size_x/size_y (box), radius (ball/circle), ## length/thickness (plank), base/height (triangle), length (line), ## segments (circle), fill_color/outline_color (hex), outline_width. ## Returns the new shape in export format. var sd := _make_shape_dict() var fill_hex: String = str(params.get("fill_color", DEFAULT_FILL_HEX)) var outline_hex: String = str(params.get("outline_color", DEFAULT_OUTLINE_HEX)) var outline_width: float = float(params.get("outline_width", DEFAULT_OUTLINE_WIDTH)) sd["fill_color"] = fill_hex sd["outline_color"] = outline_hex sd["outline_width"] = outline_width match primitive: "ball", "circle": sd["shape_type"] = "circle" var radius: float = float(params.get("radius", DEFAULT_CIRCLE_RADIUS if primitive == "circle" else DEFAULT_BALL_RADIUS)) var segments: int = int(params.get("segments", DEFAULT_CIRCLE_SEGMENTS)) sd["radius"] = radius sd["points"] = _circle_points(center, radius, segments) sd["closed"] = true sd["vertex_flags"] = _zero_flags(sd["points"].size()) "plank": sd["shape_type"] = "polygon" var length: float = float(params.get("length", DEFAULT_PLANK_LENGTH)) var thickness: float = float(params.get("thickness", DEFAULT_PLANK_THICKNESS)) var hl: float = length * 0.5 var ht: float = thickness * 0.5 sd["points"] = PackedVector2Array([ center + Vector2(-hl, -ht), center + Vector2(hl, -ht), center + Vector2(hl, ht), center + Vector2(-hl, ht), ]) sd["closed"] = true sd["vertex_flags"] = _zero_flags(4) "triangle": sd["shape_type"] = "polygon" var base: float = float(params.get("base", DEFAULT_TRIANGLE_BASE)) var height: float = float(params.get("height", DEFAULT_TRIANGLE_HEIGHT)) var hb: float = base * 0.5 var hh: float = height * 0.5 sd["points"] = PackedVector2Array([ center + Vector2(-hb, hh), center + Vector2(hb, hh), center + Vector2(0.0, -hh), ]) sd["closed"] = true sd["vertex_flags"] = _zero_flags(3) "line": sd["shape_type"] = "line" var line_length: float = float(params.get("length", DEFAULT_LINE_LENGTH)) var hl2: float = line_length * 0.5 sd["points"] = PackedVector2Array([ center + Vector2(-hl2, 0.0), center + Vector2(hl2, 0.0), ]) sd["closed"] = false sd["vertex_flags"] = _zero_flags(2) _: sd["shape_type"] = "polygon" var sx: float = float(params.get("size_x", DEFAULT_BOX_SIZE.x)) var sy: float = float(params.get("size_y", DEFAULT_BOX_SIZE.y)) var hx: float = sx * 0.5 var hy: float = sy * 0.5 sd["points"] = PackedVector2Array([ center + Vector2(-hx, -hy), center + Vector2(hx, -hy), center + Vector2(hx, hy), center + Vector2(-hx, hy), ]) sd["closed"] = true sd["vertex_flags"] = _zero_flags(4) shapes.append(sd) _selected_shape_idx = shapes.size() - 1 queue_redraw() _emit_changed() return _export_shape(sd) # --------------------------------------------------------------------------- # Public API — vertex editing # --------------------------------------------------------------------------- func insert_vertex(shape_idx: int, vertex_idx: int, world_pos: Vector2) -> void: ## Insert a USER_CREATED vertex at `world_pos` before `vertex_idx`. 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.insert(vertex_idx, world_pos) sd["points"] = pts sd["reshaped"] = true var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) if vf.size() == pts.size() - 1: vf.insert(vertex_idx, VertexFlag.USER_CREATED) sd["vertex_flags"] = vf queue_redraw() _emit_changed() func remove_vertex(shape_idx: int, vertex_idx: int) -> void: ## Remove a vertex. Shapes reduced below 2 vertices are removed. 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 sd["reshaped"] = true var vf: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) if vf.size() > 0: vf.remove_at(clampi(vertex_idx, 0, vf.size() - 1)) sd["vertex_flags"] = vf if pts.size() == 2: sd["closed"] = false if pts.size() < 2: shapes.remove_at(shape_idx) _selected_shape_idx = clampi(shape_idx - 1, -1, shapes.size() - 1) _dragging_vertex = -1 queue_redraw() _emit_changed() # --------------------------------------------------------------------------- # Public API — z-order / mirror / clipboard / transforms # --------------------------------------------------------------------------- func bring_forward(idx: int) -> void: if idx < 0 or idx >= shapes.size() - 1: return var sd: Dictionary = shapes[idx] shapes.remove_at(idx) shapes.insert(idx + 1, sd) _selected_shape_idx = idx + 1 queue_redraw() _emit_changed() func send_back(idx: int) -> void: if idx <= 0 or idx >= shapes.size(): return var sd: Dictionary = shapes[idx] shapes.remove_at(idx) shapes.insert(idx - 1, sd) _selected_shape_idx = idx - 1 queue_redraw() _emit_changed() func mirror_x(idx: int) -> void: if idx < 0 or idx >= shapes.size(): return var sd: Dictionary = shapes[idx] var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.is_empty(): return var bbox: Rect2 = _shape_bbox(sd) var center_x: float = bbox.get_center().x for i: int in pts.size(): pts[i] = Vector2(center_x * 2.0 - pts[i].x, pts[i].y) sd["points"] = pts queue_redraw() _emit_changed() func mirror_y(idx: int) -> void: if idx < 0 or idx >= shapes.size(): return var sd: Dictionary = shapes[idx] var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.is_empty(): return var bbox: Rect2 = _shape_bbox(sd) var center_y: float = bbox.get_center().y for i: int in pts.size(): pts[i] = Vector2(pts[i].x, center_y * 2.0 - pts[i].y) sd["points"] = pts queue_redraw() _emit_changed() func copy_shape(idx: int) -> Dictionary: ## Return a deep duplicate of the internal shape dictionary, {} if invalid. if idx < 0 or idx >= shapes.size(): return {} return shapes[idx].duplicate(true) func paste_shape(source: Dictionary, target_pos: Vector2) -> void: ## Insert a deep copy of `source` with its bbox center at ## target_pos + CLIPBOARD_PASTE_OFFSET. if source.is_empty(): return var new_sd: Dictionary = source.duplicate(true) var pts: PackedVector2Array = new_sd.get("points", PackedVector2Array()) if pts.is_empty(): return var bbox: Rect2 = _shape_bbox(new_sd) var offset: Vector2 = (target_pos + CLIPBOARD_PASTE_OFFSET) - bbox.get_center() var new_pts := PackedVector2Array() for pt: Vector2 in pts: new_pts.append(pt + offset) new_sd["points"] = new_pts shapes.append(new_sd) _selected_shape_idx = shapes.size() - 1 queue_redraw() _emit_changed() func translate_selected(delta: Vector2) -> void: _apply_point_transform(delta, 0.0, Vector2.ONE, Vector2.ZERO) func rotate_selected(angle_rad: float) -> void: _apply_point_transform(Vector2.ZERO, angle_rad, Vector2.ONE, Vector2.ZERO) func scale_selected(factor: Vector2) -> void: _apply_point_transform(Vector2.ZERO, 0.0, factor, Vector2.ZERO) func sanitize_shape(idx: int) -> void: ## Grid-snap + redundancy/collinear removal + clockwise enforcement on a ## polygon/line shape, keeping vertex flags aligned. Circles are skipped ## (radius-based geometry must not be grid-distorted). if idx < 0 or idx >= shapes.size(): return var sd: Dictionary = shapes[idx] if str(sd.get("shape_type", "polygon")) == "circle": return var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) var flags: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) var gs: float = float(_grid_size) if _snap_enabled else 0.0 var was_clockwise: bool = pts.size() >= 3 and Geometry2D.is_polygon_clockwise(pts) var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, gs) var is_clockwise: bool = cleaned.size() >= 3 and Geometry2D.is_polygon_clockwise(cleaned) var reversed: bool = cleaned.size() >= 3 and not was_clockwise and is_clockwise var kept_flags := PackedInt32Array() kept_flags.resize(cleaned.size()) for i: int in mini(cleaned.size(), flags.size()): kept_flags[i] = flags[i] if reversed: kept_flags.reverse() sd["points"] = cleaned sd["vertex_flags"] = kept_flags if cleaned.size() == 2 and sd.get("shape_type", "") == "polygon": sd["closed"] = false queue_redraw() _emit_changed() # --------------------------------------------------------------------------- # Public API — inspector sync (Task 2.8 live updates) # --------------------------------------------------------------------------- func set_shape_fill_color(idx: int, hex: String) -> void: if idx < 0 or idx >= shapes.size(): return shapes[idx]["fill_color"] = hex queue_redraw() _emit_changed() func set_shape_outline_color(idx: int, hex: String) -> void: if idx < 0 or idx >= shapes.size(): return shapes[idx]["outline_color"] = hex queue_redraw() _emit_changed() func set_shape_outline_width(idx: int, width: float) -> void: if idx < 0 or idx >= shapes.size(): return shapes[idx]["outline_width"] = width queue_redraw() _emit_changed() func set_shape_radius(idx: int, radius: float) -> void: if idx < 0 or idx >= shapes.size(): return var sd: Dictionary = shapes[idx] if str(sd.get("shape_type", "")) != "circle": return var old_center: Vector2 = _shape_bbox(sd).get_center() sd["radius"] = maxf(radius, 0.001) sd["points"] = _circle_points(old_center, float(sd["radius"]), DEFAULT_CIRCLE_SEGMENTS) sd["vertex_flags"] = _zero_flags(sd["points"].size()) sd["reshaped"] = false queue_redraw() _emit_changed() # --------------------------------------------------------------------------- # Public API — view / input # --------------------------------------------------------------------------- func set_grid_size(size: int) -> void: _grid_size = maxi(size, 1) queue_redraw() func set_snap_enabled(enabled: bool) -> void: _snap_enabled = enabled func get_snap_enabled() -> bool: return _snap_enabled func reset_view() -> void: _zoom = 1.0 focus_world_point(Vector2.ZERO) func set_has_clipboard(state: bool) -> void: _has_clipboard = state ## Sets the font size of the right-click context menu (Round 4, theme-driven). func set_context_menu_font_size(size: int) -> void: if _context_menu != null: _context_menu.set_font_size(size) func set_context_menu_visual_theme(visual_theme: EditorVisualTheme, editor: StringName) -> void: if _context_menu != null: _context_menu.configure(visual_theme, editor) func is_cursor_over_drawing(global_pos: Vector2) -> bool: return get_global_rect().has_point(global_pos) func global_to_world(global_pos: Vector2) -> Vector2: return _screen_to_world(global_pos - global_position) # --------------------------------------------------------------------------- # Drawing # --------------------------------------------------------------------------- func _on_canvas_draw() -> void: draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom)) _draw_grid() if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM: var rect := get_rect() var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5)) var font := get_theme_default_font() if font != null: draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6)) var drawn_shapes: Array[Dictionary] = _stage_size_preview_shapes if _stage_size_preview_active else (_whole_prop_preview_shapes if _whole_prop_dragging else shapes) for i: int in drawn_shapes.size(): var selected: bool = not _stage_size_preview_active and not _whole_prop_resize_enabled and i == _selected_shape_idx _draw_shape(drawn_shapes[i], selected, _dragging_vertex if selected else -1) if _stage_size_preview_active: _draw_stage_size_guide(_visible_bounds_for_shapes(drawn_shapes)) else: _draw_stickman_guide() _draw_origin_marker() if _whole_prop_resize_enabled: _draw_whole_prop_resize_gizmos() elif _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size(): _draw_selection_gizmos(shapes[_selected_shape_idx]) if _edit_mode == EditMode.ATTACH and _anchor_overlay != null: _anchor_overlay.draw_gizmos(self, _zoom) func _draw_origin_marker() -> void: if not _show_origin_marker and not _whole_prop_resize_enabled: return var inv_zoom: float = 1.0 / maxf(_zoom, 0.001) var radius: float = 6.0 * inv_zoom var halo: Color = Color(0.0, 0.0, 0.0, 0.75) draw_circle(Vector2.ZERO, radius + 2.0 * inv_zoom, halo, false, 3.0 * inv_zoom) draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), halo, 3.0 * inv_zoom) draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), halo, 3.0 * inv_zoom) draw_circle(Vector2.ZERO, radius, _origin_marker_color, false, 1.25 * inv_zoom) draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), _origin_marker_color, 1.25 * inv_zoom) draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), _origin_marker_color, 1.25 * inv_zoom) func _draw_stickman_guide() -> void: if not _show_stickman_guide: return var width: float = 2.0 / maxf(_zoom, 0.001) var color: Color = Color(0.75, 0.82, 0.9, 0.22) var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES: var first: String = str(bone[0]) var second: String = str(bone[1]) if joints.has(first) and joints.has(second): draw_line(joints[first] as Vector2, joints[second] as Vector2, color, width) if joints.has("Head"): draw_arc(joints["Head"] as Vector2, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, color, width) var ankle: Vector2 = joints["LeftAnkle"] _draw_reference_figure(0.0, ankle.y, ankle.x - 250.0, ankle.x + 250.0) func _draw_stage_size_guide(artwork_bounds: Rect2) -> void: var guide_bounds: Rect2 = _stage_size_guide_bounds(artwork_bounds) var floor_y: float = artwork_bounds.end.y _draw_reference_figure( guide_bounds.get_center().x, floor_y, minf(artwork_bounds.position.x, guide_bounds.position.x - 28.0), maxf(artwork_bounds.end.x, guide_bounds.end.x + 28.0) ) func _draw_reference_figure(center_x: float, floor_y: float, floor_start: float, floor_end: float) -> void: var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS var ankle_y: float = (joints["LeftAnkle"] as Vector2).y var offset: Vector2 = Vector2(center_x, floor_y - ankle_y) var inv_zoom: float = 1.0 / maxf(_zoom, 0.001) for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES: var first: String = str(bone[0]) var second: String = str(bone[1]) if not joints.has(first) or not joints.has(second): continue var color: Color = WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL if first in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS or second in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS: color = WholeStickmanPreviewScript.GUIDE_COLOR_LEFT elif first in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS or second in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS: color = WholeStickmanPreviewScript.GUIDE_COLOR_RIGHT var start: Vector2 = (joints[first] as Vector2) + offset var finish: Vector2 = (joints[second] as Vector2) + offset draw_line(start, finish, Color("#111821D9"), 8.0 * inv_zoom, true) draw_line(start, finish, color, 3.0 * inv_zoom, true) var head: Vector2 = (joints["Head"] as Vector2) + offset draw_arc(head, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, Color("#111821E8"), 8.0 * inv_zoom, true) draw_arc(head, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL, 3.0 * inv_zoom, true) for joint_name: String in joints: var point: Vector2 = (joints[joint_name] as Vector2) + offset var color: Color = WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL if joint_name in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS: color = WholeStickmanPreviewScript.GUIDE_JOINT_COLOR_LEFT elif joint_name in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS: color = WholeStickmanPreviewScript.GUIDE_JOINT_COLOR_RIGHT draw_circle(point, 6.5 * inv_zoom, Color("#111821E8")) draw_circle(point, 3.2 * inv_zoom, color) draw_line(Vector2(floor_start, floor_y), Vector2(floor_end, floor_y), Color("#111821E8"), 8.0 * inv_zoom, true) draw_line(Vector2(floor_start, floor_y), Vector2(floor_end, floor_y), Color("#F8FAFC"), 2.5 * inv_zoom, true) func _draw_whole_prop_resize_gizmos() -> void: var bounds: Rect2 = _whole_preview_bounds() if _whole_prop_dragging else get_shapes_bounds() if bounds.size == Vector2.ZERO: return draw_rect(bounds, SELECTION_HALO_COLOR, false, 4.0 / _zoom) draw_rect(bounds, SELECTION_CORE_COLOR, false, 2.0 / _zoom) var half: float = 7.0 / _zoom for corner: Vector2 in _rect_corners(bounds): draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_HALO_COLOR, 5.0 / _zoom) draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_HALO_COLOR, 5.0 / _zoom) draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_CORE_COLOR, 2.0 / _zoom) draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_CORE_COLOR, 2.0 / _zoom) var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET) draw_circle(rotation_dot, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, SELECTION_HALO_COLOR) draw_circle(rotation_dot, ROTATION_CIRCLE_RADIUS / _zoom, SELECTION_CORE_COLOR) var anchors: Dictionary = _stage_size_preview_anchors if _stage_size_preview_active else (_whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data()) for id: String in anchors: if id == "origin": continue var anchor: Dictionary = anchors[id] var pos: Vector2 = anchor.get("position", Vector2.ZERO) var color: Color = Color(1.0, 0.85, 0.2, 0.9) if str(anchor.get("role", "")) == "grip" else Color(0.8, 0.8, 0.8, 0.7) draw_circle(pos, 4.0 / _zoom, color) func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void: var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.size() < 2: return var fill: Color = Color.from_string(str(sd.get("fill_color", DEFAULT_FILL_HEX)), Color.GRAY) var outline: Color = Color.from_string(str(sd.get("outline_color", DEFAULT_OUTLINE_HEX)), Color.WHITE) var width: float = float(sd.get("outline_width", DEFAULT_OUTLINE_WIDTH)) var closed: bool = bool(sd.get("closed", false)) if closed: draw_colored_polygon(pts, fill) _draw_polyline(pts, outline, closed, width) if is_selected: # Two-tone halo selection: a dark backing pass + a bright core pass, so # the selection reads on any background (white shapes, dark shapes, or # a differently-colored shape behind). _draw_polyline(pts, SELECTION_HALO_COLOR, closed, 4.5) _draw_polyline(pts, SELECTION_CORE_COLOR, closed, 2.0) if _edit_mode == EditMode.POINTS: var flags: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) for j: int in pts.size(): var flag: int = flags[j] if flags.size() > j else 0 # Color-matched handles (stickman editor convention); yellow while dragging. var handle_color: Color = Color.YELLOW if j == dragging_vertex else fill draw_circle(pts[j], (HANDLE_RADIUS + 1.5) / _zoom, SELECTION_HALO_COLOR) if flag == VertexFlag.ORIGINAL: draw_circle(pts[j], HANDLE_RADIUS / _zoom, handle_color) else: var hs: float = HANDLE_HOLLOW_SIZE / _zoom * 0.5 draw_rect(Rect2(pts[j] - Vector2(hs, hs), Vector2(hs * 2.0, hs * 2.0)), handle_color, false) if j == _flash_vertex and _flash_remaining > 0.0: var t := 1.0 - _flash_remaining / FLASH_DURATION var ring_r := FLASH_RING_RADIUS * (0.5 + 0.5 * t) / _zoom draw_arc(pts[j], ring_r, 0.0, TAU, 24, FLASH_COLOR, 2.0 / _zoom) func _draw_selection_gizmos(sd: Dictionary) -> void: var bbox: Rect2 = _shape_bbox(sd) if bbox.size == Vector2.ZERO: return # Two-tone halo drawing (dark backing + bright core) so gizmos stay visible # over any background, including a white shape behind the selected one. var halo: Color = SELECTION_HALO_COLOR var core: Color = SELECTION_CORE_COLOR # Bounding box draw_rect(bbox, halo, false, 3.5 / _zoom) draw_rect(bbox, core, false, 1.5 / _zoom) # Rotation dot BELOW the bbox bottom-center (stickman editor pattern). var rot_center := Vector2(bbox.position.x + bbox.size.x * 0.5, bbox.end.y + ROTATION_CIRCLE_OFFSET) draw_circle(rot_center, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, halo) draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, core) # Corner scale crosses. var cross_half := SCALE_CROSS_SIZE / _zoom for corner: Vector2 in [bbox.position, Vector2(bbox.end.x, bbox.position.y), bbox.end, Vector2(bbox.position.x, bbox.end.y)]: draw_line(corner + Vector2(-cross_half, 0.0), corner + Vector2(cross_half, 0.0), halo, 3.5 / _zoom) draw_line(corner + Vector2(0.0, -cross_half), corner + Vector2(0.0, cross_half), halo, 3.5 / _zoom) draw_line(corner + Vector2(-cross_half, 0.0), corner + Vector2(cross_half, 0.0), core, 1.5 / _zoom) draw_line(corner + Vector2(0.0, -cross_half), corner + Vector2(0.0, cross_half), core, 1.5 / _zoom) func _draw_grid() -> void: var gs := float(_grid_size) if gs <= 0.0: return var area_size := 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: 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: 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) -> void: for i: int in pts.size() - 1: draw_line(pts[i], pts[i + 1], color, width) if closed and pts.size() >= 2: draw_line(pts[pts.size() - 1], pts[0], color, width) # --------------------------------------------------------------------------- # Input # --------------------------------------------------------------------------- func _on_canvas_gui_input(event: InputEvent) -> void: if event is InputEventMagnifyGesture: var mag := event as InputEventMagnifyGesture var cursor_local := get_local_mouse_position() var world := _screen_to_world(cursor_local) _zoom = clampf(_zoom * mag.factor, MIN_ZOOM, MAX_ZOOM) _pan_offset = cursor_local - world * _zoom queue_redraw() return if event is InputEventPanGesture: var pan := event as InputEventPanGesture _pan_offset -= pan.delta * 3.0 queue_redraw() return if event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_MIDDLE: if mb.pressed: _is_panning = true _pan_start = mb.position else: _is_panning = false return if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: _zoom_at(mb.position, ZOOM_STEP) return if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: _zoom_at(mb.position, 1.0 / ZOOM_STEP) return var world_pos := _screen_to_world(mb.position) if _edit_mode == EditMode.ATTACH: if mb.button_index == MOUSE_BUTTON_LEFT: if mb.pressed: _anchor_overlay.begin_pointer(world_pos, _zoom) else: _anchor_overlay.end_pointer() return if mb.button_index == MOUSE_BUTTON_RIGHT: return if _whole_prop_resize_enabled: if mb.button_index == MOUSE_BUTTON_LEFT: if mb.pressed: _begin_whole_prop_resize(world_pos) else: _end_whole_prop_resize() return if mb.button_index == MOUSE_BUTTON_RIGHT: return if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: _handle_right_click(mb, world_pos) return if mb.button_index == MOUSE_BUTTON_LEFT: if mb.pressed: if _create_point_armed and _edit_mode == EditMode.POINTS: _create_point_from_toolbar(world_pos) else: _handle_left_press(world_pos) else: _handle_left_release() elif event is InputEventMouseMotion: var mm := event as InputEventMouseMotion if _is_panning: _pan_offset += (mm.position - _pan_start) _pan_start = mm.position queue_redraw() return if _edit_mode == EditMode.ATTACH: _anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed) return if _whole_prop_resize_enabled: if _whole_prop_dragging: _update_whole_prop_resize(_screen_to_world(mm.position), mm.ctrl_pressed) return _handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed) func _zoom_at(local_pos: Vector2, factor: float) -> void: var world := _screen_to_world(local_pos) _zoom = clampf(_zoom * factor, MIN_ZOOM, MAX_ZOOM) _pan_offset = local_pos - world * _zoom queue_redraw() func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: if _edit_mode == EditMode.POINTS: _handle_points_right_click(mb, world_pos) return _handle_transform_right_click(mb, world_pos) func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: # POINTS mode = point editing only: Remove Point on a vertex, Create Point # near a segment, nothing anywhere else (no Copy/Erase/Flip/z-order, no # Add-Shape). var context_shape: int = -1 var context_vertex: int = -1 if _selected_shape_idx >= 0: var hit_idx := _hit_test_vertex(_selected_shape_idx, world_pos) if hit_idx >= 0: context_shape = _selected_shape_idx context_vertex = hit_idx for i: int in range(shapes.size() - 1, -1, -1): if context_shape >= 0: break if i == _selected_shape_idx: continue var hit_idx := _hit_test_vertex(i, world_pos) if hit_idx >= 0: context_shape = i context_vertex = hit_idx _context_menu.clear() if context_shape >= 0 and context_vertex >= 0: _context_menu.set_meta("context_shape", context_shape) _context_menu.set_meta("context_vertex", context_vertex) _context_menu.add_destructive_item("Remove Point", MENU_REMOVE_POINT) _context_menu.popup_at(mb.global_position, _global_point_target_rect(world_pos)) return # Segment proximity -> Create Point (selects the shape first). var seg_shape := hit_test_segment(world_pos) if seg_shape >= 0: _selected_shape_idx = seg_shape _context_menu.set_meta("context_shape", seg_shape) _context_menu.set_meta("click_position", world_pos) queue_redraw() shape_selected.emit(seg_shape) _context_menu.add_item("Create Point", MENU_CREATE_POINT) _context_menu.popup_at(mb.global_position, _global_shape_target_rect(seg_shape)) return # Interior body: select only, no menu. var body_shape: int = -1 for i: int in range(shapes.size() - 1, -1, -1): if _is_point_in_shape(i, world_pos): body_shape = i break if body_shape >= 0: _selected_shape_idx = body_shape queue_redraw() shape_selected.emit(body_shape) # Empty canvas -> no menu at all (adding shapes is Arrange-mode work). func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void: var context_shape: int = -1 # Shape hit (front to back). var hit_shape: int = -1 for i: int in range(shapes.size() - 1, -1, -1): if _is_point_in_shape(i, world_pos): hit_shape = i break _context_menu.clear() if hit_shape >= 0: _selected_shape_idx = hit_shape _context_menu.set_meta("context_shape", hit_shape) _context_menu.set_meta("click_position", world_pos) queue_redraw() shape_selected.emit(hit_shape) _context_menu.add_item("Copy Shape", MENU_COPY) if _has_clipboard: _context_menu.add_item("Paste Shape", MENU_PASTE) _context_menu.add_separator() _context_menu.add_item("Flip Left-Right", MENU_MIRROR_X) _context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y) _context_menu.add_separator() _context_menu.add_item("Bring Forward", MENU_BRING_FORWARD) _context_menu.add_item("Send Back", MENU_SEND_BACK) _context_menu.add_separator() _context_menu.add_destructive_item("Erase Shape", MENU_DELETE) _context_menu.popup_at(mb.global_position, _global_shape_target_rect(hit_shape)) return # Empty canvas -> add primitives. _context_menu.set_meta("click_position", world_pos) _context_menu.add_item("Add Box", MENU_ADD_BOX) _context_menu.add_item("Add Plank", MENU_ADD_PLANK) _context_menu.add_item("Add Triangle", MENU_ADD_TRIANGLE) _context_menu.add_item("Add Line", MENU_ADD_LINE) _context_menu.add_item("Add Circle", MENU_ADD_CIRCLE) if _has_clipboard: _context_menu.add_separator() _context_menu.add_item("Paste Shape", MENU_PASTE) _context_menu.popup_at(mb.global_position) func _handle_left_press(world_pos: Vector2) -> void: if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size(): var sd: Dictionary = shapes[_selected_shape_idx] # 1. Rotation dot (below the bbox) if _hit_test_rotation_dot(sd, world_pos): var bbox: Rect2 = _shape_bbox(sd) _drag_mode = DragMode.ROTATE _rotate_center = bbox.get_center() _rotate_total_delta = 0.0 _rotate_prev_angle = rad_to_deg(_rotate_center.angle_to_point(world_pos)) _gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array())) queue_redraw() return # 2. Scale corner var corner_idx := _hit_test_scale_corner(sd, world_pos) if corner_idx >= 0: var bbox: Rect2 = _shape_bbox(sd) _drag_mode = DragMode.SCALE _scale_corner = corner_idx _scale_start_world = world_pos _rotate_center = bbox.get_center() _gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array())) queue_redraw() return if _edit_mode == EditMode.POINTS: # 3. Vertex (selected shape first, then others front to back) var hit_shape_v: int = -1 var hit_idx: int = -1 if _selected_shape_idx >= 0: hit_idx = _hit_test_vertex(_selected_shape_idx, world_pos) if hit_idx >= 0: hit_shape_v = _selected_shape_idx if hit_shape_v < 0: for i: int in range(shapes.size() - 1, -1, -1): if i == _selected_shape_idx: continue hit_idx = _hit_test_vertex(i, world_pos) if hit_idx >= 0: hit_shape_v = i break if hit_shape_v >= 0: if hit_shape_v != _selected_shape_idx: _selected_shape_idx = hit_shape_v shape_selected.emit(hit_shape_v) var sd: Dictionary = shapes[hit_shape_v] _drag_mode = DragMode.VERTEX _dragging_vertex = hit_idx var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) _drag_offset = world_pos - pts[hit_idx] queue_redraw() return # Shape body hit (front to back) var hit_shape: int = -1 for i: int in range(shapes.size() - 1, -1, -1): if _is_point_in_shape(i, world_pos): hit_shape = i break if hit_shape >= 0: if hit_shape != _selected_shape_idx: _selected_shape_idx = hit_shape shape_selected.emit(hit_shape) if _edit_mode == EditMode.TRANSFORM: # Drag to move (translate) _drag_mode = DragMode.TRANSLATE _shape_drag_start = world_pos _shape_drag_original_points = PackedVector2Array(shapes[hit_shape].get("points", PackedVector2Array())) # POINTS mode: select-only (no translate) — Round 2 decision. queue_redraw() return _selected_shape_idx = -1 _drag_mode = DragMode.NONE queue_redraw() shape_selected.emit(-1) func _create_point_from_toolbar(world_pos: Vector2) -> void: var shape_idx: int = hit_test_segment(world_pos) if shape_idx < 0: return _selected_shape_idx = shape_idx shape_selected.emit(shape_idx) var new_vertex_index: int = create_point_on_edge(world_pos) if new_vertex_index < 0: return _create_point_armed = false _drag_mode = DragMode.NONE _emit_changed() queue_redraw() func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void: match _drag_mode: DragMode.VERTEX: if _selected_shape_idx >= 0: var sd: Dictionary = shapes[_selected_shape_idx] var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if _dragging_vertex >= 0 and _dragging_vertex < pts.size(): var target := world_pos - _drag_offset if _snap_enabled: target = _snap_to_grid(target) pts[_dragging_vertex] = target sd["points"] = pts sd["reshaped"] = true queue_redraw() DragMode.TRANSLATE: if _selected_shape_idx >= 0: var target := world_pos if _snap_enabled: target = _snap_to_grid(target) var delta := target - _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 queue_redraw() DragMode.ROTATE: if not _gizmo_base_points.is_empty(): var current_angle := rad_to_deg(_rotate_center.angle_to_point(world_pos)) var frame_delta := current_angle - _rotate_prev_angle if frame_delta > 180.0: frame_delta -= 360.0 elif frame_delta < -180.0: frame_delta += 360.0 _rotate_total_delta += frame_delta _rotate_prev_angle = current_angle var delta := deg_to_rad(_rotate_total_delta) if ctrl_pressed: delta = snappedf(delta, deg_to_rad(ROTATION_SNAP_DEG)) var sd: Dictionary = shapes[_selected_shape_idx] var new_pts := PackedVector2Array() for pt: Vector2 in _gizmo_base_points: new_pts.append(_rotate_center + (pt - _rotate_center).rotated(delta)) sd["points"] = new_pts queue_redraw() DragMode.SCALE: if not _gizmo_base_points.is_empty(): var rel_start := _scale_start_world - _rotate_center var rel_cur := world_pos - _rotate_center var fx: float = rel_cur.x / rel_start.x if absf(rel_start.x) > 0.001 else 1.0 var fy: float = rel_cur.y / rel_start.y if absf(rel_start.y) > 0.001 else 1.0 if ctrl_pressed: var uniform: float = fx if absf(fx) > absf(fy) else fy fx = uniform fy = uniform var sd: Dictionary = shapes[_selected_shape_idx] if str(sd.get("shape_type", "")) == "circle" and absf(fx - fy) > 0.0001: sd["reshaped"] = true var new_pts := PackedVector2Array() for pt: Vector2 in _gizmo_base_points: var rel := pt - _rotate_center new_pts.append(_rotate_center + Vector2(rel.x * fx, rel.y * fy)) sd["points"] = new_pts queue_redraw() _: pass func _handle_left_release() -> void: match _drag_mode: DragMode.VERTEX: _drag_mode = DragMode.NONE _dragging_vertex = -1 _drag_offset = Vector2.ZERO if _selected_shape_idx >= 0: sanitize_shape(_selected_shape_idx) else: queue_redraw() DragMode.TRANSLATE: _drag_mode = DragMode.NONE _shape_drag_original_points = PackedVector2Array() queue_redraw() _emit_changed() DragMode.ROTATE, DragMode.SCALE: _drag_mode = DragMode.NONE _gizmo_base_points = PackedVector2Array() if _selected_shape_idx >= 0: sanitize_shape(_selected_shape_idx) else: queue_redraw() _: pass func _on_context_menu_id_pressed(id: int) -> void: var context_shape: Variant = _context_menu.get_meta("context_shape", -1) var shape_idx: int = int(context_shape) if context_shape is int else -1 match id: MENU_REMOVE_POINT: var context_vertex: Variant = _context_menu.get_meta("context_vertex", -1) remove_vertex(shape_idx, int(context_vertex) if context_vertex is int else -1) MENU_CREATE_POINT: create_point_on_edge(_get_context_world_pos()) MENU_COPY: var copied: Dictionary = copy_shape(shape_idx) if not copied.is_empty(): shape_copy_requested.emit(copied) MENU_PASTE: _paste_from_context() MENU_DELETE: if shape_idx >= 0 and shape_idx < shapes.size(): shapes.remove_at(shape_idx) _selected_shape_idx = clampi(shape_idx - 1, -1, shapes.size() - 1) queue_redraw() _emit_changed() MENU_MIRROR_X: mirror_x(shape_idx) MENU_MIRROR_Y: mirror_y(shape_idx) MENU_BRING_FORWARD: bring_forward(shape_idx) MENU_SEND_BACK: send_back(shape_idx) MENU_ADD_BOX: add_primitive("box", _get_context_world_pos(), _add_params()) MENU_ADD_PLANK: add_primitive("plank", _get_context_world_pos(), _add_params()) MENU_ADD_TRIANGLE: add_primitive("triangle", _get_context_world_pos(), _add_params()) MENU_ADD_LINE: add_primitive("line", _get_context_world_pos(), _add_params()) MENU_ADD_CIRCLE: add_primitive("circle", _get_context_world_pos(), _add_params()) ## Overridable hook so a subclass can inject default colors and dimensions ## into context-menu additions. func _add_params() -> Dictionary: return {} func _paste_from_context() -> void: paste_from_context_requested.emit(_get_context_world_pos()) ## Insert a new USER_CREATED vertex at the cursor's projection onto the nearest ## segment of the selected shape. Emits vertex_created and flashes the new ## vertex (FLASH_DURATION). Returns the new vertex index, or -1 on failure. func create_point_on_edge(world_pos: Vector2) -> int: if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size(): return -1 var sd: Dictionary = shapes[_selected_shape_idx] var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.size() < 2: return -1 var closed: bool = bool(sd.get("closed", false)) var best_edge: int = 0 var best_dist: float = INF for i: int in 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 projection: Vector2 = _project_point_onto_segment(world_pos, pts[best_edge], pts[(best_edge + 1) % pts.size()]) var insert_idx: int = best_edge + 1 if closed and best_edge == pts.size() - 1: insert_idx = pts.size() insert_vertex(_selected_shape_idx, insert_idx, projection) vertex_created.emit(_selected_shape_idx, insert_idx) _flash_vertex = insert_idx _flash_remaining = FLASH_DURATION queue_redraw() return insert_idx func _project_point_onto_segment(point: Vector2, a: Vector2, b: Vector2) -> Vector2: 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 a var t: float = clampf(ap.dot(ab) / ab_len_sq, 0.0, 1.0) return a + ab * t # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- func _emit_changed() -> void: shape_changed.emit(get_shape_data()) func _begin_whole_prop_resize(world_pos: Vector2) -> void: var bounds: Rect2 = get_shapes_bounds() if bounds.size == Vector2.ZERO: return var corner: Vector2 = _whole_prop_hit_corner(bounds, world_pos) var drag: WholePropDrag = WholePropDrag.SCALE if corner != Vector2.INF else WholePropDrag.NONE if drag == WholePropDrag.NONE: var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET) if world_pos.distance_to(rotation_dot) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom: drag = WholePropDrag.ROTATE if drag == WholePropDrag.NONE: for i: int in range(shapes.size() - 1, -1, -1): if _is_point_in_shape(i, world_pos): drag = WholePropDrag.MOVE; break if drag == WholePropDrag.NONE: return _whole_prop_dragging = true _whole_prop_start_shapes = shapes.duplicate(true) _whole_prop_start_anchors = get_anchor_data().duplicate(true) _whole_prop_start_bounds = bounds _whole_prop_start_pointer = world_pos _whole_prop_corner = corner _whole_prop_drag = drag if drag == WholePropDrag.ROTATE: if world_pos.length_squared() <= 0.0001: cancel_whole_prop_resize(); return _whole_prop_previous_angle = world_pos.angle() _whole_prop_factor = 1.0 _whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true) _whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true) queue_redraw() func _update_whole_prop_resize(world_pos: Vector2, ctrl_pressed: bool = false) -> void: if _whole_prop_drag == WholePropDrag.MOVE: var delta: Vector2 = world_pos - _whole_prop_start_pointer if _snap_enabled: delta = Vector2(snappedf(delta.x, float(_grid_size)), snappedf(delta.y, float(_grid_size))) if delta.is_zero_approx(): _whole_prop_delta = Vector2.ZERO _whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true) _whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true) queue_redraw() return if not can_translate_all_shapes(delta) or not bool(PropAnchorDataScript.try_translate_non_origin(_whole_prop_start_anchors, delta).get("ok", false)): return _whole_prop_delta = delta; _whole_prop_preview_shapes = _translated_shape_copy(_whole_prop_start_shapes, delta); _whole_prop_preview_anchors = _translated_anchor_copy(_whole_prop_start_anchors, delta); queue_redraw(); return if _whole_prop_drag == WholePropDrag.ROTATE: if world_pos.length_squared() <= 0.0001: return var current: float = world_pos.angle(); _whole_prop_total_angle += wrapf(current - _whole_prop_previous_angle, -PI, PI); _whole_prop_previous_angle = current var angle: float = snappedf(_whole_prop_total_angle, deg_to_rad(15.0)) if ctrl_pressed else _whole_prop_total_angle if is_zero_approx(angle): _whole_prop_preview_angle = 0.0 _whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true) _whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true) queue_redraw() return if not can_rotate_all_shapes(angle) or not bool(PropAnchorDataScript.try_rotate_non_origin(_whole_prop_start_anchors, angle).get("ok", false)): return _whole_prop_preview_angle = angle _whole_prop_preview_shapes = _rotated_shape_copy(_whole_prop_start_shapes, angle); _whole_prop_preview_anchors = _rotated_anchor_copy(_whole_prop_start_anchors, angle); queue_redraw(); return var direction: Vector2 = _whole_prop_corner - _whole_prop_start_bounds.get_center() var denominator: float = direction.length_squared() if denominator <= 0.0001: return var requested: float = 1.0 + (world_pos - _whole_prop_start_pointer).dot(direction) / denominator var factor: float = clampf(requested, WHOLE_PROP_FACTOR_MIN, WHOLE_PROP_FACTOR_MAX) if not is_finite(factor): return if is_equal_approx(factor, 1.0): _whole_prop_factor = 1.0 _whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true) _whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true) queue_redraw() return if not can_scale_all_shapes(factor) or not bool(PropAnchorDataScript.try_scale_non_origin(_whole_prop_start_anchors, factor).get("ok", false)): return _whole_prop_factor = factor _whole_prop_preview_shapes = _scaled_shape_copy(_whole_prop_start_shapes, factor) _whole_prop_preview_anchors = _scaled_anchor_copy(_whole_prop_start_anchors, factor) queue_redraw() func _end_whole_prop_resize() -> void: if not _whole_prop_dragging: return var factor: float = _whole_prop_factor; var drag: WholePropDrag = _whole_prop_drag; var delta: Vector2 = _whole_prop_delta; var angle: float = _whole_prop_preview_angle cancel_whole_prop_resize() if drag == WholePropDrag.MOVE and not delta.is_zero_approx(): whole_prop_move_requested.emit(delta) elif drag == WholePropDrag.ROTATE and not is_zero_approx(angle): whole_prop_rotate_requested.emit(angle) elif drag == WholePropDrag.SCALE and not is_equal_approx(factor, 1.0): whole_prop_resize_requested.emit(factor) func _scaled_shape_copy(source: Array[Dictionary], factor: float) -> Array[Dictionary]: var result: Array[Dictionary] = source.duplicate(true) for i: int in result.size(): var shape: Dictionary = result[i] var points: PackedVector2Array = shape.get("points", PackedVector2Array()) for j: int in points.size(): points[j] *= factor shape["points"] = points if str(shape.get("shape_type", "")) == "circle": shape["radius"] = float(shape.get("radius", 0.0)) * factor result[i] = shape return result func _scaled_anchor_copy(source: Dictionary, factor: float) -> Dictionary: var result: Dictionary = source.duplicate(true) for id: String in result: if id == "origin": continue var anchor: Dictionary = result[id] anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) * factor result[id] = anchor return result func _translated_shape_copy(source: Array[Dictionary], delta: Vector2) -> Array[Dictionary]: var result: Array[Dictionary] = source.duplicate(true) for i: int in result.size(): var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) for j: int in pts.size(): pts[j] += delta sd["points"] = pts; result[i] = sd return result func _translated_anchor_copy(source: Dictionary, delta: Vector2) -> Dictionary: var result: Dictionary = source.duplicate(true) for id: String in result: if id != "origin": var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) + delta; result[id] = anchor return result func _rotated_shape_copy(source: Array[Dictionary], angle: float) -> Array[Dictionary]: var result: Array[Dictionary] = source.duplicate(true) for i: int in result.size(): var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) for j: int in pts.size(): pts[j] = pts[j].rotated(angle) sd["points"] = pts; result[i] = sd return result func _rotated_anchor_copy(source: Dictionary, angle: float) -> Dictionary: var result: Dictionary = source.duplicate(true) for id: String in result: if id != "origin": var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle); anchor["rotation_deg"] = wrapf(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle), -180.0, 180.0); result[id] = anchor return result func _whole_preview_bounds() -> Rect2: var found: bool = false var result: Rect2 = Rect2() for shape: Dictionary in _whole_prop_preview_shapes: var bounds: Rect2 = _shape_bbox(shape) if not found: result = bounds found = true else: result = result.merge(bounds) return result func _rect_corners(bounds: Rect2) -> Array[Vector2]: return [bounds.position, Vector2(bounds.end.x, bounds.position.y), bounds.end, Vector2(bounds.position.x, bounds.end.y)] func _whole_prop_hit_corner(bounds: Rect2, world_pos: Vector2) -> Vector2: var threshold: float = WHOLE_PROP_HANDLE_HIT_RADIUS / _zoom for corner: Vector2 in _rect_corners(bounds): if world_pos.distance_to(corner) <= threshold: return corner return Vector2.INF func _screen_to_world(pos: Vector2) -> Vector2: return (pos - _pan_offset) / _zoom func _global_point_target_rect(world_pos: Vector2) -> Rect2: var screen_pos := global_position + _pan_offset + world_pos * _zoom return Rect2(screen_pos - Vector2(6.0, 6.0), Vector2(12.0, 12.0)) func _global_shape_target_rect(shape_idx: int) -> Rect2: if shape_idx < 0 or shape_idx >= shapes.size(): return Rect2() var world_bounds: Rect2 = _shape_bbox(shapes[shape_idx]) var screen_position := global_position + _pan_offset + world_bounds.position * _zoom return Rect2(screen_position, world_bounds.size * _zoom).grow(4.0) func _snap_to_grid(pos: Vector2) -> Vector2: var gs := float(_grid_size) if gs <= 0.0: return pos return Vector2(round(pos.x / gs) * gs, round(pos.y / gs) * gs) func _get_context_world_pos() -> Vector2: var click_pos: Variant = _context_menu.get_meta("click_position", Vector2.ZERO) var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2.ZERO # The stored click position is always a valid world-space coordinate (from # _screen_to_world at click time). Negative coords are normal after panning, # so there is no bounds clamp; only guard non-finite values. if not is_finite(world_pos.x) or not is_finite(world_pos.y): return _screen_to_world(size * 0.5) return world_pos func _make_shape_dict() -> Dictionary: return { "shape_type": "polygon", "points": PackedVector2Array(), "radius": DEFAULT_CIRCLE_RADIUS, "fill_color": DEFAULT_FILL_HEX, "outline_color": DEFAULT_OUTLINE_HEX, "outline_width": DEFAULT_OUTLINE_WIDTH, "closed": true, "vertex_flags": PackedInt32Array(), "reshaped": false, } func _zero_flags(count: int) -> PackedInt32Array: var flags := PackedInt32Array() flags.resize(count) return flags func _circle_points(center: Vector2, radius: float, segments: int) -> PackedVector2Array: var pts := PackedVector2Array() var seg: int = maxi(segments, 3) for i: int in seg: var angle: float = TAU * float(i) / float(seg) pts.append(center + Vector2(cos(angle), sin(angle)) * radius) return pts func _shape_bbox(sd: Dictionary) -> Rect2: var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.is_empty(): return Rect2() var bbox := Rect2(pts[0], Vector2.ZERO) for pt: Vector2 in pts: bbox = bbox.expand(pt) return bbox func _shape_has_geometry(sd: Dictionary) -> bool: var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.size() < 2: return false for point: Vector2 in pts: if not is_finite(point.x) or not is_finite(point.y): return false return true func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int: if shape_idx < 0 or shape_idx >= shapes.size(): return -1 var pts: PackedVector2Array = shapes[shape_idx].get("points", PackedVector2Array()) var threshold: float = HANDLE_RADIUS_SELECTION / _zoom for i: int in pts.size(): if world_pos.distance_to(pts[i]) <= threshold: return i return -1 ## Returns the frontmost shape whose segment is within EDGE_HIT_THRESHOLD / ## _zoom of world_pos, else -1. Used by the POINTS-mode right-click menu. func hit_test_segment(world_pos: Vector2) -> int: for i: int in range(shapes.size() - 1, -1, -1): if _is_point_near_outline(i, world_pos): return i return -1 func _is_point_in_shape(shape_idx: int, world_pos: Vector2) -> bool: if shape_idx < 0 or shape_idx >= shapes.size(): return false var sd: Dictionary = shapes[shape_idx] 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) return _distance_to_shape_pts(pts, closed, world_pos) <= EDGE_HIT_THRESHOLD / _zoom func _is_point_near_outline(shape_idx: int, world_pos: Vector2) -> bool: if shape_idx < 0 or shape_idx >= shapes.size(): return false var sd: Dictionary = shapes[shape_idx] 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 pts.size() - 1: min_dist = minf(min_dist, _point_to_segment_distance(world_pos, pts[i], pts[i + 1])) if closed and pts.size() >= 2: min_dist = minf(min_dist, _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0])) 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) return point.distance_to(a + ab * t) func _hit_test_rotation_dot(sd: Dictionary, world_pos: Vector2) -> bool: var bbox: Rect2 = _shape_bbox(sd) if bbox.size == Vector2.ZERO: return false var rot_center := Vector2(bbox.position.x + bbox.size.x * 0.5, bbox.end.y + ROTATION_CIRCLE_OFFSET) return world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom func _hit_test_scale_corner(sd: Dictionary, world_pos: Vector2) -> int: var bbox: Rect2 = _shape_bbox(sd) if bbox.size == Vector2.ZERO: return -1 var corners: Array[Vector2] = [ bbox.position, Vector2(bbox.end.x, bbox.position.y), bbox.end, Vector2(bbox.position.x, bbox.end.y), ] var threshold: float = SCALE_CROSS_HIT_RADIUS / _zoom for i: int in corners.size(): if world_pos.distance_to(corners[i]) <= threshold: return i return -1 func _apply_point_transform(translate: Vector2, angle: float, factor: Vector2, pivot: Vector2) -> void: if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size(): return var sd: Dictionary = shapes[_selected_shape_idx] var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.is_empty(): return # Non-uniform scaling deforms a circle; its radius field no longer describes # the shape, so mark it reshaped (Round 6). if str(sd.get("shape_type", "")) == "circle" and absf(factor.x - factor.y) > 0.0001: sd["reshaped"] = true var center: Vector2 = pivot if pivot != Vector2.ZERO else _shape_bbox(sd).get_center() var new_pts := PackedVector2Array() for pt: Vector2 in pts: var p: Vector2 = center + (pt - center).rotated(angle) p = center + Vector2((p.x - center.x) * factor.x, (p.y - center.y) * factor.y) new_pts.append(p + translate) sd["points"] = new_pts queue_redraw() _emit_changed() func _coerce_shape(item: Dictionary) -> Dictionary: var shape_type: String = str(item.get("shape_type", "polygon")) if not PropFileScript.SHAPE_STRINGS.has(shape_type): shape_type = "polygon" var pts := PackedVector2Array() var pts_variant: Variant = item.get("points", []) if pts_variant is Array: for p: Variant in pts_variant as Array: if p is Dictionary: var d: Dictionary = p as Dictionary pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0)))) elif p is Vector2: pts.append(p as Vector2) elif pts_variant is PackedVector2Array: pts = pts_variant as PackedVector2Array var radius: float = float(item.get("radius", DEFAULT_CIRCLE_RADIUS)) if shape_type == "circle" and pts.is_empty(): pts = _circle_points(Vector2.ZERO, radius, DEFAULT_CIRCLE_SEGMENTS) var closed: bool if item.has("closed"): closed = bool(item.get("closed", false)) else: closed = shape_type != "line" var vf := PackedInt32Array() var vf_variant: Variant = item.get("vertex_flags", []) if vf_variant is Array: var vf_arr: Array = vf_variant as Array 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 as PackedInt32Array if vf.size() != pts.size(): vf = _zero_flags(pts.size()) return { "shape_type": shape_type, "points": pts, "radius": radius, "fill_color": str(item.get("fill_color", DEFAULT_FILL_HEX)), "outline_color": str(item.get("outline_color", DEFAULT_OUTLINE_HEX)), "outline_width": float(item.get("outline_width", DEFAULT_OUTLINE_WIDTH)), "closed": closed, "vertex_flags": vf, "reshaped": bool(item.get("reshaped", false)), } func _export_shape(sd: Dictionary) -> Dictionary: var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) var pts_arr: Array = [] for pt: Vector2 in pts: pts_arr.append({"x": pt.x, "y": pt.y}) var flags: PackedInt32Array = sd.get("vertex_flags", PackedInt32Array()) var flags_arr: Array = [] for i: int in flags.size(): flags_arr.append(flags[i]) var result := { "shape_type": str(sd.get("shape_type", "polygon")), "points": pts_arr, "fill_color": str(sd.get("fill_color", DEFAULT_FILL_HEX)), "outline_color": str(sd.get("outline_color", DEFAULT_OUTLINE_HEX)), "outline_width": float(sd.get("outline_width", DEFAULT_OUTLINE_WIDTH)), "closed": bool(sd.get("closed", false)), "vertex_flags": flags_arr, "reshaped": bool(sd.get("reshaped", false)), } if str(sd.get("shape_type", "")) == "circle": var radius: float = float(sd.get("radius", DEFAULT_CIRCLE_RADIUS)) if not pts.is_empty(): radius = _recompute_radius(sd) result["radius"] = radius return result func _recompute_radius(sd: Dictionary) -> float: var pts: PackedVector2Array = sd.get("points", PackedVector2Array()) if pts.is_empty(): return float(sd.get("radius", DEFAULT_CIRCLE_RADIUS)) var centroid := Vector2.ZERO for pt: Vector2 in pts: centroid += pt centroid /= float(pts.size()) var total: float = 0.0 for pt: Vector2 in pts: total += centroid.distance_to(pt) return maxf(total / float(pts.size()), 0.001)