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