2083 lines
78 KiB
GDScript
2083 lines
78 KiB
GDScript
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 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 _context_menu: PopupMenu
|
|
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 = PopupMenu.new()
|
|
_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.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 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.add_theme_font_size_override("font_size", size)
|
|
|
|
|
|
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:
|
|
_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_item("Remove Point", MENU_REMOVE_POINT)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
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_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
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", MENU_COPY)
|
|
if _has_clipboard:
|
|
_context_menu.add_item("Paste", MENU_PASTE)
|
|
_context_menu.add_item("Erase", MENU_DELETE)
|
|
_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.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
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", MENU_PASTE)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
|
|
|
|
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 _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 _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)
|