1349 lines
50 KiB
GDScript
1349 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()
|
|
|
|
|