class_name PropAnchorOverlay extends RefCounted signal anchors_changed(anchors: Dictionary) signal anchor_selected(anchor_id: String) enum DragMode { NONE, TRANSLATE, ROTATE } const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd") const MARKER_RADIUS: float = 9.0 const HIT_RADIUS: float = 15.0 const ROTATE_OFFSET: float = 28.0 const ROTATION_DOT_RADIUS: float = 5.0 const ROTATION_DOT_HIT_RADIUS: float = 12.0 var _anchors: Dictionary = PropAnchorDataScript.default_anchors() var _selected_id: String = "" var _show_inactive: bool = true var _drag_mode: DragMode = DragMode.NONE var _drag_offset: Vector2 = Vector2.ZERO func set_anchor_data(data: Variant) -> void: _anchors = PropAnchorDataScript.normalize_anchors(data) if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = "" func get_anchor_data() -> Dictionary: return _anchors.duplicate(true) func select_anchor(anchor_id: String) -> void: var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else "" if next_id != _selected_id: _selected_id = next_id anchor_selected.emit(_selected_id) func get_selected_anchor_id() -> String: return _selected_id func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled func hit_test_anchor(world_pos: Vector2, zoom: float) -> String: for id: String in _anchors: if id == PropAnchorDataScript.ORIGIN_ID: continue if not _visible(id): continue var anchor: Dictionary = _anchors[id] if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id return "" func begin_pointer(world_pos: Vector2, zoom: float) -> bool: # The dot is intentionally outside the marker hit target, so test it before # ordinary anchor selection. This matches the shape transform gizmo model. if not _selected_id.is_empty() and _anchors.has(_selected_id): var selected: Dictionary = _anchors[_selected_id] var role: String = str(selected.get("role", "custom")) var selected_pos: Vector2 = selected.get("position", Vector2.ZERO) var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001)) if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001): _drag_mode = DragMode.ROTATE return true var id: String = hit_test_anchor(world_pos, zoom) if id.is_empty(): return false select_anchor(id) var anchor: Dictionary = _anchors[id] var pos: Vector2 = anchor.get("position", Vector2.ZERO) var role: String = str(anchor.get("role", "custom")) _drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos return true func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void: if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return var anchor: Dictionary = _anchors[_selected_id].duplicate(true) if _drag_mode == DragMode.TRANSLATE: var pos: Vector2 = world_pos + _drag_offset if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size)) anchor["position"] = pos else: var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle()) if ctrl: angle = snappedf(angle, 15.0) anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0 _anchors[_selected_id] = anchor anchors_changed.emit(get_anchor_data()) func end_pointer() -> void: if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data()) _drag_mode = DragMode.NONE func draw_gizmos(canvas: CanvasItem, zoom: float) -> void: for id: String in _anchors: if id == PropAnchorDataScript.ORIGIN_ID: continue if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id) func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id) func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void: var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001) var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY) canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85)) if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom) elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color) else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom) if role == "grip" or role == "emitter": var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0 canvas.draw_line(pos, pos + direction, color, 2.0 / zoom) if selected: var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom) canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom) canvas.draw_line(pos, dot, color, 2.0 / zoom) canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85)) canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color) if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)