diff --git a/.github/disabled_classes.gdbuild b/.github/disabled_classes.gdbuild
index 1496b89d3..e85384af7 100644
--- a/.github/disabled_classes.gdbuild
+++ b/.github/disabled_classes.gdbuild
@@ -252,7 +252,6 @@
"SpinBox",
"SpriteFrames",
"StatusIndicator",
- "StreamPeer",
"SubtweenTweener",
"SurfaceTool",
"SystemFont",
diff --git a/assets/icons/Pebble.svg b/assets/icons/Pebble.svg
new file mode 100644
index 000000000..1322812f6
--- /dev/null
+++ b/assets/icons/Pebble.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/Pebble.svg.import b/assets/icons/Pebble.svg.import
new file mode 100644
index 000000000..df5da66c5
--- /dev/null
+++ b/assets/icons/Pebble.svg.import
@@ -0,0 +1,18 @@
+[remap]
+
+importer="svg"
+type="DPITexture"
+uid="uid://dvsovsw5gwmma"
+path="res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.dpitex"
+
+[deps]
+
+source_file="res://assets/icons/Pebble.svg"
+dest_files=["res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.dpitex"]
+
+[params]
+
+base_scale=1.0
+saturation=1.0
+color_map={}
+compress=true
diff --git a/assets/icons/PebbleChecked.svg b/assets/icons/PebbleChecked.svg
new file mode 100644
index 000000000..9daad2d1a
--- /dev/null
+++ b/assets/icons/PebbleChecked.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/PebbleChecked.svg.import b/assets/icons/PebbleChecked.svg.import
new file mode 100644
index 000000000..0501ed881
--- /dev/null
+++ b/assets/icons/PebbleChecked.svg.import
@@ -0,0 +1,18 @@
+[remap]
+
+importer="svg"
+type="DPITexture"
+uid="uid://b8y5cq15l01p4"
+path="res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.dpitex"
+
+[deps]
+
+source_file="res://assets/icons/PebbleChecked.svg"
+dest_files=["res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.dpitex"]
+
+[params]
+
+base_scale=1.0
+saturation=1.0
+color_map={}
+compress=true
diff --git a/project.godot b/project.godot
index f1a28a9b0..0c8f30856 100644
--- a/project.godot
+++ b/project.godot
@@ -46,7 +46,6 @@ gdscript/warnings/integer_division=0
window/size/viewport_width=1040
window/size/viewport_height=650
-window/size/mode=2
mouse_cursor/tooltip_position_offset=Vector2(0, 10)
[editor_overrides]
diff --git a/src/data_classes/PDCImage.gd b/src/data_classes/PDCImage.gd
new file mode 100644
index 000000000..af0b91217
--- /dev/null
+++ b/src/data_classes/PDCImage.gd
@@ -0,0 +1,577 @@
+## A representation of a [url=https://developer.rebble.io/guides/app-resources/pdc-format/]Pebble Draw Command[/url] file.
+## [br][br]
+## The Pebble Draw Command format is a bespoke vector image format designed for the PebbleOS smartwatch firmware, made for
+## efficient storage and drawing.
+## PDCImage supports loading a binary file with [method load_from_pdc], converting them to SVGs with [method to_svg], and
+## converting SVGs to PDCs with [method load_from_svg]. It doesn't support image sequences, only stills.
+## [br][br]
+## To convert an in-memory [PDCImage] to a binary file, use [method encode].
+## [br][br]
+## Implementation based on the specification at [url]https://developer.rebble.io/guides/app-resources/pdc-format/[/url] and
+## [url=https://github.com/pebble-examples/cards-example/blob/master/tools/svg2pdc.py]svg2pdc.py[/url].
+class_name PDCImage
+
+## Don't change, see [url]https://developer.rebble.io/guides/app-resources/pdc-format/#pebble-draw-command-image[/url] offset 8.
+const DRAW_COMMAND_VERSION = 1
+
+enum DrawType {
+ INVALID,
+ PATH,
+ CIRCLE,
+ PRECISE_PATH,
+}
+
+enum DrawFlags {
+ NONE = 0,
+ HIDDEN = 1 << 0,
+ # Bits 2-8 are reserved
+}
+
+enum PrecisePathMode {
+ AUTODETECT,
+ ONLY_PRECISE_PATHS,
+ ONLY_IMPRECISE_PATHS,
+}
+
+enum PDCLoadingError {
+ OK,
+ UNKNOWN,
+ INVALID_MAGIC_WORD,
+}
+
+var size: Vector2i
+var path_angle_tolerance: float = 10.0
+var precise_path_mode: PrecisePathMode
+var viewbox_transform: Transform2D
+
+var draw_commands: Array[PebbleCommand]
+
+func _init(_size: Vector2i = Vector2i.ZERO) -> void:
+ size = _size
+
+
+func encode() -> PackedByteArray:
+ # Magic word
+ var buffer := "PDCI".to_ascii_buffer()
+ # Buffer size, to be filled in later
+ var size_byte_offset = buffer.size()
+ buffer.resize(8)
+
+ var encoded_commands := _encode_image()
+ buffer.append_array(encoded_commands)
+
+ buffer.encode_u32(size_byte_offset, encoded_commands.size())
+
+ return buffer
+
+
+func _encode_image() -> PackedByteArray:
+ var buffer: PackedByteArray
+ buffer.resize(8)
+ buffer.encode_u8(0, DRAW_COMMAND_VERSION)
+ # Must be 0 for some reason
+ buffer.encode_u8(1, 0)
+ # View size
+ buffer.encode_s16(2, size.x)
+ buffer.encode_s16(4, size.y)
+
+ buffer.encode_u16(6, draw_commands.size())
+ for command in draw_commands:
+ buffer.append_array(command.encode())
+ return buffer
+
+
+const _DRAW_TYPE_DISPATCHER: Dictionary[DrawType, Script] = {
+ DrawType.PATH: PebblePathCommand,
+ DrawType.CIRCLE: PebbleCircleCommand,
+ DrawType.PRECISE_PATH: PebblePrecisePathCommand,
+}
+
+
+func load_from_pdc(pdc: PackedByteArray) -> PDCLoadingError:
+ var sp := StreamPeerBuffer.new()
+ sp.data_array = pdc
+ if sp.get_utf8_string(4) != "PDCI":
+ return PDCLoadingError.INVALID_MAGIC_WORD
+ var _buffer_size := sp.get_u32()
+ if sp.get_u8() != DRAW_COMMAND_VERSION:
+ return PDCLoadingError.UNKNOWN
+ if sp.get_u8() != 0:
+ return PDCLoadingError.UNKNOWN
+ size = Vector2i(
+ sp.get_16(),
+ sp.get_16(),
+ )
+ var draw_command_size := sp.get_u16()
+ for i in draw_command_size:
+ var type := sp.get_u8() as DrawType
+ var flags := sp.get_u8() as DrawFlags
+ var stroke_color := sp.get_u8()
+ var stroke_width := sp.get_u8()
+ var fill_color := sp.get_u8()
+ var path_open_or_radius := sp.get_u16()
+ var points_length := sp.get_u16()
+ var cmd: PebbleCommand = _DRAW_TYPE_DISPATCHER[type].new()
+ var starting_cursor := sp.get_position()
+ cmd.decode(sp, points_length, path_open_or_radius)
+ assert(sp.get_position() - starting_cursor == points_length * 4)
+ cmd.stroke_color = stroke_color
+ cmd.stroke_width = stroke_width
+ cmd.fill_color = fill_color
+ cmd.flags = flags
+ add_command(cmd)
+ return PDCLoadingError.OK
+
+func add_command(command: PebbleCommand) -> void:
+ draw_commands.append(command)
+
+
+func add_commands(commands: Array[PebbleCommand]) -> void:
+ draw_commands.append_array(commands)
+
+
+@abstract class PebbleCommand:
+ var stroke_color: int
+ var stroke_width: float
+ var fill_color: int
+ var flags: DrawFlags
+
+ func encode() -> PackedByteArray:
+ var buffer: PackedByteArray
+ buffer.resize(9)
+ # Command type
+ buffer.encode_u8(0, get_type())
+ # Command flags
+ buffer.encode_u8(1, flags)
+ # Command stroke color
+ buffer.encode_u8(2, stroke_color)
+ # Command stroke width (unsigned byte)
+ buffer.encode_u8(3, int(stroke_width))
+ buffer.encode_u8(4, fill_color)
+ buffer.encode_u16(5, get_path_open_or_radius())
+ var encoded_points := encode_points(get_points())
+ buffer.encode_u16(7, encoded_points.size() / 4)
+ buffer.append_array(encoded_points)
+ return buffer
+
+ @abstract func get_type() -> DrawType
+ @abstract func get_points() -> PackedVector2Array
+ @abstract func get_path_open_or_radius() -> int
+
+ func encode_points(points: PackedVector2Array) -> PackedByteArray:
+ var buf: PackedByteArray
+ # Allocate 4 bytes per point
+ buf.resize(points.size() * 4)
+ for point_index in points.size():
+ var buf_index := point_index * 4
+ var point := points[point_index]
+ buf.encode_s16(buf_index + 0, encode_point(point.x))
+ buf.encode_s16(buf_index + 2, encode_point(point.y))
+ return buf
+
+ func encode_point(value: float) -> int:
+ return int(value)
+
+ @abstract func to_svg() -> Array[Element]
+
+ func _setup_svg_element(element: Element) -> Element:
+ element.set_attribute("stroke-width", stroke_width)
+ var real_stroke_color := PDCImage.from_pebble_color(stroke_color)
+ var real_fill_color := PDCImage.from_pebble_color(fill_color)
+ element.set_attribute("stroke", "#" + real_stroke_color.to_html(false))
+ if real_stroke_color.a < 1.0:
+ element.set_attribute("stroke-opacity", real_stroke_color.a)
+ element.set_attribute("fill", "#" + real_fill_color.to_html(false))
+ if real_fill_color.a < 1.0:
+ element.set_attribute("fill-opacity", real_fill_color.a)
+ element.set_attribute("stroke-linecap", "round")
+ element.set_attribute("stroke-linejoin", "round")
+ return element
+
+ @abstract func decode(sp: StreamPeer, points_size: int, path_open_or_radius: int) -> void
+
+
+class PebblePathCommand extends PebbleCommand:
+ var points: PackedVector2Array
+ var is_path_open: bool
+ func get_type() -> DrawType: return DrawType.PATH
+ func get_points() -> PackedVector2Array: return points
+ func get_path_open_or_radius() -> int: return 0b1 if is_path_open else 0b0
+ func to_svg() -> Array[Element]:
+ var rounded_points: PackedVector2Array = points.duplicate()
+ for i in rounded_points.size() - 1:
+ rounded_points[i] = Vector2(
+ _svg_round_value(rounded_points[i].x),
+ _svg_round_value(rounded_points[i].y)
+ )
+ rounded_points = PDCImage.simplify_points(rounded_points)
+ var path := _setup_svg_element(ElementPath.new())
+ var commands: Array[PathCommand]
+ commands.append(PathCommand.MoveCommand.new(rounded_points[0].x, rounded_points[0].y))
+ for point_index in range(1, rounded_points.size()):
+ var point := rounded_points[point_index]
+ commands.append(
+ PathCommand.LineCommand.new(point.x, point.y)
+ )
+ if not is_path_open:
+ commands.append(PathCommand.CloseCommand.new())
+ path.set_attribute("d", commands)
+ return [path]
+
+ func _svg_round_value(value: float) -> float: return floorf(value)
+
+ func upgrade() -> PebblePrecisePathCommand:
+ var precise_path := PebblePrecisePathCommand.new()
+ precise_path.points = points
+ precise_path.is_path_open = is_path_open
+ precise_path.stroke_color = stroke_color
+ precise_path.stroke_width = stroke_width
+ precise_path.fill_color = fill_color
+ precise_path.flags = flags
+ return precise_path
+
+ func encode_points(input_points: PackedVector2Array) -> PackedByteArray:
+ var buf: PackedByteArray
+ for i in input_points.size() - 1:
+ input_points[i] = Vector2(
+ _svg_round_value(input_points[i].x),
+ _svg_round_value(input_points[i].y)
+ )
+ input_points = PDCImage.simplify_points(input_points)
+ # Allocate 4 bytes per point
+ buf.resize(input_points.size() * 4)
+ for point_index in input_points.size():
+ var buf_index := point_index * 4
+ var point := input_points[point_index]
+ buf.encode_s16(buf_index + 0, encode_point(point.x))
+ buf.encode_s16(buf_index + 2, encode_point(point.y))
+ return buf
+
+ func decode(sp: StreamPeer, points_size: int, path_open_or_radius: int) -> void:
+ is_path_open = path_open_or_radius == 1
+ for point_index in points_size:
+ var x := sp.get_16()
+ var y := sp.get_16()
+ points.append(Vector2(x, y))
+
+
+class PebbleCircleCommand extends PebbleCommand:
+ var center: Vector2
+ var radius: int
+ func get_type() -> DrawType: return DrawType.CIRCLE
+ func get_points() -> PackedVector2Array: return [center]
+ func get_path_open_or_radius() -> int: return radius
+ func to_svg() -> Array[Element]:
+ var circ := _setup_svg_element(ElementCircle.new())
+ circ.set_attribute("cx", center.x)
+ circ.set_attribute("cy", center.y)
+ circ.set_attribute("r", radius)
+ return [circ]
+
+ func decode(sp: StreamPeer, _points_size: int, path_open_or_radius: int) -> void:
+ radius = path_open_or_radius
+ var x := sp.get_16()
+ var y := sp.get_16()
+ center = Vector2(x, y)
+
+
+class PebblePrecisePathCommand extends PebblePathCommand:
+ func get_type() -> DrawType: return DrawType.PRECISE_PATH
+ func encode_point(value: float) -> int:
+ return int(value * (1 << 3))
+ func _svg_round_value(value: float) -> float: return floorf(value * (1 << 3)) / (1 << 3)
+
+ func decode(sp: StreamPeer, points_size: int, path_open_or_radius: int) -> void:
+ is_path_open = path_open_or_radius == 1
+ for point_index in points_size:
+ var x := float(sp.get_16()) / (1 << 3)
+ var y := float(sp.get_16()) / (1 << 3)
+ points.append(Vector2(x, y))
+
+
+var path_generator := SVGPathUtils.AngleTolerancePathGenerator.new()
+
+
+func load_from_svg(svg: ElementRoot) -> void:
+ var size_float := Vector2(svg.get_attribute_num("width"), svg.get_attribute_num("height"))
+ size = Vector2i(size_float)
+ var viewbox := svg.get_attribute_list("viewBox")
+ if viewbox.size() == 4:
+ var viewbox_rect := Rect2(viewbox[0], viewbox[1], viewbox[2], viewbox[3])
+ var scaling_factor := size_float / viewbox_rect.size
+ viewbox_transform = Transform2D(0.0, scaling_factor, 0.0, -viewbox_rect.position * scaling_factor)
+ else:
+ viewbox_transform = Transform2D.IDENTITY
+ path_generator.angle_tolerance = path_angle_tolerance
+ for element in svg.get_all_valid_element_descendants():
+ var element_xform := element.get_transform()
+ match element.name:
+ "circle":
+ var cmd := _setup_cmd(element, PebbleCircleCommand.new())
+ cmd.center = element_xform * Vector2(element.get_attribute_num("cx"), element.get_attribute_num("cy"))
+ cmd.radius = int(Utils.vector2_min_element(element_xform.get_scale()) * element.get_attribute_num("r"))
+ add_command(cmd)
+ "ellipse":
+ var cmd := _setup_cmd(element, PebbleCircleCommand.new())
+ cmd.center = element_xform * Vector2(element.get_attribute_num("cx"), element.get_attribute_num("cy"))
+ var element_scale := element_xform.get_scale()
+ cmd.radius = int(minf(element_scale.x * element.get_attribute_num("rx"), element_scale.y * element.get_attribute_num("ry")))
+ add_command(cmd)
+ "rect":
+ var x := element.get_attribute_num("x")
+ var y := element.get_attribute_num("y")
+ var w := element.get_attribute_num("w")
+ var h := element.get_attribute_num("h")
+ var points: PackedVector2Array = [
+ Vector2(x + 0, y + 0),
+ Vector2(x + w, y + 0),
+ Vector2(x + w, y + h),
+ Vector2(x + 0, y + h),
+ ]
+ points = Utils64Bit.transform_vector_array_mult(
+ element.get_precise_transform(),
+ points
+ )
+ var cmd := _setup_cmd(element, (PebblePrecisePathCommand if requires_precise_path(points) else PebblePathCommand).new())
+ cmd.points = points
+ cmd.is_path_open = false
+ add_command(cmd)
+ "line":
+ var points: PackedVector2Array = [
+ Vector2(element.get_attribute_num("x1"), element.get_attribute_num("y1")),
+ Vector2(element.get_attribute_num("x2"), element.get_attribute_num("y2")),
+ ]
+ points = Utils64Bit.transform_vector_array_mult(
+ element.get_precise_transform(),
+ points
+ )
+ var cmd := _setup_cmd(element, (PebblePrecisePathCommand if requires_precise_path(points) else PebblePathCommand).new())
+ cmd.points = points
+ cmd.is_path_open = true
+ add_command(cmd)
+ "polygon", "polyline":
+ var points: PackedVector2Array
+ var element_points := element.get_attribute_list("points")
+ points.resize(element_points.size() / 2)
+ for point_index in points.size():
+ points[point_index].x = element_points[point_index * 2]
+ points[point_index].y = element_points[point_index * 2 + 1]
+ points = Utils64Bit.transform_vector_array_mult(
+ element.get_precise_transform(),
+ points
+ )
+ var cmd: PebblePathCommand = _setup_cmd(
+ element,
+ (PebblePrecisePathCommand if requires_precise_path(points) else PebblePathCommand).new()
+ )
+ cmd.points = points
+ cmd.is_path_open = element.name == "polyline"
+ add_command(cmd)
+ "path":
+ var commands := _generate_path_commands(element)
+
+ # All commands are imprecise when they come out of _generate_path_commands,
+ # so upgrade the ones that need it.
+ for command_index in commands.size():
+ if requires_precise_path(commands[command_index].points):
+ commands[command_index] = commands[command_index].upgrade()
+ add_command(commands[command_index])
+
+
+func _generate_path_commands(element: Element) -> Array[PebblePathCommand]:
+ var pathdata: AttributePathdata = element.get_attribute("d")
+ if pathdata.get_command_count() == 0 or not pathdata.get_command(0).command_char in "Mm":
+ return [] # Nothing to draw.
+ var commands: Array[PebblePathCommand]
+ for cmd_idx in pathdata.get_command_count():
+ # Drawing logic.
+ var cmd := pathdata.get_command(cmd_idx)
+ var relative := cmd.relative
+
+ var end_path := func(is_open: bool) -> void:
+ if commands.size() > 0:
+ if commands[-1] == null:
+ return
+ commands[-1].is_path_open = is_open
+ commands.append(null)
+
+ var append_points := func(points: PackedVector2Array) -> void:
+ if commands.size() == 0:
+ commands.append(null)
+ if commands[-1] == null:
+ commands[-1] = _setup_cmd(element, PebblePathCommand.new())
+ commands[-1].points.append_array(points)
+ elif commands[-1].points.size() > 0 and points[0].is_equal_approx(commands[-1].points[-1]):
+ commands[-1].points.append_array(points.slice(1))
+ else:
+ commands[-1].is_path_open = true
+ commands.append(null)
+ commands[-1] = _setup_cmd(element, PebblePathCommand.new())
+ commands[-1].points.append_array(points)
+
+ match cmd.command_char.to_upper():
+ "L":
+ # Line contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var end := cmd.get_start_coords() + v if relative else v
+ append_points.call(PackedVector2Array([cmd.get_start_coords(), end]))
+ "H":
+ # Horizontal line contour.
+ var v := Vector2(cmd.x, 0)
+ var end := cmd.get_start_coords() + v if relative else Vector2(v.x, cmd.start_y)
+ append_points.call(PackedVector2Array([cmd.get_start_coords(), end]))
+ "V":
+ # Vertical line contour.
+ var v := Vector2(0, cmd.y)
+ var end := cmd.get_start_coords() + v if relative else Vector2(cmd.start_x, v.y)
+ append_points.call(PackedVector2Array([cmd.get_start_coords(), end]))
+ "C":
+ # Cubic Bezier curve contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := Vector2(cmd.x1, cmd.y1)
+ var v2 := Vector2(cmd.x2, cmd.y2)
+ var cp1 := cmd.get_start_coords()
+ var cp4 := cp1 + v if relative else v
+ var cp2 := v1 if relative else v1 - cp1
+ var cp3 := v2 - v
+
+ append_points.call(path_generator.generate_cubic(cp1, cp2, cp3, cp4))
+ "S":
+ # Shorthand cubic Bezier curve contour.
+ if cmd_idx == 0:
+ break
+
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := pathdata.get_implied_S_control(cmd_idx)
+ var v2 := Vector2(cmd.x2, cmd.y2)
+
+ var cp1 := cmd.get_start_coords()
+ var cp4 := cp1 + v if relative else v
+ var cp2 := v1 if relative else v1 - cp1
+ var cp3 := v2 - v
+
+ append_points.call(path_generator.generate_cubic(cp1, cp2, cp3, cp4))
+ "Q":
+ # Quadratic Bezier curve contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := Vector2(cmd.x1, cmd.y1)
+ var cp1 := cmd.get_start_coords()
+ var cp2 := cp1 + v1 if relative else v1
+ var cp3 := cp1 + v if relative else v
+
+ append_points.call(path_generator.generate_quadratic(cp1, cp2, cp3))
+ "T":
+ # Shorthand quadratic Bezier curve contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := pathdata.get_implied_T_control(cmd_idx)
+
+ var cp1 := cmd.get_start_coords()
+ var cp2 := v1 + cp1 if relative else v1
+ var cp3 := cp1 + v if relative else v
+
+ if is_nan(cp2.x) and is_nan(cp2.y):
+ append_points.call(PackedVector2Array([cp1, cp3]))
+ else:
+ append_points.call(path_generator.generate_quadratic(cp1, cp2, cp3))
+ "A":
+ # Elliptical arc contour.
+ var start := cmd.get_start_coords()
+ var v := Vector2(cmd.x, cmd.y)
+ var end := start + v if relative else v
+ var ellipse_points := SVGPathUtils.generate_ellipse(
+ start,
+ end,
+ Vector2(cmd.rx, cmd.ry),
+ deg_to_rad(cmd.rot),
+ cmd.large_arc_flag,
+ cmd.sweep_flag,
+ path_generator,
+ )
+ ellipse_points.reverse()
+ append_points.call(ellipse_points)
+ "Z":
+ end_path.call(false)
+ "M":
+ end_path.call(true)
+ if commands[-1] == null:
+ commands.pop_back()
+ else:
+ commands[-1].is_path_open = true
+ var transform := viewbox_transform * element.get_transform()
+ var stroke_width_scale := Utils.vector2_min_element(transform.get_scale())
+ for command in commands:
+ command.points = transform * command.points
+ command.stroke_width *= stroke_width_scale
+ return commands
+
+
+static var _formatter := Formatter.new(Formatter.Preset.COMPACT)
+
+func to_svg() -> String:
+ var document := ElementRoot.new()
+ document.set_attribute("width", size.x)
+ document.set_attribute("height", size.y)
+ for cmd in draw_commands:
+ for svg_cmd in cmd.to_svg():
+ document.insert_child(document.get_child_count(), svg_cmd)
+ return SVGParser.root_to_markup(document, _formatter)
+
+
+static var _path := PebblePathCommand.new()
+
+func _setup_cmd(element: Element, cmd: PebbleCommand) -> PebbleCommand:
+ var fill_color_no_alpha := ColorParser.text_to_color(element.get_attribute_true_color("fill"))
+ cmd.fill_color = to_pebble_color(Color(fill_color_no_alpha, fill_color_no_alpha.a * element.get_attribute_num("fill-opacity")))
+ var stroke_color_no_alpha := ColorParser.text_to_color(element.get_attribute_true_color("stroke"))
+ cmd.stroke_color = to_pebble_color(Color(stroke_color_no_alpha, stroke_color_no_alpha.a * element.get_attribute_num("stroke-opacity")))
+ cmd.stroke_width = int(element.get_attribute_num("stroke-width"))
+ return cmd
+
+func requires_precise_path(points: PackedVector2Array) -> bool:
+ if precise_path_mode == PrecisePathMode.ONLY_PRECISE_PATHS:
+ return true
+ if precise_path_mode == PrecisePathMode.ONLY_IMPRECISE_PATHS:
+ return false
+ for point in points:
+ if not is_equal_approx(_path.encode_point(point.x), point.x) or not is_equal_approx(_path.encode_point(point.y), point.y):
+ return true
+ return false
+
+
+static func to_pebble_color(color: Color) -> int:
+ return (0
+ # BGRA format for some reason 💀
+ | clampi(int(color.b * 4.0), 0, 3) << 0
+ | clampi(int(color.g * 4.0), 0, 3) << 2
+ | clampi(int(color.r * 4.0), 0, 3) << 4
+ | clampi(int(color.a * 4.0), 0, 3) << 6
+ )
+
+
+static func from_pebble_color(color: int) -> Color:
+ return Color(
+ ((color >> 4) & 0b11) / 3.0,
+ ((color >> 2) & 0b11) / 3.0,
+ ((color >> 0) & 0b11) / 3.0,
+ ((color >> 6) & 0b11) / 3.0,
+ )
+
+
+static func simplify_points(points: PackedVector2Array) -> PackedVector2Array:
+ var new_points: PackedVector2Array
+ for point_index in points.size():
+ if new_points.size() > 0:
+ if point_index - 1 >= 0 and point_index + 1 < points.size() - 1:
+ var colinear_a := new_points[-1]
+ var colinear_b := points[point_index] - colinear_a
+ var colinear_c := points[point_index + 1] - colinear_a
+ var alignment := colinear_b.dot(colinear_c) / sqrt(colinear_b.length_squared() * colinear_c.length_squared())
+ if is_equal_approx(alignment, 1.0):
+ # Skip colinear points.
+ continue
+ if point_index > 0:
+ if new_points[-1].is_equal_approx(points[point_index]):
+ # Skip overlapping points.
+ continue
+ new_points.append(points[point_index])
+ return new_points
diff --git a/src/data_classes/PDCImage.gd.uid b/src/data_classes/PDCImage.gd.uid
new file mode 100644
index 000000000..1f4eb33e3
--- /dev/null
+++ b/src/data_classes/PDCImage.gd.uid
@@ -0,0 +1 @@
+uid://qshqvcmff71q
diff --git a/src/ui_parts/export_menu.gd b/src/ui_parts/export_menu.gd
index e69f5c77c..169a53ac0 100644
--- a/src/ui_parts/export_menu.gd
+++ b/src/ui_parts/export_menu.gd
@@ -21,6 +21,9 @@ var dimensions := Vector2.ZERO
@onready var height_edit: NumberEdit = %Height
@onready var size_container: CenterContainer = %SizeContainer
@onready var lossless_checkbox: CheckBox = %LosslessCheckBox
+@onready var precise_path_mode_dropdown: Dropdown = %PrecisePathModeDropdown
+@onready var path_quality_container: Control = %PDCPathQualityContainer
+@onready var path_quality_edit: NumberEdit = %PathQualityNumberEdit
@onready var quality_edit: NumberEdit = %Quality
@onready var quality_hbox: HBoxContainer = %QualityHBox
@onready var cancel_button: Button = %ButtonContainer/CancelButton
@@ -46,6 +49,8 @@ func _ready() -> void:
height_edit.value_changed.connect(_on_height_edit_value_changed)
quality_edit.value_changed.connect(_on_quality_value_changed)
lossless_checkbox.toggled.connect(_on_lossless_check_box_toggled)
+ path_quality_edit.value_changed.connect(_on_path_quality_value_changed)
+ precise_path_mode_dropdown.value_changed.connect(_on_precise_path_mode_dropdown_toggled)
format_dropdown.value_changed.connect(_on_dropdown_value_changed)
dimensions = State.root_element.get_size()
@@ -70,10 +75,13 @@ func _ready() -> void:
file_title.add_theme_color_override("font_color", ThemeUtils.subtle_text_color)
file_title.text = Configs.savedata.get_active_tab().presented_name
- final_size_label.text = Translator.translate("Size") + ": " + String.humanize_size(State.get_export_text().length())
%TitleLabel.text = Translator.translate("Export Configuration")
%FormatHBox/Label.text = Translator.translate("Format") + ":"
%LosslessCheckBox.text = Translator.translate("Lossless")
+ %PDCPathQualityContainer/PreciseModeLabel.text = Translator.translate("Precise") + ":"
+ %PDCPathQualityContainer/QualityLabel.text = Translator.translate("Tessellation quality") + ":"
+ %PDCPathQualityContainer/HBoxContainer/SuffixLabel.text = Translator.translate("degrees")
+ path_quality_edit.tooltip_text = Translator.translate("The angular tolerance at which to tessellate paths.")
%QualityHBox/Label.text = Translator.translate("Quality") + ":"
%ScaleContainer/Label.text = Translator.translate("Scale")
%WidthContainer/Label.text = Translator.translate("Width") + ":"
@@ -93,8 +101,21 @@ func _ready() -> void:
titled_panel.title_margin = 2
titled_panel.panel_margin = 8
- HandlerGUI.register_focus_sequence(self, [clipboard_button, format_dropdown, lossless_checkbox,
- quality_edit, scale_edit, width_edit, height_edit, cancel_button, export_button], true)
+ var focus_sequence: Array[Control]
+ focus_sequence.append(clipboard_button)
+ focus_sequence.append(format_dropdown)
+ focus_sequence.append(path_quality_edit)
+ focus_sequence.append(precise_path_mode_dropdown)
+ focus_sequence.append(lossless_checkbox)
+ focus_sequence.append(quality_edit)
+ focus_sequence.append(scale_edit)
+ focus_sequence.append(width_edit)
+ focus_sequence.append(height_edit)
+ focus_sequence.append(cancel_button)
+ focus_sequence.append(export_button)
+
+ HandlerGUI.register_focus_sequence(self, focus_sequence, true)
+ clipboard_button.grab_focus(true)
func _on_export_button_pressed() -> void:
@@ -121,6 +142,20 @@ func _on_lossless_check_box_toggled(toggled_on: bool) -> void:
undo_redo.add_undo_property(export_data, "lossy", current_lossy)
undo_redo.commit_action()
+func _on_precise_path_mode_dropdown_toggled(precise_path_mode: PDCImage.PrecisePathMode) -> void:
+ var current_precise_path_mode := export_data.precise_path_mode
+ undo_redo.create_action()
+ undo_redo.add_do_property(export_data, "precise_path_mode", precise_path_mode)
+ undo_redo.add_undo_property(export_data, "precise_path_mode", current_precise_path_mode)
+ undo_redo.commit_action()
+
+func _on_path_quality_value_changed(new_value: float) -> void:
+ var current_quality := export_data.tessellation_tolerance_degrees
+ undo_redo.create_action()
+ undo_redo.add_do_property(export_data, "tessellation_tolerance_degrees", new_value)
+ undo_redo.add_undo_property(export_data, "tessellation_tolerance_degrees", current_quality)
+ undo_redo.commit_action()
+
func _on_quality_value_changed(new_value: float) -> void:
var current_quality := export_data.quality
undo_redo.create_action()
@@ -158,12 +193,11 @@ func _dimension_component_change_common_logic(component_index: int, new_value: f
func update() -> void:
# Determine which fields are visible.
quality_related_container.visible = export_data.format in ["jpg", "jpeg", "webp"]
+ path_quality_container.visible = export_data.format in ["pdc"]
quality_hbox.visible = export_data.format in ["jpg", "jpeg"] or export_data.format == "webp" and export_data.lossy
lossless_checkbox.visible = (export_data.format == "webp")
size_container.visible = export_data.format in ["png", "jpg", "jpeg", "webp"]
- final_size_label.visible = (export_data.format == "svg")
-
var file_name := Utils.get_file_name(Configs.savedata.get_active_tab().svg_file_path)
if not file_name.is_empty():
file_title.text = file_name + "." + export_data.format
@@ -181,10 +215,32 @@ func update() -> void:
# Sync all other widgets, so they are updated on changes from UndoRedo too.
quality_edit.set_value(export_data.quality * 100, false)
lossless_checkbox.set_pressed_no_signal(not export_data.lossy)
+
+ var export_size_fac := export_data.upscale_amount * Utils.vector2_max_element(dimensions) / texture_preview.MAX_IMAGE_DIMENSION
+
+ var export_size: int
+ match export_data.format:
+ "svg":
+ export_size = State.get_export_text().length()
+ _:
+ match export_data.format:
+ "webp":
+ # WebP seems to have better compression as the image gets larger.
+ pass
+ "pdc":
+ # PDC exports are always perfectly accurate.
+ export_size_fac = 1.0
+ _:
+ export_size_fac **= 2
+ export_size = roundi(texture_preview.last_image_size * maxf(1.0, export_size_fac))
+ final_size_label.text = Translator.translate("Size") + ": " + String.humanize_size(export_size)
+ if export_size_fac > 1.0:
+ final_size_label.text += Translator.translate(" (approximate)")
+
format_dropdown.set_value(export_data.format, false)
+ precise_path_mode_dropdown.set_value(export_data.precise_path_mode, false)
- info_tooltip.visible = (export_data.format != "svg" and\
- roundi(export_data.upscale_amount * maxf(dimensions.x, dimensions.y)) > texture_preview.MAX_IMAGE_DIMENSION)
+ info_tooltip.visible = (export_data.format != "svg" and export_size_fac > 1.0)
clipboard_button.disabled = not ClipboardUtils.is_supported(export_data.format)
diff --git a/src/ui_parts/export_menu.tscn b/src/ui_parts/export_menu.tscn
index 7a3b5065d..97af82363 100644
--- a/src/ui_parts/export_menu.tscn
+++ b/src/ui_parts/export_menu.tscn
@@ -88,7 +88,7 @@ layout_mode = 2
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel" unique_id=1828943266]
custom_minimum_size = Vector2(240, 0)
layout_mode = 2
-theme_override_constants/separation = 12
+theme_override_constants/separation = 6
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer" unique_id=480818878]
layout_mode = 2
@@ -130,10 +130,11 @@ theme_override_font_sizes/font_size = 12
[node name="Dropdown" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/FormatHBox" unique_id=1383013352 instance=ExtResource("5_y6ex0")]
custom_minimum_size = Vector2(60, 22)
layout_mode = 2
-values = ["svg", "png", "jpeg", "webp"]
+values = ["svg", "png", "jpeg", "webp", "pdc"]
aliases = Dictionary[String, Variant]({
"JPEG": "jpeg",
"JPG": "jpeg",
+"PDC": "pdc",
"PNG": "png",
"SVG": "svg",
"WEBP": "webp",
@@ -142,12 +143,44 @@ aliases = Dictionary[String, Variant]({
})
editing_enabled = true
-[node name="FinalSizeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" unique_id=819439799]
+[node name="PDCPathQualityContainer" type="GridContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" unique_id=819439799]
unique_name_in_owner = true
layout_mode = 2
+columns = 2
+
+[node name="QualityLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1784192134]
+layout_mode = 2
theme_override_font_sizes/font_size = 12
-[node name="QualityRelatedContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" unique_id=1408255560]
+[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1683798398]
+layout_mode = 2
+
+[node name="PathQualityNumberEdit" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer/HBoxContainer" unique_id=86271434 instance=ExtResource("6_w1sag")]
+unique_name_in_owner = true
+layout_mode = 2
+initial_value = 10.0
+
+[node name="SuffixLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer/HBoxContainer" unique_id=988850831]
+layout_mode = 2
+theme_override_font_sizes/font_size = 12
+
+[node name="PreciseModeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1100694615]
+layout_mode = 2
+size_flags_horizontal = 8
+theme_override_font_sizes/font_size = 12
+
+[node name="PrecisePathModeDropdown" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1547915458 instance=ExtResource("5_y6ex0")]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(96, 22)
+layout_mode = 2
+values = [0, 1, 2]
+value_text_map = {
+0: "Autodetect",
+1: "Always",
+2: "Never"
+}
+
+[node name="QualityRelatedContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" unique_id=1072118615]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12
@@ -248,6 +281,11 @@ min_value = 1.0
max_value = 16384.0
is_float = false
+[node name="FinalSizeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" unique_id=51719619]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_font_sizes/font_size = 12
+
[node name="ButtonContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=435949939]
unique_name_in_owner = true
layout_mode = 2
diff --git a/src/ui_parts/previews.gd b/src/ui_parts/previews.gd
index 8eef4435b..df0e1122a 100644
--- a/src/ui_parts/previews.gd
+++ b/src/ui_parts/previews.gd
@@ -38,7 +38,7 @@ class IconPreviewTileData extends RefCounted:
var more_button_rect: Rect2
var bigger_dimension: int
var label_text: String
- var preview_texture: DPITexture
+ var preview_texture: Texture2D
func _init(new_index: int) -> void:
index = new_index
@@ -109,6 +109,7 @@ func _ready() -> void:
sync_tile_positions()
)
icon_preview_tiles.resized.connect(sync_tile_positions)
+
sync_tiles()
HandlerGUI.register_focus_sequence(self, [add_new_preview_button,
transparent_color_swatch, black_color_swatch, white_color_swatch, color_edit, more_button])
diff --git a/src/ui_widgets/ContextButton.gd b/src/ui_widgets/ContextButton.gd
index d41aec102..7cc5d39f0 100644
--- a/src/ui_widgets/ContextButton.gd
+++ b/src/ui_widgets/ContextButton.gd
@@ -19,6 +19,8 @@ var custom_dim_text := ""
var toggled_on := true
var submenu_button_builders: Array[Callable] = []
var disabled := false
+var theme_type := ""
+var auto_toggle := true
func _init() -> void:
@@ -76,6 +78,16 @@ static func create_checkbox_from_action(new_action: String, start_toggled_on: bo
context_button.toggled_on = start_toggled_on
return context_button
+static func create_custom_checkbox(new_custom_text: String, new_custom_callback: Callable, start_toggled_on: bool, new_custom_icon: Texture2D = null, new_disabled := false) -> ContextButton:
+ var context_button := ContextButton.create(new_disabled)
+ context_button.type = Type.CHECKBOX
+ context_button.custom_text = new_custom_text
+ context_button.custom_callback = new_custom_callback
+ context_button.toggled_on = start_toggled_on
+ context_button.custom_icon = new_custom_icon
+ context_button.calibrate()
+ return context_button
+
static func create_arrow(new_text: String, new_submenu_button_builders: Array[Callable]) -> ContextButton:
var context_button := ContextButton.new()
context_button.custom_text = new_text
@@ -155,7 +167,17 @@ func add_custom_dim_text(new_dim_text: String) -> ContextButton:
func get_callback() -> Callable:
- return custom_callback if custom_callback.is_valid() else HandlerGUI.throw_action_event.bind(action)
+ # Consider making this call the function directly, rather than return a callback.
+ if not auto_toggle:
+ return custom_callback if custom_callback.is_valid() else HandlerGUI.throw_action_event.bind(action)
+ return func():
+ if type == Type.CHECKBOX:
+ toggled_on = not toggled_on
+ if custom_callback.is_valid():
+ custom_callback.call()
+ else:
+ HandlerGUI.throw_action_event(action)
+ queue_redraw()
func get_text() -> String:
return custom_text if not custom_text.is_empty() else TranslationUtils.get_action_description(action, true)
@@ -171,3 +193,10 @@ func get_icon() -> Texture2D:
elif not action.is_empty():
return ShortcutUtils.get_action_icon(action)
return null
+
+func get_theme_type() -> String:
+ if not theme_type.is_empty():
+ return theme_type
+ if type == Type.CHECKBOX:
+ return "CheckBox"
+ return ""
diff --git a/src/ui_widgets/ContextPopup.gd b/src/ui_widgets/ContextPopup.gd
index 1da356a0c..7d2512a3b 100644
--- a/src/ui_widgets/ContextPopup.gd
+++ b/src/ui_widgets/ContextPopup.gd
@@ -158,14 +158,14 @@ func _draw() -> void:
var checkbox_icon: Texture2D
if button.toggled_on:
if button.disabled:
- checkbox_icon = get_theme_icon("checked_disabled", "CheckBox")
+ checkbox_icon = get_theme_icon("checked_disabled", button.get_theme_type())
else:
- checkbox_icon = get_theme_icon("checked", "CheckBox")
+ checkbox_icon = get_theme_icon("checked", button.get_theme_type())
else:
if button.disabled:
- checkbox_icon = get_theme_icon("unchecked_disabled", "CheckBox")
+ checkbox_icon = get_theme_icon("unchecked_disabled", button.get_theme_type())
else:
- checkbox_icon = get_theme_icon("unchecked", "CheckBox")
+ checkbox_icon = get_theme_icon("unchecked", button.get_theme_type())
checkbox_icon.draw(ci, button_rect.position + ContextButton.PADDING * Vector2(1, 1))
var button_icon := button.get_icon()
diff --git a/src/ui_widgets/handles_manager.gd b/src/ui_widgets/handles_manager.gd
index 1d68de81f..54ad4cbc1 100644
--- a/src/ui_widgets/handles_manager.gd
+++ b/src/ui_widgets/handles_manager.gd
@@ -458,203 +458,19 @@ func _draw() -> void:
selection_rects.append(bounding_box.grow_individual(grow_amount_x, grow_amount_y, grow_amount_x, grow_amount_y))
"path":
- var pathdata: AttributePathdata = element.get_attribute("d")
- if pathdata.get_command_count() == 0 or not pathdata.get_command(0).command_char in "Mm":
- continue # Nothing to draw.
-
- var current_mode := Utils.InteractionType.NONE
-
- for cmd_idx in pathdata.get_command_count():
- # Drawing logic.
- var points := PackedVector2Array()
- var tangent_points := PackedVector2Array()
- var cmd := pathdata.get_command(cmd_idx)
- var relative := cmd.relative
-
- current_mode = Utils.InteractionType.NONE
- if canvas.is_hovered(element.xid, cmd_idx, true):
- @warning_ignore("int_as_enum_without_cast")
- current_mode += Utils.InteractionType.HOVERED
- if canvas.is_selected(element.xid, cmd_idx, true):
- @warning_ignore("int_as_enum_without_cast")
- current_mode += Utils.InteractionType.SELECTED
-
- match cmd.command_char.to_upper():
- "L":
- # Line contour.
- var v := Vector2(cmd.x, cmd.y)
- var end := cmd.get_start_coords() + v if relative else v
- points = PackedVector2Array([cmd.get_start_coords(), end])
- "H":
- # Horizontal line contour.
- var v := Vector2(cmd.x, 0)
- var end := cmd.get_start_coords() + v if relative else Vector2(v.x, cmd.start_y)
- points = PackedVector2Array([cmd.get_start_coords(), end])
- "V":
- # Vertical line contour.
- var v := Vector2(0, cmd.y)
- var end := cmd.get_start_coords() + v if relative else Vector2(cmd.start_x, v.y)
- points = PackedVector2Array([cmd.get_start_coords(), end])
- "C":
- # Cubic Bezier curve contour.
- var v := Vector2(cmd.x, cmd.y)
- var v1 := Vector2(cmd.x1, cmd.y1)
- var v2 := Vector2(cmd.x2, cmd.y2)
- var cp1 := cmd.get_start_coords()
- var cp4 := cp1 + v if relative else v
- var cp2 := v1 if relative else v1 - cp1
- var cp3 := v2 - v
-
- points = Utils.get_cubic_bezier_points(cp1, cp2, cp3, cp4)
- tangent_points.append_array(PackedVector2Array([cp1, cp1 + cp2, cp1 + v2 if relative else v2, cp4]))
- "S":
- # Shorthand cubic Bezier curve contour.
- if cmd_idx == 0:
- break
-
- var v := Vector2(cmd.x, cmd.y)
- var v1 := pathdata.get_implied_S_control(cmd_idx)
- var v2 := Vector2(cmd.x2, cmd.y2)
-
- var cp1 := cmd.get_start_coords()
- var cp4 := cp1 + v if relative else v
- var cp2 := v1 if relative else v1 - cp1
- var cp3 := v2 - v
-
- points = Utils.get_cubic_bezier_points(cp1, cp2, cp3, cp4)
- tangent_points.append_array(PackedVector2Array([cp1, cp1 + cp2, cp1 + v2 if relative else v2, cp4]))
- "Q":
- # Quadratic Bezier curve contour.
- var v := Vector2(cmd.x, cmd.y)
- var v1 := Vector2(cmd.x1, cmd.y1)
- var cp1 := cmd.get_start_coords()
- var cp2 := cp1 + v1 if relative else v1
- var cp3 := cp1 + v if relative else v
-
- points = Utils.get_quadratic_bezier_points(cp1, cp2, cp3)
- tangent_points.append_array(PackedVector2Array([cp1, cp2, cp2, cp3]))
- "T":
- # Shorthand quadratic Bezier curve contour.
- var v := Vector2(cmd.x, cmd.y)
- var v1 := pathdata.get_implied_T_control(cmd_idx)
-
- var cp1 := cmd.get_start_coords()
- var cp2 := v1 + cp1 if relative else v1
- var cp3 := cp1 + v if relative else v
-
- if is_nan(cp2.x) and is_nan(cp2.y):
- points = PackedVector2Array([cp1, cp3])
- else:
- points = Utils.get_quadratic_bezier_points(cp1, cp2, cp3)
- tangent_points.append_array(PackedVector2Array([cp1, cp2, cp2, cp3]))
- "A":
- # Elliptical arc contour.
- var start := cmd.get_start_coords()
- var v := Vector2(cmd.x, cmd.y)
- var end := start + v if relative else v
- # Correct for out-of-range radii.
- if start == end:
- continue
- elif cmd.rx == 0 or cmd.ry == 0:
- points = PackedVector2Array([start, end])
-
- var r := Vector2(cmd.rx, cmd.ry).abs()
- # Obtain center parametrization.
- var rot := deg_to_rad(cmd.rot)
- 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 cmd.large_arc_flag == cmd.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 cmd.sweep_flag == 0:
- theta1 += delta_theta
- delta_theta = TAU - delta_theta
-
- # Now we have a center parametrization (r, c, theta1, delta_theta).
- # We will approximate the elliptical arc with Bezier curves.
- # Use the method described in https://www.blog.akhil.cc/ellipse
- # (but with modifications because it wasn't working fully).
- var segments := delta_theta * 4/PI
- var n := floori(segments)
- var p1 := Utils.E(c, r, cosine, sine, theta1)
- var e1 := Utils.Et(r, cosine, sine, theta1)
- var alpha := 0.26511478
- var t := theta1 + PI/4
- var cp: Array[PackedVector2Array] = []
- for _i in n:
- var p2 := Utils.E(c, r, cosine, sine, t)
- var e2 := Utils.Et(r, cosine, sine, t)
- cp.append(PackedVector2Array([p1, alpha * e1, -alpha * e2, p2]))
- p1 = p2
- e1 = e2
- t += PI/4
-
- if n != ceili(segments) and not is_equal_approx(n, segments):
- t = theta1 + delta_theta
- var p2 := Utils.E(c, r, cosine, sine, t)
- var e2 := Utils.Et(r, cosine, sine, t)
- alpha *= fposmod(delta_theta, PI/4) / (PI/4)
- cp.append(PackedVector2Array([p1, alpha * e1, -alpha * e2, p2]))
-
- for p in cp:
- points += Utils.get_cubic_bezier_points(p[0], p[1], p[2], p[3])
- "Z":
- # Path closure contour.
- var prev_M_idx := cmd_idx - 1
- var prev_M_cmd := pathdata.get_command(prev_M_idx)
- while prev_M_idx >= 0:
- if prev_M_cmd.command_char in "Mm":
- break
- prev_M_idx -= 1
- prev_M_cmd = pathdata.get_command(prev_M_idx)
- if prev_M_idx == -1:
- break
-
- var end := Vector2(prev_M_cmd.x, prev_M_cmd.y)
- if prev_M_cmd.relative:
- end += prev_M_cmd.get_start_coords()
-
- points = PackedVector2Array([cmd.get_start_coords(), end])
- "M":
- continue
-
- var final_transform := element.get_transform()
- points = final_transform * points
- tangent_points = final_transform * tangent_points
- match current_mode:
- Utils.InteractionType.NONE:
- normal_polylines.append(points)
- normal_multiline += tangent_points
- Utils.InteractionType.HOVERED:
- hovered_polylines.append(points)
- hovered_multiline += tangent_points
- Utils.InteractionType.SELECTED:
- selected_polylines.append(points)
- selected_multiline += tangent_points
- Utils.InteractionType.HOVERED_SELECTED:
- hovered_selected_polylines.append(points)
- hovered_selected_multiline += tangent_points
-
+ SVGPathUtils.get_path_element_points(
+ element,
+ normal_polylines,
+ normal_multiline,
+ SVGPathUtils.default_path_generator,
+ hovered_polylines,
+ hovered_multiline,
+ selected_polylines,
+ selected_multiline,
+ hovered_selected_polylines,
+ hovered_selected_multiline,
+ canvas,
+ )
if element_selected:
var bounding_box: Rect2 = element.get_bounding_box()
if bounding_box.has_area():
diff --git a/src/ui_widgets/preview_rect.gd b/src/ui_widgets/preview_rect.gd
index 67895666c..e064b27a1 100644
--- a/src/ui_widgets/preview_rect.gd
+++ b/src/ui_widgets/preview_rect.gd
@@ -5,6 +5,9 @@ const MAX_IMAGE_DIMENSION = 512
@onready var checkerboard: TextureRect = $Checkerboard
@onready var texture_preview: TextureRect = $Checkerboard/TexturePreview
+## The size of the last setup image, in bytes.
+var last_image_size: int
+
func setup_svg_without_dimensions(svg_text: String) -> void:
var root := SVGParser.markup_to_root(svg_text).svg
if is_instance_valid(root):
@@ -27,15 +30,23 @@ func setup_image(config: ImageExportData) -> void:
final_image_config.format = config.format
final_image_config.lossy = config.lossy
final_image_config.quality = config.quality
+ final_image_config.precise_path_mode = config.precise_path_mode
+ final_image_config.tessellation_tolerance_degrees = config.tessellation_tolerance_degrees
var svg_size := State.root_element.get_size()
final_image_config.upscale_amount = minf(config.upscale_amount, MAX_IMAGE_DIMENSION / maxf(svg_size.x, svg_size.y))
- var buffer := final_image_config.image_to_buffer(final_image_config.generate_image())
var image := Image.new()
+ var buffer := final_image_config.image_to_buffer(final_image_config.generate_image())
+ last_image_size = buffer.size()
match config.format:
"png": image.load_png_from_buffer(buffer)
"jpg", "jpeg": image.load_jpg_from_buffer(buffer)
"webp": image.load_webp_from_buffer(buffer)
+ "pdc":
+ var pdc := PDCImage.new()
+ pdc.load_from_pdc(buffer)
+ image.load_svg_from_string(pdc.to_svg())
+ last_image_size = buffer.size()
var factor := minf(size.x / image.get_width(), size.y / image.get_height())
var final_width := maxi(int(image.get_width() * factor), 1)
diff --git a/src/utils/FileUtils.gd b/src/utils/FileUtils.gd
index 65c06c4e4..99b585c0a 100644
--- a/src/utils/FileUtils.gd
+++ b/src/utils/FileUtils.gd
@@ -145,6 +145,9 @@ static func _finish_export(file_path: String, export_data: ImageExportData) -> v
"png": export_data.generate_image().save_png(file_path)
"jpg", "jpeg": export_data.generate_image().save_jpg(file_path, export_data.quality)
"webp": export_data.generate_image().save_webp(file_path, export_data.lossy, export_data.quality)
+ "pdc":
+ var fd := FileAccess.open(file_path, FileAccess.WRITE)
+ fd.store_buffer(export_data.image_to_buffer(null))
_:
# When saving SVG, also modify the file path to associate it with the graphic being edited.
var active_tab := Configs.savedata.get_active_tab()
diff --git a/src/utils/ImageExportData.gd b/src/utils/ImageExportData.gd
index 039e99f23..220e3e88c 100644
--- a/src/utils/ImageExportData.gd
+++ b/src/utils/ImageExportData.gd
@@ -6,6 +6,7 @@ const image_types_dict: Dictionary[String, String] = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
+ "pdc": "image/x-pdc",
}
signal changed
@@ -34,15 +35,35 @@ var lossy := false:
lossy = new_value
changed.emit()
+var tessellation_tolerance_degrees := 10.0:
+ set(new_value):
+ if new_value != tessellation_tolerance_degrees:
+ tessellation_tolerance_degrees = new_value
+ changed.emit()
+
+
+var precise_path_mode := PDCImage.PrecisePathMode.AUTODETECT:
+ set(new_value):
+ if new_value != precise_path_mode:
+ precise_path_mode = new_value
+ changed.emit()
+
static func svg_to_buffer() -> PackedByteArray:
return State.get_export_text().to_utf8_buffer()
+
func image_to_buffer(image: Image) -> PackedByteArray:
match format:
"png": return image.save_png_to_buffer()
"jpg", "jpeg": return image.save_jpg_to_buffer(quality)
"webp": return image.save_webp_to_buffer(lossy, quality)
+ "pdc":
+ var pdc := PDCImage.new()
+ pdc.precise_path_mode = precise_path_mode
+ pdc.path_angle_tolerance = tessellation_tolerance_degrees
+ pdc.load_from_svg(SVGParser.markup_to_root(State.get_export_text()).svg)
+ return pdc.encode()
_: return svg_to_buffer()
diff --git a/src/utils/SVGPathUtils.gd b/src/utils/SVGPathUtils.gd
new file mode 100644
index 000000000..2067b185c
--- /dev/null
+++ b/src/utils/SVGPathUtils.gd
@@ -0,0 +1,255 @@
+class_name SVGPathUtils
+
+static var default_path_generator := AngleTolerancePathGenerator.new()
+@warning_ignore("unused_parameter")
+static func get_path_element_points(
+ element: Element,
+ normal_polylines: Array[PackedVector2Array],
+ normal_multiline: PackedVector2Array = [],
+ path_generator: PathGenerator = default_path_generator,
+ hovered_polylines: Array[PackedVector2Array] = [],
+ hovered_multiline: PackedVector2Array = [],
+ selected_polylines: Array[PackedVector2Array] = [],
+ selected_multiline: PackedVector2Array = [],
+ hovered_selected_polylines: Array[PackedVector2Array] = [],
+ hovered_selected_multiline: PackedVector2Array = [],
+ canvas: Canvas = null,
+) -> void:
+ assert(element.name == "path")
+ var pathdata: AttributePathdata = element.get_attribute("d")
+ if pathdata.get_command_count() == 0 or not pathdata.get_command(0).command_char in "Mm":
+ return # Nothing to draw.
+ var current_mode := Utils.InteractionType.NONE
+
+ for cmd_idx in pathdata.get_command_count():
+ # Drawing logic.
+ var points := PackedVector2Array()
+ var tangent_points := PackedVector2Array()
+ var cmd := pathdata.get_command(cmd_idx)
+ var relative := cmd.relative
+
+ current_mode = Utils.InteractionType.NONE
+ if canvas != null:
+ if canvas.is_hovered(element.xid, cmd_idx, true):
+ @warning_ignore("int_as_enum_without_cast")
+ current_mode += Utils.InteractionType.HOVERED
+ if canvas.is_selected(element.xid, cmd_idx, true):
+ @warning_ignore("int_as_enum_without_cast")
+ current_mode += Utils.InteractionType.SELECTED
+
+ match cmd.command_char.to_upper():
+ "L":
+ # Line contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var end := cmd.get_start_coords() + v if relative else v
+ points = PackedVector2Array([cmd.get_start_coords(), end])
+ "H":
+ # Horizontal line contour.
+ var v := Vector2(cmd.x, 0)
+ var end := cmd.get_start_coords() + v if relative else Vector2(v.x, cmd.start_y)
+ points = PackedVector2Array([cmd.get_start_coords(), end])
+ "V":
+ # Vertical line contour.
+ var v := Vector2(0, cmd.y)
+ var end := cmd.get_start_coords() + v if relative else Vector2(cmd.start_x, v.y)
+ points = PackedVector2Array([cmd.get_start_coords(), end])
+ "C":
+ # Cubic Bezier curve contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := Vector2(cmd.x1, cmd.y1)
+ var v2 := Vector2(cmd.x2, cmd.y2)
+ var cp1 := cmd.get_start_coords()
+ var cp4 := cp1 + v if relative else v
+ var cp2 := v1 if relative else v1 - cp1
+ var cp3 := v2 - v
+
+ points = path_generator.generate_cubic(cp1, cp2, cp3, cp4)
+ tangent_points.append_array(PackedVector2Array([cp1, cp1 + cp2, cp1 + v2 if relative else v2, cp4]))
+ "S":
+ # Shorthand cubic Bezier curve contour.
+ if cmd_idx == 0:
+ break
+
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := pathdata.get_implied_S_control(cmd_idx)
+ var v2 := Vector2(cmd.x2, cmd.y2)
+
+ var cp1 := cmd.get_start_coords()
+ var cp4 := cp1 + v if relative else v
+ var cp2 := v1 if relative else v1 - cp1
+ var cp3 := v2 - v
+
+ points = path_generator.generate_cubic(cp1, cp2, cp3, cp4)
+ tangent_points.append_array(PackedVector2Array([cp1, cp1 + cp2, cp1 + v2 if relative else v2, cp4]))
+ "Q":
+ # Quadratic Bezier curve contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := Vector2(cmd.x1, cmd.y1)
+ var cp1 := cmd.get_start_coords()
+ var cp2 := cp1 + v1 if relative else v1
+ var cp3 := cp1 + v if relative else v
+
+ points = path_generator.generate_quadratic(cp1, cp2, cp3)
+ tangent_points.append_array(PackedVector2Array([cp1, cp2, cp2, cp3]))
+ "T":
+ # Shorthand quadratic Bezier curve contour.
+ var v := Vector2(cmd.x, cmd.y)
+ var v1 := pathdata.get_implied_T_control(cmd_idx)
+
+ var cp1 := cmd.get_start_coords()
+ var cp2 := v1 + cp1 if relative else v1
+ var cp3 := cp1 + v if relative else v
+
+ if is_nan(cp2.x) and is_nan(cp2.y):
+ points = PackedVector2Array([cp1, cp3])
+ else:
+ points = path_generator.generate_quadratic(cp1, cp2, cp3)
+ tangent_points.append_array(PackedVector2Array([cp1, cp2, cp2, cp3]))
+ "A":
+ # Elliptical arc contour.
+ var start := cmd.get_start_coords()
+ var v := Vector2(cmd.x, cmd.y)
+ var end := start + v if relative else v
+ points = generate_ellipse(
+ start,
+ end,
+ Vector2(cmd.rx, cmd.ry),
+ deg_to_rad(cmd.rot),
+ cmd.large_arc_flag,
+ cmd.sweep_flag,
+ path_generator,
+ )
+ "Z":
+ # Path closure contour.
+ var prev_M_idx := cmd_idx - 1
+ var prev_M_cmd := pathdata.get_command(prev_M_idx)
+ while prev_M_idx >= 0:
+ if prev_M_cmd.command_char in "Mm":
+ break
+ prev_M_idx -= 1
+ prev_M_cmd = pathdata.get_command(prev_M_idx)
+ if prev_M_idx == -1:
+ break
+
+ var end := Vector2(prev_M_cmd.x, prev_M_cmd.y)
+ if prev_M_cmd.relative:
+ end += prev_M_cmd.get_start_coords()
+
+ points = PackedVector2Array([cmd.get_start_coords(), end])
+ "M":
+ continue
+
+ var final_transform := element.get_transform()
+ points = final_transform * points
+ tangent_points = final_transform * tangent_points
+ match current_mode:
+ Utils.InteractionType.NONE:
+ normal_polylines.append(points)
+ normal_multiline += tangent_points
+ Utils.InteractionType.HOVERED:
+ hovered_polylines.append(points)
+ hovered_multiline += tangent_points
+ Utils.InteractionType.SELECTED:
+ selected_polylines.append(points)
+ selected_multiline += tangent_points
+ Utils.InteractionType.HOVERED_SELECTED:
+ hovered_selected_polylines.append(points)
+ hovered_selected_multiline += tangent_points
+
+
+static func generate_ellipse(
+ start: Vector2, end: Vector2, r: Vector2,
+ rot: float,
+ large_arc_flag: bool, sweep_flag: bool,
+ path_generator: PathGenerator = default_path_generator
+) -> PackedVector2Array:
+ var points: PackedVector2Array
+ if start == end:
+ return []
+ elif r.x == 0 or r.y == 0:
+ points = PackedVector2Array([start, end])
+
+ # Obtain center parametrization.
+ 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
+
+ # Now we have a center parametrization (r, c, theta1, delta_theta).
+ # We will approximate the elliptical arc with Bezier curves.
+ # Use the method described in https://www.blog.akhil.cc/ellipse
+ # (but with modifications because it wasn't working fully).
+ var segments := delta_theta * 4/PI
+ var n := floori(segments)
+ var p1 := Utils.E(c, r, cosine, sine, theta1)
+ var e1 := Utils.Et(r, cosine, sine, theta1)
+ var alpha := 0.26511478
+ var t := theta1 + PI/4
+ var cp: Array[PackedVector2Array] = []
+ for _i in n:
+ var p2 := Utils.E(c, r, cosine, sine, t)
+ var e2 := Utils.Et(r, cosine, sine, t)
+ cp.append(PackedVector2Array([p1, alpha * e1, -alpha * e2, p2]))
+ p1 = p2
+ e1 = e2
+ t += PI/4
+
+ if n != ceili(segments) and not is_equal_approx(n, segments):
+ t = theta1 + delta_theta
+ var p2 := Utils.E(c, r, cosine, sine, t)
+ var e2 := Utils.Et(r, cosine, sine, t)
+ alpha *= fposmod(delta_theta, PI/4) / (PI/4)
+ cp.append(PackedVector2Array([p1, alpha * e1, -alpha * e2, p2]))
+
+ for point in cp:
+ points += path_generator.generate_cubic(point[0], point[1], point[2], point[3])
+ return points
+
+
+@abstract class PathGenerator:
+ func generate_cubic(cp1: Vector2, cp2: Vector2, cp3: Vector2, cp4: Vector2) -> PackedVector2Array:
+ var curve := Curve2D.new()
+ curve.add_point(cp1, Vector2(), cp2)
+ curve.add_point(cp4, cp3)
+ return generate_curve(curve)
+
+ func generate_quadratic(cp1: Vector2, cp2: Vector2, cp3: Vector2) -> PackedVector2Array:
+ return generate_cubic(cp1, 2/3.0 * (cp2 - cp1), 2/3.0 * (cp2 - cp3), cp3)
+
+ @abstract func generate_curve(from: Curve2D) -> PackedVector2Array
+
+
+class AngleTolerancePathGenerator extends PathGenerator:
+ var angle_tolerance: float = 1.0
+ func generate_curve(from: Curve2D) -> PackedVector2Array:
+ return from.tessellate(6, angle_tolerance)
+
+
+class EvenLengthPathGenerator extends PathGenerator:
+ var length_tolerance: float = 0.5
+ func generate_curve(from: Curve2D) -> PackedVector2Array:
+ return from.tessellate_even_length(6, length_tolerance)
diff --git a/src/utils/SVGPathUtils.gd.uid b/src/utils/SVGPathUtils.gd.uid
new file mode 100644
index 000000000..94816b176
--- /dev/null
+++ b/src/utils/SVGPathUtils.gd.uid
@@ -0,0 +1 @@
+uid://24or3s5wdtim
diff --git a/src/utils/ThemeUtils.gd b/src/utils/ThemeUtils.gd
index ee46574f2..bc0aa8dd3 100644
--- a/src/utils/ThemeUtils.gd
+++ b/src/utils/ThemeUtils.gd
@@ -800,30 +800,77 @@ static func _setup_checkbox(theme: Theme) -> void:
theme.set_color("font_hover_color", "CheckBox", highlighted_text_color)
theme.set_color("font_pressed_color", "CheckBox", text_color)
theme.set_color("font_hover_pressed_color", "CheckBox", highlighted_text_color)
+ var checked_colors: Dictionary[String, String] = {
+ base = black_or_white_counter_accent_color.to_html(false),
+ accent = soft_accent_color.to_html(false),
+ }
+ var checked_disabled_colors: Dictionary[String, String] = {
+ base = black_or_white_counter_accent_color.to_html(false),
+ accent = soft_accent_color.lerp(gray_color, 0.2).to_html(false),
+ }
+ var unchecked_colors: Dictionary[String, String] = {
+ base = gray_color.to_html(false),
+ }
+ var unchecked_disabled_colors: Dictionary[String, String] = {
+ base = gray_color.to_html(false),
+ }
theme.set_icon("checked", "CheckBox", DPITexture.create_from_string(
"""""" % [soft_accent_color.to_html(false), black_or_white_counter_accent_color.to_html(false)])
+
+
+ """.format(checked_colors))
)
theme.set_icon("checked_disabled", "CheckBox", DPITexture.create_from_string(
"""""" % [soft_accent_color.lerp(gray_color, 0.2).to_html(false), black_or_white_counter_accent_color.to_html(false)])
+ """.format(checked_disabled_colors))
)
theme.set_icon("unchecked", "CheckBox", DPITexture.create_from_string(
"""""" % gray_color.to_html(false))
+
+ """.format(unchecked_colors))
)
theme.set_icon("unchecked_disabled", "CheckBox", DPITexture.create_from_string(
"""""" % gray_color.to_html(false))
+
+ """.format(unchecked_disabled_colors))
+ )
+
+ theme.set_icon("checked", "PebbleCheckBox", DPITexture.create_from_string(
+ """""".format(checked_colors))
)
+ theme.set_icon("checked_disabled", "PebbleCheckBox", DPITexture.create_from_string(
+ """""".format(checked_disabled_colors))
+ )
+ theme.set_icon("unchecked", "PebbleCheckBox", DPITexture.create_from_string(
+ """""".format(unchecked_colors))
+ )
+ theme.set_icon("unchecked_disabled", "PebbleCheckBox", DPITexture.create_from_string(
+ """""".format(unchecked_disabled_colors))
+ )
+
+ theme.set_type_variation("PebbleCheckBox", "CheckBox")
var checkbox_stylebox := StyleBoxFlat.new()
checkbox_stylebox.set_corner_radius_all(4)
diff --git a/src/utils/Utils.gd b/src/utils/Utils.gd
index b805b415e..3aa1953ea 100644
--- a/src/utils/Utils.gd
+++ b/src/utils/Utils.gd
@@ -207,3 +207,50 @@ static func get_app_info() -> Dictionary[String, Variant]:
i += 1
return result
+
+static func vector2_min_element(vector: Vector2) -> float:
+ return vector[vector.min_axis_index()]
+
+
+static func vector3_min_element(vector: Vector3) -> float:
+ return vector[vector.min_axis_index()]
+
+
+static func vector4_min_element(vector: Vector4) -> float:
+ return vector[vector.min_axis_index()]
+
+
+static func vector2_max_element(vector: Vector2) -> float:
+ return vector[vector.max_axis_index()]
+
+
+static func vector3_max_element(vector: Vector3) -> float:
+ return vector[vector.max_axis_index()]
+
+
+static func vector4_max_element(vector: Vector4) -> float:
+ return vector[vector.max_axis_index()]
+
+
+static func vector2i_min_element(vector: Vector2i) -> int:
+ return vector[vector.min_axis_index()]
+
+
+static func vector3i_min_element(vector: Vector3i) -> int:
+ return vector[vector.min_axis_index()]
+
+
+static func vector4i_min_element(vector: Vector4i) -> int:
+ return vector[vector.min_axis_index()]
+
+
+static func vector2i_max_element(vector: Vector2i) -> int:
+ return vector[vector.max_axis_index()]
+
+
+static func vector3i_max_element(vector: Vector3i) -> int:
+ return vector[vector.max_axis_index()]
+
+
+static func vector4i_max_element(vector: Vector4i) -> int:
+ return vector[vector.max_axis_index()]
diff --git a/src/utils/Utils64Bit.gd b/src/utils/Utils64Bit.gd
index 8a5f86a14..3fa02ffeb 100644
--- a/src/utils/Utils64Bit.gd
+++ b/src/utils/Utils64Bit.gd
@@ -84,3 +84,11 @@ static func vector_project(a: PackedFloat64Array, b: PackedFloat64Array) -> Pack
## 64-bit version of Vector2.dot(Vector2) = float
static func dot(a: PackedFloat64Array, b: PackedFloat64Array) -> float:
return a[0] * b[0] + a[1] * b[1]
+
+
+static func transform_vector_array_mult(transform: PackedFloat64Array, vectors: PackedVector2Array) -> PackedVector2Array:
+ var result: PackedVector2Array
+ result.resize(vectors.size())
+ for i in vectors.size():
+ result[i] = get_vector(transform_vector_mult(transform, [vectors[i].x, vectors[i].y]))
+ return result