- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency. - Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values. - Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage. - Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
2119 lines
79 KiB
GDScript
2119 lines
79 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 _create_point_armed: 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.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.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:
|
|
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_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 _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 _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)
|