- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality. - Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management. - Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens. - Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
1402 lines
47 KiB
GDScript
1402 lines
47 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)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums / constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
enum ShapeType { POLYGON, CIRCLE, LINE }
|
|
enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }
|
|
|
|
enum DragMode { NONE, VERTEX, TRANSLATE, 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 }
|
|
|
|
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 TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
|
|
const PropFileScript := preload("res://scripts/prop_file.gd")
|
|
|
|
const SELECTION_CORE_COLOR := Color.WHITE
|
|
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
|
|
const FLASH_COLOR := Color.YELLOW
|
|
|
|
# Primitive defaults mirror PropUtils.create_* generator defaults.
|
|
const DEFAULT_FILL_HEX: String = "#8C6133"
|
|
const DEFAULT_OUTLINE_HEX: String = "#40290F"
|
|
const DEFAULT_OUTLINE_WIDTH: float = 2.0
|
|
const DEFAULT_BOX_SIZE := Vector2(48.0, 48.0)
|
|
const DEFAULT_BALL_RADIUS: float = 24.0
|
|
const DEFAULT_PLANK_LENGTH: float = 160.0
|
|
const DEFAULT_PLANK_THICKNESS: float = 16.0
|
|
const DEFAULT_TRIANGLE_BASE: float = 56.0
|
|
const DEFAULT_TRIANGLE_HEIGHT: float = 48.0
|
|
const DEFAULT_LINE_LENGTH: float = 120.0
|
|
const DEFAULT_CIRCLE_RADIUS: float = 40.0
|
|
|
|
# Context menu item ids.
|
|
const MENU_REMOVE_POINT := 1
|
|
const MENU_CREATE_POINT := 2
|
|
const MENU_COPY := 3
|
|
const MENU_PASTE := 4
|
|
const MENU_DELETE := 5
|
|
const MENU_MIRROR_X := 6
|
|
const MENU_MIRROR_Y := 7
|
|
const MENU_BRING_FORWARD := 8
|
|
const MENU_SEND_BACK := 9
|
|
const MENU_ADD_BOX := 10
|
|
const MENU_ADD_PLANK := 12
|
|
const MENU_ADD_TRIANGLE := 13
|
|
const MENU_ADD_LINE := 14
|
|
const MENU_ADD_CIRCLE := 15
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var shapes: Array[Dictionary] = []
|
|
var _selected_shape_idx: int = -1
|
|
var _zoom: float = 1.0
|
|
var _pan_offset: Vector2 = Vector2.ZERO
|
|
var _grid_size: int = 15
|
|
var _snap_enabled: bool = false
|
|
var _has_clipboard: bool = false
|
|
var _context_menu: PopupMenu
|
|
var _edit_mode: EditMode = EditMode.TRANSFORM
|
|
|
|
# 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)
|
|
_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 = EditMode.TRANSFORM if mode != EditMode.POINTS else EditMode.POINTS
|
|
if m == _edit_mode:
|
|
return
|
|
_edit_mode = m
|
|
_drag_mode = DragMode.NONE
|
|
_dragging_vertex = -1
|
|
queue_redraw()
|
|
mode_changed.emit(_edit_mode)
|
|
|
|
|
|
func get_edit_mode() -> int:
|
|
return _edit_mode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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.
|
|
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
|
|
_zoom = 1.0
|
|
_pan_offset = Vector2.ZERO
|
|
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.
|
|
shapes.clear()
|
|
_selected_shape_idx = -1
|
|
_drag_mode = DragMode.NONE
|
|
_dragging_vertex = -1
|
|
_zoom = 1.0
|
|
_pan_offset = Vector2.ZERO
|
|
queue_redraw()
|
|
|
|
|
|
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
|
|
_pan_offset = Vector2.ZERO
|
|
queue_redraw()
|
|
|
|
|
|
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():
|
|
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))
|
|
return
|
|
|
|
for i: int in shapes.size():
|
|
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
|
|
|
|
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
|
|
_draw_selection_gizmos(shapes[_selected_shape_idx])
|
|
|
|
|
|
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 mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
|
_handle_right_click(mb, world_pos)
|
|
return
|
|
if mb.button_index == MOUSE_BUTTON_LEFT:
|
|
if mb.pressed:
|
|
_handle_left_press(world_pos)
|
|
else:
|
|
_handle_left_release()
|
|
|
|
elif event is InputEventMouseMotion:
|
|
var mm := event as InputEventMouseMotion
|
|
if _is_panning:
|
|
_pan_offset += (mm.position - _pan_start)
|
|
_pan_start = mm.position
|
|
queue_redraw()
|
|
return
|
|
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
|
|
|
|
|
|
func _zoom_at(local_pos: Vector2, factor: float) -> void:
|
|
var world := _screen_to_world(local_pos)
|
|
_zoom = clampf(_zoom * factor, MIN_ZOOM, MAX_ZOOM)
|
|
_pan_offset = local_pos - world * _zoom
|
|
queue_redraw()
|
|
|
|
|
|
func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
|
if _edit_mode == EditMode.POINTS:
|
|
_handle_points_right_click(mb, world_pos)
|
|
return
|
|
_handle_transform_right_click(mb, world_pos)
|
|
|
|
|
|
func _handle_points_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
|
# POINTS mode = point editing only: Remove Point on a vertex, Create Point
|
|
# near a segment, nothing anywhere else (no Copy/Erase/Flip/z-order, no
|
|
# Add-Shape).
|
|
var context_shape: int = -1
|
|
var context_vertex: int = -1
|
|
if _selected_shape_idx >= 0:
|
|
var hit_idx := _hit_test_vertex(_selected_shape_idx, world_pos)
|
|
if hit_idx >= 0:
|
|
context_shape = _selected_shape_idx
|
|
context_vertex = hit_idx
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
if context_shape >= 0:
|
|
break
|
|
if i == _selected_shape_idx:
|
|
continue
|
|
var hit_idx := _hit_test_vertex(i, world_pos)
|
|
if hit_idx >= 0:
|
|
context_shape = i
|
|
context_vertex = hit_idx
|
|
|
|
_context_menu.clear()
|
|
if context_shape >= 0 and context_vertex >= 0:
|
|
_context_menu.set_meta("context_shape", context_shape)
|
|
_context_menu.set_meta("context_vertex", context_vertex)
|
|
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
return
|
|
|
|
# Segment proximity -> Create Point (selects the shape first).
|
|
var seg_shape := hit_test_segment(world_pos)
|
|
if seg_shape >= 0:
|
|
_selected_shape_idx = seg_shape
|
|
_context_menu.set_meta("context_shape", seg_shape)
|
|
_context_menu.set_meta("click_position", world_pos)
|
|
queue_redraw()
|
|
shape_selected.emit(seg_shape)
|
|
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
return
|
|
|
|
# Interior body: select only, no menu.
|
|
var body_shape: int = -1
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
if _is_point_in_shape(i, world_pos):
|
|
body_shape = i
|
|
break
|
|
if body_shape >= 0:
|
|
_selected_shape_idx = body_shape
|
|
queue_redraw()
|
|
shape_selected.emit(body_shape)
|
|
# Empty canvas -> no menu at all (adding shapes is Arrange-mode work).
|
|
|
|
|
|
func _handle_transform_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
|
var context_shape: int = -1
|
|
|
|
# Shape hit (front to back).
|
|
var hit_shape: int = -1
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
if _is_point_in_shape(i, world_pos):
|
|
hit_shape = i
|
|
break
|
|
|
|
_context_menu.clear()
|
|
if hit_shape >= 0:
|
|
_selected_shape_idx = hit_shape
|
|
_context_menu.set_meta("context_shape", hit_shape)
|
|
_context_menu.set_meta("click_position", world_pos)
|
|
queue_redraw()
|
|
shape_selected.emit(hit_shape)
|
|
_context_menu.add_item("Copy", MENU_COPY)
|
|
if _has_clipboard:
|
|
_context_menu.add_item("Paste", MENU_PASTE)
|
|
_context_menu.add_item("Erase", MENU_DELETE)
|
|
_context_menu.add_separator()
|
|
_context_menu.add_item("Flip Left-Right", MENU_MIRROR_X)
|
|
_context_menu.add_item("Flip Up-Down", MENU_MIRROR_Y)
|
|
_context_menu.add_separator()
|
|
_context_menu.add_item("Bring Forward", MENU_BRING_FORWARD)
|
|
_context_menu.add_item("Send Back", MENU_SEND_BACK)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
return
|
|
|
|
# Empty canvas -> add primitives.
|
|
_context_menu.set_meta("click_position", world_pos)
|
|
_context_menu.add_item("Add Box", MENU_ADD_BOX)
|
|
_context_menu.add_item("Add Plank", MENU_ADD_PLANK)
|
|
_context_menu.add_item("Add Triangle", MENU_ADD_TRIANGLE)
|
|
_context_menu.add_item("Add Line", MENU_ADD_LINE)
|
|
_context_menu.add_item("Add Circle", MENU_ADD_CIRCLE)
|
|
if _has_clipboard:
|
|
_context_menu.add_separator()
|
|
_context_menu.add_item("Paste", MENU_PASTE)
|
|
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
|
|
|
|
|
func _handle_left_press(world_pos: Vector2) -> void:
|
|
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
# 1. Rotation dot (below the bbox)
|
|
if _hit_test_rotation_dot(sd, world_pos):
|
|
var bbox: Rect2 = _shape_bbox(sd)
|
|
_drag_mode = DragMode.ROTATE
|
|
_rotate_center = bbox.get_center()
|
|
_rotate_total_delta = 0.0
|
|
_rotate_prev_angle = rad_to_deg(_rotate_center.angle_to_point(world_pos))
|
|
_gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array()))
|
|
queue_redraw()
|
|
return
|
|
# 2. Scale corner
|
|
var corner_idx := _hit_test_scale_corner(sd, world_pos)
|
|
if corner_idx >= 0:
|
|
var bbox: Rect2 = _shape_bbox(sd)
|
|
_drag_mode = DragMode.SCALE
|
|
_scale_corner = corner_idx
|
|
_scale_start_world = world_pos
|
|
_rotate_center = bbox.get_center()
|
|
_gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array()))
|
|
queue_redraw()
|
|
return
|
|
|
|
if _edit_mode == EditMode.POINTS:
|
|
# 3. Vertex (selected shape first, then others front to back)
|
|
var hit_shape_v: int = -1
|
|
var hit_idx: int = -1
|
|
if _selected_shape_idx >= 0:
|
|
hit_idx = _hit_test_vertex(_selected_shape_idx, world_pos)
|
|
if hit_idx >= 0:
|
|
hit_shape_v = _selected_shape_idx
|
|
if hit_shape_v < 0:
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
if i == _selected_shape_idx:
|
|
continue
|
|
hit_idx = _hit_test_vertex(i, world_pos)
|
|
if hit_idx >= 0:
|
|
hit_shape_v = i
|
|
break
|
|
if hit_shape_v >= 0:
|
|
if hit_shape_v != _selected_shape_idx:
|
|
_selected_shape_idx = hit_shape_v
|
|
shape_selected.emit(hit_shape_v)
|
|
var sd: Dictionary = shapes[hit_shape_v]
|
|
_drag_mode = DragMode.VERTEX
|
|
_dragging_vertex = hit_idx
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
_drag_offset = world_pos - pts[hit_idx]
|
|
queue_redraw()
|
|
return
|
|
|
|
# Shape body hit (front to back)
|
|
var hit_shape: int = -1
|
|
for i: int in range(shapes.size() - 1, -1, -1):
|
|
if _is_point_in_shape(i, world_pos):
|
|
hit_shape = i
|
|
break
|
|
if hit_shape >= 0:
|
|
if hit_shape != _selected_shape_idx:
|
|
_selected_shape_idx = hit_shape
|
|
shape_selected.emit(hit_shape)
|
|
if _edit_mode == EditMode.TRANSFORM:
|
|
# Drag to move (translate)
|
|
_drag_mode = DragMode.TRANSLATE
|
|
_shape_drag_start = world_pos
|
|
_shape_drag_original_points = PackedVector2Array(shapes[hit_shape].get("points", PackedVector2Array()))
|
|
# POINTS mode: select-only (no translate) — Round 2 decision.
|
|
queue_redraw()
|
|
return
|
|
|
|
_selected_shape_idx = -1
|
|
_drag_mode = DragMode.NONE
|
|
queue_redraw()
|
|
shape_selected.emit(-1)
|
|
|
|
|
|
func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void:
|
|
match _drag_mode:
|
|
DragMode.VERTEX:
|
|
if _selected_shape_idx >= 0:
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if _dragging_vertex >= 0 and _dragging_vertex < pts.size():
|
|
var target := world_pos - _drag_offset
|
|
if _snap_enabled:
|
|
target = _snap_to_grid(target)
|
|
pts[_dragging_vertex] = target
|
|
sd["points"] = pts
|
|
sd["reshaped"] = true
|
|
queue_redraw()
|
|
DragMode.TRANSLATE:
|
|
if _selected_shape_idx >= 0:
|
|
var target := world_pos
|
|
if _snap_enabled:
|
|
target = _snap_to_grid(target)
|
|
var delta := target - _shape_drag_start
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
var new_pts := PackedVector2Array()
|
|
for pt: Vector2 in _shape_drag_original_points:
|
|
new_pts.append(pt + delta)
|
|
sd["points"] = new_pts
|
|
queue_redraw()
|
|
DragMode.ROTATE:
|
|
if not _gizmo_base_points.is_empty():
|
|
var current_angle := rad_to_deg(_rotate_center.angle_to_point(world_pos))
|
|
var frame_delta := current_angle - _rotate_prev_angle
|
|
if frame_delta > 180.0:
|
|
frame_delta -= 360.0
|
|
elif frame_delta < -180.0:
|
|
frame_delta += 360.0
|
|
_rotate_total_delta += frame_delta
|
|
_rotate_prev_angle = current_angle
|
|
var delta := deg_to_rad(_rotate_total_delta)
|
|
if ctrl_pressed:
|
|
delta = snappedf(delta, deg_to_rad(ROTATION_SNAP_DEG))
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
var new_pts := PackedVector2Array()
|
|
for pt: Vector2 in _gizmo_base_points:
|
|
new_pts.append(_rotate_center + (pt - _rotate_center).rotated(delta))
|
|
sd["points"] = new_pts
|
|
queue_redraw()
|
|
DragMode.SCALE:
|
|
if not _gizmo_base_points.is_empty():
|
|
var rel_start := _scale_start_world - _rotate_center
|
|
var rel_cur := world_pos - _rotate_center
|
|
var fx: float = rel_cur.x / rel_start.x if absf(rel_start.x) > 0.001 else 1.0
|
|
var fy: float = rel_cur.y / rel_start.y if absf(rel_start.y) > 0.001 else 1.0
|
|
if ctrl_pressed:
|
|
var uniform: float = fx if absf(fx) > absf(fy) else fy
|
|
fx = uniform
|
|
fy = uniform
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
if str(sd.get("shape_type", "")) == "circle" and absf(fx - fy) > 0.0001:
|
|
sd["reshaped"] = true
|
|
var new_pts := PackedVector2Array()
|
|
for pt: Vector2 in _gizmo_base_points:
|
|
var rel := pt - _rotate_center
|
|
new_pts.append(_rotate_center + Vector2(rel.x * fx, rel.y * fy))
|
|
sd["points"] = new_pts
|
|
queue_redraw()
|
|
_:
|
|
pass
|
|
|
|
|
|
func _handle_left_release() -> void:
|
|
match _drag_mode:
|
|
DragMode.VERTEX:
|
|
_drag_mode = DragMode.NONE
|
|
_dragging_vertex = -1
|
|
_drag_offset = Vector2.ZERO
|
|
if _selected_shape_idx >= 0:
|
|
sanitize_shape(_selected_shape_idx)
|
|
else:
|
|
queue_redraw()
|
|
DragMode.TRANSLATE:
|
|
_drag_mode = DragMode.NONE
|
|
_shape_drag_original_points = PackedVector2Array()
|
|
queue_redraw()
|
|
_emit_changed()
|
|
DragMode.ROTATE, DragMode.SCALE:
|
|
_drag_mode = DragMode.NONE
|
|
_gizmo_base_points = PackedVector2Array()
|
|
if _selected_shape_idx >= 0:
|
|
sanitize_shape(_selected_shape_idx)
|
|
else:
|
|
queue_redraw()
|
|
_:
|
|
pass
|
|
|
|
|
|
func _on_context_menu_id_pressed(id: int) -> void:
|
|
var context_shape: Variant = _context_menu.get_meta("context_shape", -1)
|
|
var shape_idx: int = int(context_shape) if context_shape is int else -1
|
|
match id:
|
|
MENU_REMOVE_POINT:
|
|
var context_vertex: Variant = _context_menu.get_meta("context_vertex", -1)
|
|
remove_vertex(shape_idx, int(context_vertex) if context_vertex is int else -1)
|
|
MENU_CREATE_POINT:
|
|
create_point_on_edge(_get_context_world_pos())
|
|
MENU_COPY:
|
|
var copied: Dictionary = copy_shape(shape_idx)
|
|
if not copied.is_empty():
|
|
shape_copy_requested.emit(copied)
|
|
MENU_PASTE:
|
|
_paste_from_context()
|
|
MENU_DELETE:
|
|
if shape_idx >= 0 and shape_idx < shapes.size():
|
|
shapes.remove_at(shape_idx)
|
|
_selected_shape_idx = clampi(shape_idx - 1, -1, shapes.size() - 1)
|
|
queue_redraw()
|
|
_emit_changed()
|
|
MENU_MIRROR_X:
|
|
mirror_x(shape_idx)
|
|
MENU_MIRROR_Y:
|
|
mirror_y(shape_idx)
|
|
MENU_BRING_FORWARD:
|
|
bring_forward(shape_idx)
|
|
MENU_SEND_BACK:
|
|
send_back(shape_idx)
|
|
MENU_ADD_BOX:
|
|
add_primitive("box", _get_context_world_pos(), _add_params())
|
|
MENU_ADD_PLANK:
|
|
add_primitive("plank", _get_context_world_pos(), _add_params())
|
|
MENU_ADD_TRIANGLE:
|
|
add_primitive("triangle", _get_context_world_pos(), _add_params())
|
|
MENU_ADD_LINE:
|
|
add_primitive("line", _get_context_world_pos(), _add_params())
|
|
MENU_ADD_CIRCLE:
|
|
add_primitive("circle", _get_context_world_pos(), _add_params())
|
|
|
|
|
|
## Overridable hook so a subclass can inject default colors and dimensions
|
|
## into context-menu additions.
|
|
func _add_params() -> Dictionary:
|
|
return {}
|
|
|
|
|
|
func _paste_from_context() -> void:
|
|
paste_from_context_requested.emit(_get_context_world_pos())
|
|
|
|
|
|
## Insert a new USER_CREATED vertex at the cursor's projection onto the nearest
|
|
## segment of the selected shape. Emits vertex_created and flashes the new
|
|
## vertex (FLASH_DURATION). Returns the new vertex index, or -1 on failure.
|
|
func create_point_on_edge(world_pos: Vector2) -> int:
|
|
if _selected_shape_idx < 0 or _selected_shape_idx >= shapes.size():
|
|
return -1
|
|
var sd: Dictionary = shapes[_selected_shape_idx]
|
|
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
|
|
if pts.size() < 2:
|
|
return -1
|
|
var closed: bool = bool(sd.get("closed", false))
|
|
var best_edge: int = 0
|
|
var best_dist: float = INF
|
|
for i: int in pts.size() - 1:
|
|
var seg_dist: float = _point_to_segment_distance(world_pos, pts[i], pts[i + 1])
|
|
if seg_dist < best_dist:
|
|
best_dist = seg_dist
|
|
best_edge = i
|
|
if closed and pts.size() >= 2:
|
|
var seg_dist: float = _point_to_segment_distance(world_pos, pts[pts.size() - 1], pts[0])
|
|
if seg_dist < best_dist:
|
|
best_dist = seg_dist
|
|
best_edge = pts.size() - 1
|
|
var projection: Vector2 = _project_point_onto_segment(world_pos, pts[best_edge], pts[(best_edge + 1) % pts.size()])
|
|
var insert_idx: int = best_edge + 1
|
|
if closed and best_edge == pts.size() - 1:
|
|
insert_idx = pts.size()
|
|
insert_vertex(_selected_shape_idx, insert_idx, projection)
|
|
vertex_created.emit(_selected_shape_idx, insert_idx)
|
|
_flash_vertex = insert_idx
|
|
_flash_remaining = FLASH_DURATION
|
|
queue_redraw()
|
|
return insert_idx
|
|
|
|
|
|
func _project_point_onto_segment(point: Vector2, a: Vector2, b: Vector2) -> Vector2:
|
|
var ab: Vector2 = b - a
|
|
var ap: Vector2 = point - a
|
|
var ab_len_sq: float = ab.length_squared()
|
|
if ab_len_sq < 0.0001:
|
|
return a
|
|
var t: float = clampf(ap.dot(ab) / ab_len_sq, 0.0, 1.0)
|
|
return a + ab * t
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _emit_changed() -> void:
|
|
shape_changed.emit(get_shape_data())
|
|
|
|
|
|
func _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 _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)
|