Files
nodewars/addons/curved_lines_2d/scalable_vector_shape_2d.gd
2026-05-20 16:34:39 +02:00

1347 lines
50 KiB
GDScript

@tool
extends Node2D
## A custom node that uses a Curve2D to control shapes like Line2D, Polygon2D with
## Original adapted code: https://www.hedberggames.com/blog/rendering-curves-in-godot
class_name ScalableVectorShape2D
## Emitted when a new set of points was calculated for the [member curve].
signal path_changed(new_points : PackedVector2Array)
## Emitted when all polygons are computed, also provides [member self] in order to keep track of the
## owning [ScalableVectorShape2D]
signal polygons_updated(polygons : Array[PackedVector2Array], poly_strokes : Array[PackedVector2Array], myself : ScalableVectorShape2D)
## Emitted when [member CanvasItem.set_notify_transform] was toggled on upon
## every transformation (used internally to handle changes in the position of cutouts)
signal transform_changed(ref_to_self : ScalableVectorShape2D)
## This signal is used internally in editor-mode to tell the DrawablePath2D tool that
## the instance of assigned [member line], [member polygon], or [member collision_polygon] has changed.
signal assigned_node_changed()
## This signal is emitted when the properties for describing an ellipse or rectangle change.
## Further reading: [member shape_type]
signal dimensions_changed()
signal clip_paths_changed()
## The constant used to convert a radius unit to the equivalent cubic Beziér control point length
const R_TO_CP = 0.5523
const CAP_MODE_MAP : Dictionary[Line2D.LineCapMode, Geometry2D.PolyEndType] = {
Line2D.LINE_CAP_NONE: Geometry2D.END_BUTT,
Line2D.LINE_CAP_ROUND: Geometry2D.END_ROUND,
Line2D.LINE_CAP_BOX: Geometry2D.END_SQUARE,
}
const JOINT_MODE_MAP : Dictionary[Line2D.LineJointMode, Geometry2D.PolyJoinType] = {
Line2D.LINE_JOINT_SHARP: Geometry2D.JOIN_MITER,
Line2D.LINE_JOINT_BEVEL: Geometry2D.JOIN_SQUARE,
Line2D.LINE_JOINT_ROUND: Geometry2D.JOIN_ROUND,
}
enum ShapeType {
## Gives every point in the [member curve] a handle, as well as their in- and out- control points.
## Ignores the [member size], [member offset], [member rx] and [member ry] properties when
## drawing the shape.
PATH,
## Keeps the shape of the [member curve] as a rectangle, based on the [member offset],
## [member size], [member rx] and [member ry].
## Provides one handle to change [member size], and two handles to change [member rx] and
## [member ry] for rounded corners.
## The [member offset] can change by using the pivot-tool in the 2D Editor
RECT,
## Keeps the shape of the [member curve] as an ellipse, based on the [member offset] and
## [member size]
## Provides one handle to change [member size]. The [member size] determines the radii of the
## ellipse on the y- and x- axis, so [member rx] and [member ry] are always sync'ed with
## [member size] (and vice-versa)
## The [member offset] can change by using the pivot-tool in the 2D Editor
ELLIPSE
}
enum CollisionObjectType {
NONE,
STATIC_BODY_2D,
AREA_2D,
ANIMATABLE_BODY_2D,
RIGID_BODY_2D,
CHARACTER_BODY_2D,
PHYSICAL_BONE_2D
}
@export_group("Fill")
## The 'Fill' of a [ScalableVectorShape2D] is simply an instance of a [Polygon2D] node
## assigned to the `polygon` property.
## If you remove that [Polygon2D] node, you need to unassign it here as well, before
## you can add a new 'Fill' with the 'Add Fill' button
## The polygon's shape is controlled by this node's curve ([Curve2D]) property,
## it does _not_ have to be the child of this ScalableVectorShape2D
@export var polygon: Polygon2D:
set(_poly):
polygon = _poly
assigned_node_changed.emit()
@export_group("Stroke")
## The color of the stroke, also sets the [member Line2D.default_color] of the
## [member line] and the [member Polygon2D.color] of the [member poly_stroke]
@export var stroke_color := Color.WHITE:
set(_c):
stroke_color = _c
if is_instance_valid(line):
line.default_color = _c
if is_instance_valid(poly_stroke):
poly_stroke.color = _c
assigned_node_changed.emit()
## The width of the stroke, also sets the [member Line2D.width] of the [member line]
@export_range(0.5, 100.0, 0.5, "suffix:px", "or_greater", "or_less")
var stroke_width := 10.0:
set(_sw):
stroke_width = _sw
if is_instance_valid(line):
line.width = _sw
assigned_node_changed.emit()
## The cap mode of the stroke start point, also sets the [member Line2D.begin_cap_mode]
## of the [member line].
## The [member poly_stroke] and [member collision_object] can only set _one_ cap mode
## using this property ([member begin_cap_mode]).
## Therefore the [member end_cap_mode] is ignored
@export var begin_cap_mode := Line2D.LINE_CAP_NONE:
set(_bcm):
begin_cap_mode = _bcm
if is_instance_valid(line):
line.begin_cap_mode = _bcm
assigned_node_changed.emit()
## The cap mode of the stroke end point, also sets the [member Line2D.end_cap_mode] of the
## [member line].
## The [member poly_stroke] and [member collision_object] can only set _one_ cap mode
## using the [member begin_cap_mode]-property
## This property ([member end_cap_mode]) is ignored by them!
@export var end_cap_mode := Line2D.LINE_CAP_NONE:
set(_ecm):
end_cap_mode = _ecm
if is_instance_valid(line):
line.end_cap_mode = _ecm
assigned_node_changed.emit()
## The line joint mode of the stroke, also sets the [member Line2D.line_joint_mode] of the
## [member line]
@export var line_joint_mode := Line2D.LINE_JOINT_SHARP:
set(_ljm):
line_joint_mode = _ljm
if is_instance_valid(line):
line.joint_mode = _ljm
assigned_node_changed.emit()
## The 'Stroke' of a [ScalableVectorShape2D] is simply an instance of a [Line2D] node
## assigned to the `line` property.
## If you remove that Line2D node, you need to unassign it here as well, before
## you can add a new 'Line2D Stroke' with the 'Add Line2D Stroke' button
## The line's shape is controlled by this node's curve ([Curve2D]) pproperty, it
## does _not_ have to be the child of this [ScalableVectorShape2D]
@export var line: Line2D:
set(_line):
line = _line
assigned_node_changed.emit()
## The 'Stroke' of a [ScalableVectorShape2D] can be an instance of a [Polygon2D] node
## assigned to the `poly_stroke` property.
## If you remove that Polygon2D node, you need to unassign it here as well, before
## you can add a new 'Poly Stroke' with the 'Add Polygon2D Stroke' button
## The line's shape is controlled by this node's curve ([Curve2D]) pproperty, it
## does _not_ have to be the child of this [ScalableVectorShape2D]
@export var poly_stroke: Polygon2D:
set(_ps):
poly_stroke = _ps
assigned_node_changed.emit()
@export_group("Collision")
## The [CollisionObject2D] containing the [CollisionPolygon2D] node(s) generated
## by this shape
@export var collision_object: CollisionObject2D:
set(_coll):
collision_object = _coll
assigned_node_changed.emit()
@export_subgroup("Collision Polygon2D*")
## The CollisionPolygon2D controlled by this node's curve property
## @deprecated: Use [member collision_object] instead.
@export var collision_polygon: CollisionPolygon2D:
set(_poly):
collision_polygon = _poly
assigned_node_changed.emit()
@export_group("Navigation")
@export var navigation_region: NavigationRegion2D:
set(_nav):
navigation_region = _nav
assigned_node_changed.emit()
## Controls the paramaters used to divide up the line in segments.
## These settings are prefilled with the default values.
@export_group("Curve settings")
## The [Curve2D] that dynamically triggers updates of the shapes assigned to this node
## Changes to this curve will also emit the path_changed signal with the updated points array
@export var curve: Curve2D = Curve2D.new():
set(_curve):
curve = _curve if _curve != null else Curve2D.new()
assigned_node_changed.emit()
## Controls whether the path is treated as static (only update in editor) or dynamic (can be updated during runtime)
## If you set this to true, be alert for potential performance issues
@export var update_curve_at_runtime: bool = false
## Controls how many subdivisions a curve segment may face before it is considered approximate enough.
## Each subdivision splits the segment in half, so the default 5 stages may mean up to 32 subdivisions
## per curve segment. Increase with care!
@export_range(1, 10) var max_stages : int = 5:
set(_max_stages):
max_stages = _max_stages
assigned_node_changed.emit()
## Controls how many degrees the midpoint of a segment may deviate from the real curve, before the
## segment has to be subdivided.
@export_range(0.0, 180.0) var tolerance_degrees := 4.0:
set(_tolerance_degrees):
tolerance_degrees = _tolerance_degrees
assigned_node_changed.emit()
## Manages the line segments which should be treated as arcs in stead of Bézier
## curves, see [class ScalableArc] for arc properties
@export var arc_list : ScalableArcList = ScalableArcList.new():
set(_arc_list):
arc_list = _arc_list if _arc_list != null else ScalableArcList.new()
assigned_node_changed.emit()
## You can assign a [class Node2D] to a point in the curve (by point index) in order to control
## that [class Node2D]'s [member Node2D.position]: it's global position will be set exactly to the
## curve point's global position
@export var glue_map : Dictionary[int, Node2D] = {}:
set(_map):
glue_map = _map
for p_idx in glue_map.keys():
if p_idx < 0 or p_idx >= curve.point_count:
printerr("Warning: point index key for glue_map not present in curve: ", p_idx)
assigned_node_changed.emit()
@export_group("Masking")
## Holds the list of shapes used to make cutouts out of this shape, or
## clippings of this shape when their [member use_interect_when_clipping]
## is flagged on
@export var clip_paths : Array[ScalableVectorShape2D] = []:
set(_clip_paths):
clip_paths = _clip_paths if clip_paths != null else []
for i in clip_paths.size():
if clip_paths[i] == self:
clip_paths[i] = null
clip_paths_changed.emit()
## When this shape is used as a cutout, this tells the parent shape to use
## the [method Geometry2D.intersect_polygons] operation in stead of the
## [method Geometry2D.clip_polygons] operation
@export var use_interect_when_clipping := false:
set(flag):
if flag:
use_union_in_stead_of_clipping = false
use_interect_when_clipping = flag
path_changed.emit()
## When this shape is used as a cutout, this tells the parent shape to use
## the [method Geometry2D.intersect_polygons] operation in stead of the
## [method Geometry2D.clip_polygons] operation
@export var use_union_in_stead_of_clipping := false:
set(flag):
if flag:
use_interect_when_clipping = false
use_union_in_stead_of_clipping = flag
path_changed.emit()
@export_group("Shape Type Settings")
## Determines what handles are shown in the editor and how the [member curve] is (re)drawn on changing
## properties [member size], [member offset], [member rx], and [member ry].
@export var shape_type := ShapeType.PATH:
set(st):
shape_type = st
if st == ShapeType.PATH:
assigned_node_changed.emit()
else:
if shape_type == ShapeType.RECT:
rx = 0.0
ry = 0.0
dimensions_changed.emit()
## The Ellipse/Rect's center relative to its pivot
@export var offset : Vector2 = Vector2(0.0, 0.0):
set(ofs):
offset = ofs
dimensions_changed.emit()
## The natural (unscaled) size of the Ellipse/Rect
@export var size : Vector2 = Vector2(100.0, 100.0):
set(sz):
if sz.x < 0:
sz.x = 0.001
if sz.y < 0:
sz.y = 0.001
if shape_type == ShapeType.RECT:
if sz.x < rx * 2.001:
sz.x = rx * 2.001
if sz.y < ry * 2.001:
sz.y = ry * 2.001
size = sz
dimensions_changed.emit()
elif shape_type == ShapeType.ELLIPSE:
size = sz
rx = sz.x * 0.5
ry = sz.y * 0.5
## The rotation of the Rect/Ellipse's points relative to its natural center
@export_range(-180.0, 180.0, 0.1, "radians_as_degrees") var spin := 0.0:
set(a):
spin = a
dimensions_changed.emit()
## The Ellipse's radius / the Rect's rounded corder along the x-axis.
@export var rx : float = 0.0:
set(_rx):
rx = _rx if _rx > 0 else 0
if shape_type == ShapeType.RECT:
if rx > size.x * 0.49:
rx = size.x * 0.49
dimensions_changed.emit()
## The Ellipse's radius / the Rect's rounded corder along the y-axis.
@export var ry : float = 0.0:
set(_ry):
ry = _ry if _ry > 0 else 0
if shape_type == ShapeType.RECT:
if ry > size.y * 0.49:
ry = size.y * 0.49
dimensions_changed.emit()
@export_group("Editor settings")
## The [Color] used to draw the this shape's curve in the editor
@export var shape_hint_color := Color.LIME_GREEN
## When this field is checked, the 'Strokes', 'Fills' and 'Collisions' created
## with the 'Add ...' buttons will be locked from transforming to prevent
## inadvertently changing them, whilst the idea is that [ScalableVectorShape2D]
## controls them
@export var lock_assigned_shapes := true
@export_group("Export Options")
@export var show_export_options := true
var cached_outline : PackedVector2Array = []
var cached_clipped_polygons : Array[PackedVector2Array] = []
var cached_poly_strokes : Array[PackedVector2Array] = []
var should_update_curve := false
# Wire up signals at runtime
func _ready():
if update_curve_at_runtime:
if not curve.changed.is_connected(curve_changed):
curve.changed.connect(curve_changed)
if not arc_list.changed.is_connected(curve_changed):
arc_list.changed.connect(curve_changed)
if not clip_paths_changed.is_connected(_on_clip_paths_changed):
clip_paths_changed.connect(_on_clip_paths_changed)
_on_clip_paths_changed()
if not dimensions_changed.is_connected(_on_dimensions_changed):
dimensions_changed.connect(_on_dimensions_changed)
# Wire up signals on enter tree for the editor
func _enter_tree():
# ensure backward compatibility by overriding stroke properties by line's properties
if is_instance_valid(line):
if stroke_color != line.default_color:
stroke_color = line.default_color
if stroke_width != line.width:
stroke_width = line.width
if begin_cap_mode != line.begin_cap_mode:
begin_cap_mode = line.begin_cap_mode
if end_cap_mode != line.end_cap_mode:
end_cap_mode = line.end_cap_mode
if line_joint_mode != line.joint_mode:
line_joint_mode = line.joint_mode
# ensure forward compatibility by assigning the default ShapeType
if shape_type == null:
shape_type = ShapeType.PATH
# ensure forward compatibility by assigning the default arc_list
if arc_list == null:
arc_list = ScalableArcList.new()
# ensure forward compatibility by assigning an empty array to clip_paths
if clip_paths == null:
clip_paths = []
# ensure forward compatibility by assigning an empty dict to glue_map
if glue_map == null:
glue_map = {}
if Engine.is_editor_hint():
if not curve.changed.is_connected(curve_changed):
curve.changed.connect(curve_changed)
if not arc_list.changed.is_connected(curve_changed):
arc_list.changed.connect(curve_changed)
if not assigned_node_changed.is_connected(_on_assigned_node_changed):
assigned_node_changed.connect(_on_assigned_node_changed)
if not clip_paths_changed.is_connected(_on_clip_paths_changed):
clip_paths_changed.connect(_on_clip_paths_changed)
_on_clip_paths_changed()
# handles update when reparenting
if update_curve_at_runtime:
if not curve.changed.is_connected(curve_changed):
curve.changed.connect(curve_changed)
if not arc_list.changed.is_connected(curve_changed):
arc_list.changed.connect(curve_changed)
if not clip_paths_changed.is_connected(_on_clip_paths_changed):
clip_paths_changed.connect(_on_clip_paths_changed)
_on_clip_paths_changed()
# updates the curve points when size, offset, rx, or ry prop changes
# (used for ShapeType.RECT and ShapeType.ELLIPSE)
if not dimensions_changed.is_connected(_on_dimensions_changed):
dimensions_changed.connect(_on_dimensions_changed)
_on_dimensions_changed()
# Clean up signals (ie. when closing scene) to prevent error messages in the editor
func _exit_tree():
if curve.changed.is_connected(curve_changed):
curve.changed.disconnect(curve_changed)
if arc_list.changed.is_connected(curve_changed):
arc_list.changed.disconnect(curve_changed)
func _process(_delta: float) -> void:
if should_update_curve:
_update_curve()
should_update_curve = false
func _on_clip_paths_changed():
for cp in clip_paths:
if is_instance_valid(cp) and not cp.path_changed.is_connected(_on_assigned_node_changed):
cp.path_changed.connect(_on_assigned_node_changed)
cp.transform_changed.connect(_on_assigned_node_changed)
cp.tree_entered.connect(_on_assigned_node_changed)
cp.tree_exited.connect(func(): if is_inside_tree(): _on_assigned_node_changed())
if Engine.is_editor_hint() or update_curve_at_runtime:
cp.set_notify_local_transform(true)
if not cp in get_children():
set_notify_local_transform(true)
transform_changed.connect(func(_x): curve_changed())
_on_assigned_node_changed()
func _notification(what: int) -> void:
if what == NOTIFICATION_LOCAL_TRANSFORM_CHANGED:
transform_changed.emit(self)
func _on_dimensions_changed():
if shape_type == ShapeType.RECT:
var width = size.x
var height = size.y
# curve is passed by reference to trigger changed on existing instance
set_rect_points(curve, width, height, rx, ry, offset, spin)
elif shape_type == ShapeType.ELLIPSE:
# curve is passed by reference to trigger changed on existing instance
set_ellipse_points(curve, size, offset, spin)
func _on_assigned_node_changed(_x : Variant = null):
if Engine.is_editor_hint() or update_curve_at_runtime:
if not curve.changed.is_connected(curve_changed):
curve.changed.connect(curve_changed)
if not arc_list.changed.is_connected(curve_changed):
arc_list.changed.connect(curve_changed)
if lock_assigned_shapes:
if is_instance_valid(line):
line.set_meta("_edit_lock_", true)
if is_instance_valid(poly_stroke):
poly_stroke.set_meta("_edit_lock_", true)
if is_instance_valid(polygon):
polygon.set_meta("_edit_lock_", true)
if is_instance_valid(collision_polygon):
collision_polygon.set_meta("_edit_lock_", true)
if is_instance_valid(collision_object):
collision_object.set_meta("_edit_lock_", true)
if is_instance_valid(navigation_region):
navigation_region.set_meta("_edit_lock_", true)
curve_changed()
## Exposes assigned_node_changed signal to outside callers
func notify_assigned_node_change():
assigned_node_changed.emit()
func tessellate() -> PackedVector2Array:
if not cached_outline.is_empty():
return cached_outline
if not arc_list or arc_list.arcs.is_empty():
return curve.tessellate(max_stages, tolerance_degrees)
var poly_points = []
var arc_starts := (arc_list.arcs
.filter(func(a): return a != null)
.map(func(a : ScalableArc): return a.start_point)
)
for p_idx in curve.point_count - 1:
if p_idx in arc_starts:
var seg := _get_curve_segment(p_idx)
var arc = arc_list.get_arc_for_point(p_idx)
if arc:
var seg_points := tessellate_arc_segment(seg.get_point_position(0), arc.radius,
arc.rotation_deg, arc.large_arc_flag, arc.sweep_flag, seg.get_point_position(1))
for i in seg_points.size():
if i == 0 and not poly_points.is_empty():
continue
poly_points.append(seg_points[i])
else:
printerr("Illegal state: there should be an arc int arc_list with start_point=%d - (%s)" % [p_idx, name])
if poly_points.is_empty():
poly_points.append(seg.get_point_position(0))
poly_points.append(seg.get_point_position(1))
else:
var seg_points := _get_curve_segment(p_idx).tessellate(max_stages, tolerance_degrees)
for i in seg_points.size():
if i == 0 and not poly_points.is_empty():
continue
poly_points.append(seg_points[i])
return poly_points
## Redraw the line based on the new curve, using its tessellate method
func curve_changed():
if (not is_instance_valid(line) and not is_instance_valid(polygon)
and not is_instance_valid(poly_stroke)
and not is_instance_valid(collision_polygon)
and not is_instance_valid(collision_object)
and not is_instance_valid(navigation_region)
and not path_changed.has_connections()
and not polygons_updated.has_connections()):
# guard against needlessly invoking expensive tessellate operation
return
should_update_curve = true
func _update_curve():
# recalculate the polygon points for this shape based on curve and arc_list
cached_outline.clear()
cached_poly_strokes.clear()
cached_outline.append_array(self.tessellate())
# emit updated path to listeners
path_changed.emit(cached_outline)
for p_idx in glue_map.keys():
if p_idx > -1 and p_idx < curve.point_count and is_instance_valid(glue_map[p_idx]):
glue_map[p_idx].global_position = to_global(curve.get_point_position(p_idx))
var polygon_points := cached_outline.duplicate()
# Fixes cases start- and end-node are so close to each other that
# polygons won't fill and closed lines won't cap nicely
if (polygon_points.size() > 0 and
polygon_points[0].distance_to(polygon_points[polygon_points.size()-1]) < 0.001):
polygon_points.remove_at(polygon_points.size() - 1)
var valid_clip_paths : Array[ScalableVectorShape2D] = (clip_paths
.filter(func(cp): return is_instance_valid(cp))
.filter(func(cp : Node2D): return cp.is_inside_tree())
)
if clip_paths.is_empty():
_update_assigned_nodes(polygon_points)
polygons_updated.emit(
Array([cached_outline] if is_instance_valid(polygon) else [], TYPE_PACKED_VECTOR2_ARRAY, "", null),
cached_poly_strokes, self)
else:
_update_assigned_nodes_with_clips(polygon_points, valid_clip_paths)
polygons_updated.emit(cached_clipped_polygons if is_instance_valid(polygon) else Array([], TYPE_PACKED_VECTOR2_ARRAY, "", null), cached_poly_strokes, self)
func _update_assigned_nodes(polygon_points : PackedVector2Array) -> void:
var collision_polygons : Array[PackedVector2Array] = []
var navigation_polygons : Array[PackedVector2Array] = []
# calculate stroke as polygon and cache it
if (is_instance_valid(poly_stroke) or (is_instance_valid(line) and is_instance_valid(collision_object)) or (is_instance_valid(line) and is_instance_valid(navigation_region))) and not cached_outline.size() < 2:
var cap_mode := Geometry2D.END_JOINED if is_curve_closed() else CAP_MODE_MAP[begin_cap_mode]
var result := Geometry2DUtil.calculate_polystroke(cached_outline,
stroke_width * 0.5, cap_mode, JOINT_MODE_MAP[line_joint_mode])
cached_poly_strokes = result
# add to list of updated collision polygons
if is_instance_valid(collision_object):
collision_polygons.append_array(cached_poly_strokes)
if is_instance_valid(navigation_region):
navigation_polygons.append_array(cached_poly_strokes)
# i. if there is a fill assigned, also generate collision polygon for the entire outline
# ii. if there is no fill assigned and no stroke assigned, we assume the user _does_ want nav and collision
if is_instance_valid(polygon) or (collision_polygons.is_empty() and not is_instance_valid(polygon)):
collision_polygons.append(polygon_points)
navigation_polygons.append(polygon_points)
if is_instance_valid(line):
line.points = polygon_points
line.closed = is_curve_closed()
if is_instance_valid(poly_stroke):
var polygon_indices : Array = []
var poly := Geometry2DUtil.get_polygon_indices(cached_poly_strokes, polygon_indices)
poly_stroke.polygon = poly
poly_stroke.polygons = polygon_indices
_update_polygon_texture(poly_stroke, true)
if is_instance_valid(polygon):
polygon.polygons.clear()
polygon.polygon = polygon_points
_update_polygon_texture()
if is_instance_valid(collision_polygon):
collision_polygon.polygon = polygon_points
if is_instance_valid(collision_object):
var existing = collision_object.get_children().filter(func(ch): return ch is CollisionPolygon2D)
for idx in existing.size():
if idx >= collision_polygons.size():
existing[idx].hide()
existing[idx].disabled = true
for polygon_index in collision_polygons.size():
if polygon_index >= existing.size():
existing.append(_make_new_collision_polygon_2d())
existing[polygon_index].polygon = collision_polygons[polygon_index]
existing[polygon_index].show()
existing[polygon_index].disabled = false
if is_instance_valid(navigation_region):
var navigation_poly = NavigationPolygon.new()
for poly_points in navigation_polygons:
navigation_poly.add_outline(poly_points)
NavigationServer2D.bake_from_source_geometry_data(navigation_poly, NavigationMeshSourceGeometryData2D.new())
navigation_region.navigation_polygon = navigation_poly
func add_clip_path(svs : ScalableVectorShape2D):
clip_paths.append(svs)
_on_clip_paths_changed()
func _update_polygon_texture(poly := polygon, grow := false):
if poly.texture is GradientTexture2D or poly.texture is ImageTexture:
var box := get_bounding_rect().grow(0.5 * stroke_width) if grow else get_bounding_rect()
poly.texture_offset = -box.position if grow else -box.position
if poly.texture is GradientTexture2D:
poly.texture.width = 1 if box.size.x < 1 else box.size.x
poly.texture.height = 1 if box.size.y < 1 else box.size.y
else:
if not poly.texture_repeat:
poly.texture_scale = poly.texture.get_size() / box.size
func _update_assigned_nodes_with_clips(polygon_points : PackedVector2Array, valid_clip_paths : Array[ScalableVectorShape2D]) -> void:
var merges := valid_clip_paths.filter(func(cp : ScalableVectorShape2D): return cp.use_union_in_stead_of_clipping)
var clips := valid_clip_paths.filter(func(cp : ScalableVectorShape2D): return cp.use_interect_when_clipping)
var cutouts := valid_clip_paths.filter(func(cp : ScalableVectorShape2D): return not cp.use_interect_when_clipping and not cp.use_union_in_stead_of_clipping)
var merge_results := Geometry2DUtil.apply_clips_to_polygon(
[polygon_points],
Array(merges.map(_clip_path_to_local), TYPE_PACKED_VECTOR2_ARRAY, "", null),
Geometry2D.PolyBooleanOperation.OPERATION_UNION
)
var cutout_results := Geometry2DUtil.apply_clips_to_polygon(
merge_results,
Array(cutouts.map(_clip_path_to_local), TYPE_PACKED_VECTOR2_ARRAY, "", null),
Geometry2D.PolyBooleanOperation.OPERATION_DIFFERENCE
)
var intersect_results_polystroke : Array[PackedVector2Array] = []
if (is_instance_valid(poly_stroke) or (is_instance_valid(line) and is_instance_valid(collision_object)) or (is_instance_valid(line) and is_instance_valid(navigation_region))) and not cached_outline.size() < 2:
var cutout_result_polylines : Array[PackedVector2Array] = (
Geometry2DUtil.calculate_outlines(cutout_results.duplicate())
if is_instance_valid(line) or is_instance_valid(poly_stroke) else
[]
)
var polystroke_result : Array[PackedVector2Array] = []
for polyline in cutout_result_polylines:
polystroke_result.append_array(Geometry2DUtil.calculate_polystroke(polyline,
stroke_width * 0.5, Geometry2D.END_JOINED, JOINT_MODE_MAP[line_joint_mode]))
intersect_results_polystroke = Geometry2DUtil.apply_clips_to_polygon(
polystroke_result,
Array(clips.map(_clip_path_to_local), TYPE_PACKED_VECTOR2_ARRAY, "", null),
Geometry2D.PolyBooleanOperation.OPERATION_INTERSECTION
)
var intersect_results_fill_polygon := Geometry2DUtil.apply_clips_to_polygon(
cutout_results,
Array(clips.map(_clip_path_to_local), TYPE_PACKED_VECTOR2_ARRAY, "", null),
Geometry2D.PolyBooleanOperation.OPERATION_INTERSECTION
)
cached_poly_strokes = intersect_results_polystroke
cached_clipped_polygons = intersect_results_fill_polygon
var collision_polygons : Array[PackedVector2Array] = []
if is_instance_valid(collision_object):
collision_polygons.append_array(cached_poly_strokes)
if is_instance_valid(polygon) or (collision_polygons.is_empty() and not is_instance_valid(polygon)):
collision_polygons.append_array(cached_clipped_polygons)
var navigation_polygons : Array[PackedVector2Array] = []
if is_instance_valid(navigation_region):
navigation_polygons.append_array(cached_poly_strokes)
if is_instance_valid(polygon) or (navigation_polygons.is_empty() and not is_instance_valid(polygon)):
navigation_polygons.append_array(cached_clipped_polygons)
if is_instance_valid(line):
if cached_clipped_polygons.is_empty():
line.hide()
else:
var polylines := Geometry2DUtil.calculate_outlines(cached_clipped_polygons.duplicate())
line.show()
line.points = polylines.pop_front()
# FIXME: closes the loop when original line is not closed
line.closed = true
var existing = line.get_children().filter(func(c): return c is Line2D)
for idx in existing.size():
if idx >= polylines.size():
existing[idx].hide()
for polyline_index in polylines.size():
if polyline_index >= existing.size():
existing.append(_make_new_line_2d())
existing[polyline_index].points = polylines[polyline_index]
existing[polyline_index].width = line.width
existing[polyline_index].begin_cap_mode = line.begin_cap_mode
existing[polyline_index].end_cap_mode = line.end_cap_mode
existing[polyline_index].joint_mode = line.joint_mode
existing[polyline_index].default_color = line.default_color
existing[polyline_index].show()
if is_instance_valid(poly_stroke):
if cached_poly_strokes.is_empty():
poly_stroke.hide()
else:
poly_stroke.show()
var polygon_indices : Array = []
var poly := Geometry2DUtil.get_polygon_indices(cached_poly_strokes, polygon_indices)
poly_stroke.polygon = poly
poly_stroke.polygons = polygon_indices
_update_polygon_texture(poly_stroke, true)
if is_instance_valid(polygon):
if cached_clipped_polygons.is_empty():
polygon.hide()
else:
polygon.show()
var polygon_indices : Array = []
var poly := Geometry2DUtil.get_polygon_indices(cached_clipped_polygons, polygon_indices)
polygon.polygon = poly
polygon.polygons = polygon_indices
_update_polygon_texture()
if is_instance_valid(collision_polygon):
collision_polygon.polygon = polygon_points
if is_instance_valid(collision_object):
var existing = collision_object.get_children().filter(func(ch): return ch is CollisionPolygon2D)
for idx in existing.size():
if idx >= collision_polygons.size():
existing[idx].hide()
existing[idx].disabled = true
for polygon_index in collision_polygons.size():
if polygon_index >= existing.size():
existing.append(_make_new_collision_polygon_2d())
existing[polygon_index].polygon = collision_polygons[polygon_index]
existing[polygon_index].show()
existing[polygon_index].disabled = false
if is_instance_valid(navigation_region):
var navigation_poly = NavigationPolygon.new()
for outline in navigation_polygons:
navigation_poly.add_outline(outline)
NavigationServer2D.bake_from_source_geometry_data(navigation_poly, NavigationMeshSourceGeometryData2D.new())
navigation_region.navigation_polygon = navigation_poly
func _make_new_collision_polygon_2d() -> CollisionPolygon2D:
var c_poly = CollisionPolygon2D.new()
collision_object.add_child(c_poly, true)
if collision_object.owner:
c_poly.set_owner(collision_object.owner)
if Engine.is_editor_hint() and lock_assigned_shapes:
c_poly.set_meta("_edit_lock_", true)
if collision_object not in get_children():
c_poly.global_transform = global_transform
return c_poly
func _make_new_line_2d() -> Line2D:
var ln := Line2D.new()
ln.name = "ExtraStroke"
line.add_child(ln, true)
ln.closed = true
if line.owner:
ln.set_owner(line.owner)
if Engine.is_editor_hint() and lock_assigned_shapes:
ln.set_meta("_edit_lock_", true)
return ln
func _clip_path_to_local(clip_path : ScalableVectorShape2D) -> PackedVector2Array:
var pts := clip_path.global_transform * clip_path.tessellate()
return self.global_transform.affine_inverse() * pts
func get_center() -> Vector2:
if shape_type != ShapeType.PATH:
return offset
return get_bounding_rect().get_center()
## Calculate and return the bounding rect in local space
func get_bounding_rect() -> Rect2:
if not curve:
return Rect2(Vector2.ZERO, Vector2.ZERO)
var points := self.tessellate()
if points.size() < 1:
# Cannot calculate a center for 0 points
return Rect2(Vector2.ZERO, Vector2.ZERO)
return Geometry2DUtil.get_polygon_bounding_rect(points)
func has_point(global_pos : Vector2) -> bool:
return get_bounding_rect().grow(
stroke_width / 2.0 if is_instance_valid(line) or is_instance_valid(poly_stroke) else 0
).has_point(to_local(global_pos))
func has_fine_point(global_pos : Vector2) -> bool:
var poly_points := self.tessellate()
if Geometry2D.is_point_in_polygon(to_local(global_pos), poly_points):
return true
for poly_points1 in cached_poly_strokes:
if Geometry2D.is_point_in_polygon(to_local(global_pos), poly_points1):
return true
return false
func clipped_polygon_has_point(global_pos : Vector2) -> bool:
if not has_point(global_pos) or not has_fine_point(global_pos):
return false
if cached_clipped_polygons.is_empty() and has_fine_point(global_pos):
return true
for poly_points1 in cached_poly_strokes:
if Geometry2D.is_point_in_polygon(to_local(global_pos), poly_points1):
return true
for poly_points in cached_clipped_polygons:
if Geometry2D.is_point_in_polygon(to_local(global_pos), poly_points):
return true
return false
func set_position_to_center() -> void:
var c = get_center()
position += c
for i in range(curve.get_point_count()):
curve.set_point_position(i, curve.get_point_position(i) - c)
func set_origin(global_pos : Vector2) -> void:
var local_pos = to_local(global_pos)
match shape_type:
ShapeType.RECT, ShapeType.ELLIPSE:
offset = offset - to_local(global_pos)
global_position = global_pos
if is_instance_valid(polygon) and polygon.texture is GradientTexture2D:
polygon.texture_offset = -get_bounding_rect().position
ShapeType.PATH, _:
for i in range(curve.get_point_count()):
curve.set_point_position(i, curve.get_point_position(i) - local_pos)
global_position = global_pos
if is_instance_valid(polygon) and polygon.texture is GradientTexture2D:
polygon.texture_offset = -get_bounding_rect().position
func get_bounding_box() -> Array[Vector2]:
var rect = get_bounding_rect().grow(
stroke_width / 2.0 if is_instance_valid(line) or is_instance_valid(poly_stroke) else 0
)
return [
to_global(rect.position),
to_global(Vector2(rect.position.x + rect.size.x, rect.position.y)),
to_global(rect.position + rect.size),
to_global(Vector2(rect.position.x, rect.position.y + rect.size.y)),
to_global(rect.position)
]
func get_poly_points() -> Array:
return Array(self.tessellate()).map(to_global)
func get_farthest_point(from_local_pos := Vector2.ZERO) -> Vector2:
var farthest_point = from_local_pos
for p in self.tessellate():
if p.distance_to(from_local_pos) > farthest_point.distance_to(from_local_pos):
farthest_point = p
return farthest_point
func is_curve_closed() -> bool:
var n = curve.point_count
return n > 2 and curve.get_point_position(0).distance_to(curve.get_point_position(n - 1)) < 0.001
func get_curve_handles() -> Array:
if shape_type == ShapeType.RECT or shape_type == ShapeType.ELLIPSE:
var size_handle_pos := (size * 0.5).rotated(spin) + offset
var top_left := (-size * 0.5).rotated(spin) + offset
var rx_handle := ((-size * 0.5) + Vector2(rx, 0)).rotated(spin) + offset
var ry_handle := ((-size * 0.5) + Vector2(0, ry)).rotated(spin) + offset
return [{
"top_left_pos": to_global(top_left),
"point_position": to_global(size_handle_pos),
"mirrored": true,
"in": rx_handle,
"out": ry_handle,
"in_position": to_global(rx_handle),
"out_position": to_global(ry_handle),
"is_closed": "",
}]
var n = curve.point_count
var is_closed := is_curve_closed()
var result := []
for i in range(n):
var p = curve.get_point_position(i)
var c_i = curve.get_point_in(i)
var c_o = curve.get_point_out(i)
if i == 0 and is_closed:
c_i = curve.get_point_in(n - 1)
elif i == n - 1 and is_closed:
continue
result.append({
'point_position': to_global(p),
'in': c_i,
'out': c_o,
'mirrored': c_i.length() and c_i.distance_to(-c_o) < 0.01,
'in_position': to_global(p + c_i),
'out_position': to_global(p + c_o),
'is_closed': ("" + str(n - 1) if i == 0 and is_closed else "")
})
return result
func get_gradient_handles() -> Dictionary:
if not (
is_instance_valid(polygon) and polygon.texture is GradientTexture2D
):
return {}
var gradient_tex : GradientTexture2D = polygon.texture
var box := get_bounding_rect()
var stop_colors = Array(
gradient_tex.gradient.colors if gradient_tex.gradient.colors else [
Color.WHITE, Color.BLACK
]
).map(func(gc): return gc * polygon.color)
var stop_positions = Array(gradient_tex.gradient.offsets).map(
func(offs): return (gradient_tex.fill_to - gradient_tex.fill_from) * offs
).map(func(offs_p): return gradient_tex.fill_from + offs_p
).map(func(offs_p1): return to_global((offs_p1 * box.size) + box.position))
var result := {
"fill_from": gradient_tex.fill_from,
"fill_to": gradient_tex.fill_to,
"fill_from_pos": to_global((gradient_tex.fill_from * box.size) + box.position),
"fill_to_pos": to_global((gradient_tex.fill_to * box.size) + box.position),
"start_color": stop_colors[0] * polygon.color,
"end_color": stop_colors[stop_colors.size() - 1] * polygon.color,
"stop_positions": stop_positions,
"stop_colors": stop_colors
}
return result
func flip_points(flip_dir := Vector2(-1, 1)) -> void:
if shape_type == ShapeType.PATH:
curve.set_block_signals(true)
arc_list.set_block_signals(true)
for i in curve.point_count:
curve.set_point_position(i, curve.get_point_position(i) * flip_dir)
curve.set_point_in(i, curve.get_point_in(i) * flip_dir)
curve.set_point_out(i, curve.get_point_out(i) * flip_dir)
for i in arc_list.arcs.size():
arc_list.arcs[i].sweep_flag = not arc_list.arcs[i].sweep_flag
arc_list.set_block_signals(false)
curve.set_block_signals(false)
curve.emit_changed()
arc_list.emit_changed()
else:
spin = -spin
func translate_points_by(global_vector : Vector2) -> void:
var delta := global_vector.rotated(-global_rotation) / global_scale
if shape_type == ShapeType.PATH:
curve.set_block_signals(true)
for idx in curve.point_count:
curve.set_point_position(idx, curve.get_point_position(idx) + delta)
curve.set_block_signals(false)
curve.emit_changed()
else:
offset += delta
func scale_points_by(from_global_vector : Vector2, to_global_vector : Vector2, around_center := false) -> void:
var local_from := to_local(from_global_vector)
var local_to := to_local(to_global_vector)
var origin := get_center() if around_center else Vector2.ZERO
var s := origin.distance_to(local_to) / origin.distance_to(local_from)
if shape_type == ShapeType.PATH:
curve.set_block_signals(true)
for idx in curve.point_count:
var p := curve.get_point_position(idx)
var p1 := (p - origin) * s + origin
var cp_in_abs := curve.get_point_in(idx) + p
var cp_out_abs := curve.get_point_out(idx) + p
var cp_in_abs_1 := (cp_in_abs - origin) * s + origin
var cp_out_abs_1 := (cp_out_abs - origin) * s + origin
curve.set_point_position(idx, p1)
curve.set_point_in(idx, cp_in_abs_1 - p1)
curve.set_point_out(idx, cp_out_abs_1 - p1)
curve.set_block_signals(false)
curve.emit_changed()
else:
size *= s
func rotate_points_by(angle : float, rotation_origin := Vector2.ZERO) -> void:
if shape_type != ShapeType.PATH:
spin += angle
return
var transform := Transform2D.IDENTITY.rotated(-angle)
curve.set_block_signals(true)
for idx in curve.point_count:
var p := curve.get_point_position(idx)
var p1 := (p - rotation_origin) * transform + rotation_origin
var cp_in_abs := curve.get_point_in(idx) + p
var cp_out_abs := curve.get_point_out(idx) + p
var cp_in_abs_1 := (cp_in_abs - rotation_origin) * transform + rotation_origin
var cp_out_abs_1 := (cp_out_abs - rotation_origin) * transform + rotation_origin
curve.set_point_position(idx, p1)
curve.set_point_in(idx, cp_in_abs_1 - p1)
curve.set_point_out(idx, cp_out_abs_1 - p1)
curve.set_block_signals(false)
curve.emit_changed()
func set_global_curve_point_position(global_pos : Vector2, point_idx : int, snapped : bool,
snap : float) -> void:
if curve.point_count > point_idx:
if snapped:
global_pos = snapped(global_pos, Vector2.ONE * snap)
curve.set_point_position(point_idx, to_local(global_pos))
func set_global_curve_cp_in_position(global_pos : Vector2, point_idx : int, snapped : bool,
snap : float) -> void:
if curve.point_count > point_idx:
if snapped:
global_pos = snapped(global_pos, Vector2.ONE * snap)
curve.set_point_in(point_idx, to_local(global_pos) - curve.get_point_position(point_idx))
func set_global_curve_cp_out_position(global_pos : Vector2, point_idx : int, snapped : bool,
snap : float) -> void:
if curve.point_count > point_idx:
if snapped:
global_pos = snapped(global_pos, Vector2.ONE * snap)
curve.set_point_out(point_idx, to_local(global_pos) - curve.get_point_position(point_idx))
func replace_curve_points(curve_in : Curve2D) -> void:
curve.clear_points()
for i in range(curve_in.point_count):
curve.add_point(curve_in.get_point_position(i),
curve_in.get_point_in(i), curve_in.get_point_out(i))
func add_arc(segment_p1_idx : int) -> void:
var seg := _get_curve_segment(segment_p1_idx)
var r := seg.get_point_position(0).distance_to(seg.get_point_position(1)) * 0.5
arc_list.add_arc(ScalableArc.new(segment_p1_idx, Vector2.ONE * r, 0.0))
func _get_curve_segment(segment_p1_idx : int) -> Curve2D:
var curve_segment := Curve2D.new()
curve_segment.add_point(
curve.get_point_position(segment_p1_idx),
Vector2.ZERO,
curve.get_point_out(segment_p1_idx)
)
var segment_p2_idx = (0 if segment_p1_idx == curve.point_count - 1
else segment_p1_idx + 1)
curve_segment.add_point(
curve.get_point_position(segment_p2_idx),
curve.get_point_in(segment_p2_idx)
)
return curve_segment
func is_arc_start(p_idx) -> bool:
return arc_list.get_arc_for_point(p_idx) != null
func _get_closest_point_on_curve_segment(p : Vector2, segment_p1_idx : int) -> Vector2:
var arc := arc_list.get_arc_for_point(segment_p1_idx)
var seg := _get_curve_segment(segment_p1_idx)
var poly_points := (
tessellate_arc_segment(seg.get_point_position(0), arc.radius, arc.rotation_deg,
arc.large_arc_flag, arc.sweep_flag, seg.get_point_position(1))
if arc else
seg.tessellate(max_stages, tolerance_degrees)
)
var closest_result := Vector2.INF
for i in range(1, poly_points.size()):
var p_a := poly_points[i - 1]
var p_b := poly_points[i]
var c_p := Geometry2D.get_closest_point_to_segment(p, p_a, p_b)
if p.distance_to(c_p) < p.distance_to(closest_result):
closest_result = c_p
return closest_result
func get_closest_point_on_curve(global_pos : Vector2) -> ClosestPointOnCurveMeta:
var p := to_local(global_pos)
if curve.point_count < 2:
return ClosestPointOnCurveMeta.new(1, global_pos, p)
var closest_result := Vector2.INF
var before_segment := 1
for i in range(curve.point_count):
var c_p := _get_closest_point_on_curve_segment(p, i)
if p.distance_to(c_p) < p.distance_to(closest_result):
closest_result = c_p
before_segment = i + 1
return ClosestPointOnCurveMeta.new(before_segment, to_global(closest_result), closest_result)
func get_sliced_curve_segment(before_segment : int, point_position : Vector2) -> Curve2D:
var curve_segment := Curve2D.new()
curve_segment.add_point(curve.get_point_position(before_segment - 1))
curve_segment.set_point_out(0, curve.get_point_out(before_segment - 1))
curve_segment.add_point(curve.get_point_position(before_segment))
curve_segment.set_point_in(1, curve.get_point_in(before_segment))
var progress_ratio := Geometry2DUtil.get_progress_ratio_for_point_on_curve(
point_position, curve_segment, max_stages, tolerance_degrees)
return Geometry2DUtil.slice_bezier(
curve_segment.get_point_position(0),
curve_segment.get_point_out(0),
curve_segment.get_point_in(1),
curve_segment.get_point_position(1),
progress_ratio
)
func get_curve_segment_halfway_point(before_segment : int) -> Vector2:
var p_idx_1 := before_segment if before_segment < curve.point_count else 0
var curve_segment := Curve2D.new()
curve_segment.add_point(curve.get_point_position(before_segment - 1))
curve_segment.set_point_out(0, curve.get_point_out(before_segment - 1))
curve_segment.add_point(curve.get_point_position(p_idx_1))
curve_segment.set_point_in(1, curve.get_point_in(p_idx_1))
return Geometry2DUtil.get_halfway_point_on_bezier(curve_segment, max_stages, tolerance_degrees)
func get_subdivided_curve() -> Curve2D:
if curve.point_count < 2:
return curve
var new_curve := Curve2D.new()
new_curve.add_point(curve.get_point_position(0))
for i in range(1, curve.point_count):
var segment := get_sliced_curve_segment(i, get_curve_segment_halfway_point(i))
new_curve.add_point(segment.get_point_position(1))
new_curve.add_point(segment.get_point_position(2))
if curve.get_point_out(i - 1).length() > 0.0 or curve.get_point_in(i).length() > 0.0:
new_curve.set_point_out((i * 2) - 2, segment.get_point_out(0))
new_curve.set_point_in((i * 2) - 1, segment.get_point_in(1))
new_curve.set_point_out((i * 2) - 1, segment.get_point_out(1))
new_curve.set_point_in(i * 2, segment.get_point_in(2))
return new_curve
# Adapted from the GodSVG repository to draw arc in stead of determine bounding box.
# https://github.com/MewPurPur/GodSVG/blob/53168a8cf74739fe828f488901eada02d5d97b69/src/data_classes/ElementPath.gd#L118
func tessellate_arc_segment(start : Vector2, arc_radius : Vector2, arc_rotation_deg : float,
large_arc_flag : bool, sweep_flag : bool, end : Vector2) -> PackedVector2Array:
if start == end or arc_radius.x == 0 or arc_radius.y == 0:
return [start, end]
var r := arc_radius.abs()
# Obtain center parametrization.
var rot := deg_to_rad(arc_rotation_deg)
var cosine := cos(rot)
var sine := sin(rot)
var half := (start - end) / 2
var x1 := half.x * cosine + half.y * sine
var y1 := -half.x * sine + half.y * cosine
var r2 := Vector2(r.x * r.x, r.y * r.y)
var x12 := x1 * x1
var y12 := y1 * y1
var cr := x12 / r2.x + y12 / r2.y
if cr > 1:
cr = sqrt(cr)
r *= cr
r2 = Vector2(r.x * r.x, r.y * r.y)
var dq := r2.x * y12 + r2.y * x12
var pq := (r2.x * r2.y - dq) / dq
var sc := sqrt(maxf(0, pq))
if large_arc_flag == sweep_flag:
sc = -sc
var ct := Vector2(r.x * sc * y1 / r.y, -r.y * sc * x1 / r.x)
var c := Vector2(ct.x * cosine - ct.y * sine,
ct.x * sine + ct.y * cosine) + start.lerp(end, 0.5)
var tv := Vector2(x1 - ct.x, y1 - ct.y) / r
var theta1 := tv.angle()
var delta_theta := fposmod(tv.angle_to(
Vector2(-x1 - ct.x, -y1 - ct.y) / r), TAU)
if not sweep_flag:
theta1 += delta_theta
delta_theta = TAU - delta_theta
theta1 = fposmod(theta1, TAU)
var step := deg_to_rad(1.0 if tolerance_degrees < 1.0 else tolerance_degrees)
var angle := theta1 if sweep_flag else theta1 + delta_theta
var init_pnt := Vector2(c.x + r.x * cos(angle) * cosine - r.y * sin(angle) * sine,
c.y + r.x * cos(angle) * sine + r.y * sin(angle) * cosine)
var points : PackedVector2Array = []
while (sweep_flag and angle < theta1 + delta_theta) or (not sweep_flag and angle > theta1):
var pnt := Vector2(c.x + r.x * cos(angle) * cosine - r.y * sin(angle) * sine,
c.y + r.x * cos(angle) * sine + r.y * sin(angle) * cosine)
points.append(pnt)
if sweep_flag:
angle += step
else:
angle -= step
if points[points.size() - 1] != end:
if points[points.size() - 1].distance_to(end) < 0.01:
points[points.size() - 1] = end
else:
points.append(end)
return points
## Convert an existing [Curve2D] instance to a (rounded) rectangle.
## [param curve] is passed by reference so the curve's [signal Resource.changed]
## signal is emitted.
static func set_rect_points(curve : Curve2D, width : float, height : float, rx := 0.0, ry := 0.0,
offset := Vector2.ZERO, rotation := 0.0) -> void:
curve.set_block_signals(true)
curve.clear_points()
var top_left := Vector2(-width, -height) * 0.5
var top_right := Vector2(width, -height) * 0.5
var bottom_right := Vector2(width, height) * 0.5
var bottom_left := Vector2(-width, height) * 0.5
if rx == 0 and ry == 0:
curve.add_point(top_left.rotated(rotation) + offset)
curve.add_point(top_right.rotated(rotation) + offset)
curve.add_point(bottom_right.rotated(rotation) + offset)
curve.add_point(bottom_left.rotated(rotation) + offset)
curve.add_point(top_left.rotated(rotation) + offset)
else:
curve.add_point(
(top_left + Vector2(width - rx, 0)).rotated(rotation) + offset,
Vector2.ZERO,
Vector2(rx * R_TO_CP, 0).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(width, ry)).rotated(rotation) + offset,
Vector2(0, -ry * R_TO_CP).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(width, height - ry)).rotated(rotation) + offset,
Vector2.ZERO,
Vector2(0, ry * R_TO_CP).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(width - rx, height)).rotated(rotation) + offset,
Vector2(rx * R_TO_CP, 0).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(rx, height)).rotated(rotation) + offset,
Vector2.ZERO,
Vector2(-rx * R_TO_CP, 0).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(0, height - ry)).rotated(rotation) + offset,
Vector2(0, ry * R_TO_CP).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(0, ry)).rotated(rotation) + offset,
Vector2.ZERO,
Vector2(0, -ry * R_TO_CP).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(rx, 0)).rotated(rotation) + offset,
Vector2(-rx * R_TO_CP, 0).rotated(rotation)
)
curve.add_point(
(top_left + Vector2(width - rx, 0)).rotated(rotation) + offset,
Vector2.ZERO,
Vector2(rx * R_TO_CP, 0).rotated(rotation)
)
curve.set_block_signals(false)
curve.changed.emit()
## Convert an existing [Curve2D] instance to an ellipse.
## [param curve] is passed by reference so the curve's [signal Resource.changed]
## signal is emitted.
static func set_ellipse_points(curve : Curve2D, size: Vector2, offset := Vector2.ZERO, rotation := 0.0):
curve.set_block_signals(true)
curve.clear_points()
curve.add_point(
offset + Vector2(size.x * 0.5, 0).rotated(rotation),
Vector2.ZERO,
Vector2(0, size.y * 0.5 * R_TO_CP).rotated(rotation)
)
curve.add_point(
offset + Vector2(0, size.y * 0.5).rotated(rotation),
Vector2(size.x * 0.5 * R_TO_CP, 0).rotated(rotation),
Vector2(-size.x * 0.5 * R_TO_CP, 0).rotated(rotation)
)
curve.add_point(
offset + Vector2(-size.x * 0.5, 0).rotated(rotation),
Vector2(0, size.y * 0.5 * R_TO_CP).rotated(rotation),
Vector2(0, -size.y * 0.5 * R_TO_CP).rotated(rotation)
)
curve.add_point(
offset + Vector2(0, -size.y * 0.5).rotated(rotation),
Vector2(-size.x * 0.5 * R_TO_CP, 0).rotated(rotation),
Vector2(size.x * 0.5 * R_TO_CP, 0).rotated(rotation)
)
curve.add_point(
offset + Vector2(size.x * 0.5, 0).rotated(rotation),
Vector2(0, -size.y * 0.5 * R_TO_CP).rotated(rotation)
)
curve.set_block_signals(false)
curve.changed.emit()