Files
stickman/scripts/editors/prop/prop_canvas.gd
ryan ddb298f631 Add visual harmonization tests for editor UI components and ground finish chooser
- 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.
2026-10-23 15:20:12 -04:00

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)