From a01d72974cccdc03a5014b9bfa4c230f0857726e Mon Sep 17 00:00:00 2001 From: fish Date: Mon, 12 Jan 2026 04:24:14 -0800 Subject: [PATCH 1/8] Add pebble draw command export format --- godlproject | 9 + project.godot | 1 - src/data_classes/AttributeColor.gd | 3 + src/data_classes/PDCImage.gd | 308 +++++++++++++++++++++++++++++ src/data_classes/PDCImage.gd.uid | 1 + src/ui_parts/export_menu.gd | 26 ++- src/ui_parts/export_menu.tscn | 16 +- src/ui_widgets/handles_manager.gd | 210 ++------------------ src/ui_widgets/preview_rect.gd | 13 +- src/utils/ImageExportData.gd | 15 ++ src/utils/SVGPathUtils.gd | 213 ++++++++++++++++++++ src/utils/SVGPathUtils.gd.uid | 1 + src/utils/Utils.gd | 8 +- src/utils/Utils64Bit.gd | 8 + 14 files changed, 623 insertions(+), 209 deletions(-) create mode 100644 godlproject create mode 100644 src/data_classes/PDCImage.gd create mode 100644 src/data_classes/PDCImage.gd.uid create mode 100644 src/utils/SVGPathUtils.gd create mode 100644 src/utils/SVGPathUtils.gd.uid diff --git a/godlproject b/godlproject new file mode 100644 index 000000000..c17f0dbb9 --- /dev/null +++ b/godlproject @@ -0,0 +1,9 @@ +description= +icon=assets/logos/icon.png +name=GodSVG +tags=project + +[version] +isMono=false +repo=/godotengine/godot-builds +tagName=4.5.1-stable 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/AttributeColor.gd b/src/data_classes/AttributeColor.gd index 7f81fb37f..216f6c8fa 100644 --- a/src/data_classes/AttributeColor.gd +++ b/src/data_classes/AttributeColor.gd @@ -4,6 +4,9 @@ class_name AttributeColor extends Attribute # No direct color representation for this attribute type. There are too many quirks. func set_value(new_value: String) -> void: + print(new_value, ": ", ColorParser.is_valid(new_value, false, + name in DB.COLOR_ATTRIBUTES_WITH_URL_ALLOWED, name in DB.COLOR_ATTRIBUTES_WITH_NONE_ALLOWED, + name in DB.COLOR_ATTRIBUTES_WITH_CURRENT_COLOR_ALLOWED)) super(new_value if ColorParser.is_valid(new_value, false, name in DB.COLOR_ATTRIBUTES_WITH_URL_ALLOWED, name in DB.COLOR_ATTRIBUTES_WITH_NONE_ALLOWED, name in DB.COLOR_ATTRIBUTES_WITH_CURRENT_COLOR_ALLOWED) else "") diff --git a/src/data_classes/PDCImage.gd b/src/data_classes/PDCImage.gd new file mode 100644 index 000000000..9fe7cd151 --- /dev/null +++ b/src/data_classes/PDCImage.gd @@ -0,0 +1,308 @@ +class_name PDCImage + +## Don't change +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, +} + +var size: Vector2i +var curve_tolerance: float +var precise_path_mode: PrecisePathMode + +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(6) + 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) + + for command in draw_commands: + buffer.append_array(command.encode()) + return buffer + + +func add_command(command: PebbleCommand) -> void: + draw_commands.append(command) + + +@abstract class PebbleCommand: + var stroke_color: int + var stroke_width: int + 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, stroke_width) + buffer.encode_u8(4, fill_color) + buffer.encode_u16(5, get_path_open_or_radius()) + var points := get_points() + buffer.encode_u16(7, points.size()) + buffer.append_array(encode_points(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) + return element + + +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 path := _setup_svg_element(ElementPath.new()) + var commands: Array[PathCommand] + commands.append(PathCommand.MoveCommand.new(points[0].x, points[0].y)) + for point_index in range(1, points.size()): + var point := points[point_index] + commands.append( + PathCommand.LineCommand.new(int(point.x), int(point.y)) + ) + if not is_path_open: + commands.append(PathCommand.CloseCommand.new()) + path.set_attribute("d", commands) + return [path] + + +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] + + +class PebblePrecisePathCommand extends PebblePathCommand: + func get_type() -> DrawType: return DrawType.PRECISE_PATH + func encode_point(value: float) -> int: + return int(value * (1 << 3)) + func to_svg() -> Array[Element]: + var path := _setup_svg_element(ElementPath.new()) + var commands: Array[PathCommand] + commands.append(PathCommand.MoveCommand.new(points[0].x, points[0].y)) + for point_index in range(1, points.size()): + var point := points[point_index] + commands.append( + PathCommand.LineCommand.new(floorf(point.x * (1 << 3)) / (1 << 3), floorf(point.y * (1 << 3)) / (1 << 3)) + ) + if not is_path_open: + commands.append(PathCommand.CloseCommand.new()) + path.set_attribute("d", commands) + return [path] + + +func load_from_svg(svg: ElementRoot) -> void: + size.x = int(svg.get_attribute_num("width")) + size.y = int(svg.get_attribute_num("height")) + for element in svg.get_all_valid_element_descendants(): + match element.name: + "circle": + var cmd := _setup_cmd(element, PebbleCircleCommand.new()) + cmd.center = Vector2(element.get_attribute_num("cx"), element.get_attribute_num("cy")) + cmd.radius = int(element.get_attribute_num("r")) + add_command(cmd) + "ellipse": + var cmd := _setup_cmd(element, PebbleCircleCommand.new()) + cmd.center = Vector2(element.get_attribute_num("cx"), element.get_attribute_num("cy")) + cmd.radius = int(minf(element.get_attribute_num("rx"), 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 := _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 polylines: Array[PackedVector2Array] + var multilines := PackedVector2Array() + SVGPathUtils.get_path_element_points(element, polylines, multilines, curve_tolerance) + for points in polylines: + 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) + for multiline_index in multilines.size() / 2: + var points: PackedVector2Array = [ + multilines[multiline_index * 2], + multilines[multiline_index * 2 + 1], + ] + 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) + + +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() + +static 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 + | int(color.r * 3.0) << 0 + | int(color.g * 3.0) << 2 + | int(color.b * 3.0) << 4 + | int(color.a * 3.0) << 6 + ) + + +static func from_pebble_color(color: int) -> Color: + return Color( + ((color >> 0) & 0b11) / 3.0, + ((color >> 2) & 0b11) / 3.0, + ((color >> 4) & 0b11) / 3.0, + ((color >> 6) & 0b11) / 3.0, + ) 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..ff2c55ee8 100644 --- a/src/ui_parts/export_menu.gd +++ b/src/ui_parts/export_menu.gd @@ -21,6 +21,7 @@ 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 quality_edit: NumberEdit = %Quality @onready var quality_hbox: HBoxContainer = %QualityHBox @onready var cancel_button: Button = %ButtonContainer/CancelButton @@ -46,6 +47,7 @@ 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) + 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,7 +72,6 @@ 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") @@ -121,6 +122,13 @@ 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_quality_value_changed(new_value: float) -> void: var current_quality := export_data.quality undo_redo.create_action() @@ -157,12 +165,11 @@ func _dimension_component_change_common_logic(component_index: int, new_value: f # Everything gets updated at once when export config changes for simplicity. func update() -> void: # Determine which fields are visible. - quality_related_container.visible = export_data.format in ["jpg", "jpeg", "webp"] + quality_related_container.visible = export_data.format in ["jpg", "jpeg", "webp", "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") + precise_path_mode_dropdown.visible = export_data.format in ["pdc"] var file_name := Utils.get_file_name(Configs.savedata.get_active_tab().svg_file_path) if not file_name.is_empty(): @@ -181,7 +188,18 @@ 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) + + final_size_label.visible = export_data.format in ["svg", "pdc"] + var export_size: int + match export_data.format: + "svg": + export_size = State.get_export_text().length() + "pdc": + export_size = texture_preview.last_image_size + final_size_label.text = Translator.translate("Size") + ": " + String.humanize_size(export_size) + 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) diff --git a/src/ui_parts/export_menu.tscn b/src/ui_parts/export_menu.tscn index 7a3b5065d..753619700 100644 --- a/src/ui_parts/export_menu.tscn +++ b/src/ui_parts/export_menu.tscn @@ -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", @@ -147,7 +148,18 @@ unique_name_in_owner = true 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="PrecisePathModeDropdown" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" instance=ExtResource("5_y6ex0")] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +values = [0, 1, 2] +value_text_map = { +0: "Autodetect", +1: "Only precise paths", +2: "Only imprecise paths" +} + +[node name="QualityRelatedContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 theme_override_constants/separation = 12 diff --git a/src/ui_widgets/handles_manager.gd b/src/ui_widgets/handles_manager.gd index 1d68de81f..11debcdbd 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, + 1.0, + 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..8f562ca4a 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): @@ -30,12 +33,20 @@ func setup_image(config: ImageExportData) -> void: 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()) 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.precise_path_mode = config.precise_path_mode + # Convert the SVG to PDCImage then back to SVG for accurate display + pdc.load_from_svg(SVGParser.markup_to_root(buffer.get_string_from_utf8()).svg) + image.load_svg_from_string(pdc.to_svg()) + print(pdc.to_svg()) + last_image_size = pdc.encode().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/ImageExportData.gd b/src/utils/ImageExportData.gd index 039e99f23..93c7fc75a 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,10 +35,24 @@ var lossy := false: lossy = new_value changed.emit() +var tesselation_tolerance_degrees := 4: + set(new_value): + if new_value != tesselation_tolerance_degrees: + tesselation_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() diff --git a/src/utils/SVGPathUtils.gd b/src/utils/SVGPathUtils.gd new file mode 100644 index 000000000..b0ae5caca --- /dev/null +++ b/src/utils/SVGPathUtils.gd @@ -0,0 +1,213 @@ +class_name SVGPathUtils + +@warning_ignore("unused_parameter") +static func get_path_element_points( + element: Element, + normal_polylines: Array[PackedVector2Array], + normal_multiline: PackedVector2Array = [], + curve_tolerance_degrees: float = 1.0, + 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 = Utils.get_cubic_bezier_points(cp1, cp2, cp3, cp4, curve_tolerance_degrees) + 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, curve_tolerance_degrees) + 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, curve_tolerance_degrees) + 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, curve_tolerance_degrees) + 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], curve_tolerance_degrees) + "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 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/Utils.gd b/src/utils/Utils.gd index b805b415e..7f9f195ee 100644 --- a/src/utils/Utils.gd +++ b/src/utils/Utils.gd @@ -81,14 +81,14 @@ static func rotate_flat_stylebox_90_left(stylebox: StyleBoxFlat) -> void: stylebox.expand_margin_bottom = temp4 -static func get_cubic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2, cp4: Vector2) -> PackedVector2Array: +static func get_cubic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2, cp4: Vector2, tolerance_degrees := 1.0) -> PackedVector2Array: var curve := Curve2D.new() curve.add_point(cp1, Vector2(), cp2) curve.add_point(cp4, cp3) - return curve.tessellate(6, 1) + return curve.tessellate(6, tolerance_degrees) -static func get_quadratic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2) -> PackedVector2Array: - return Utils.get_cubic_bezier_points(cp1, 2/3.0 * (cp2 - cp1), 2/3.0 * (cp2 - cp3), cp3) +static func get_quadratic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2, tolerance_degrees := 1.0) -> PackedVector2Array: + return Utils.get_cubic_bezier_points(cp1, 2/3.0 * (cp2 - cp1), 2/3.0 * (cp2 - cp3), cp3, tolerance_degrees) # Calculate quadratic bezier point coordinate along an axis. static func quadratic_bezier_point(p0: float, p1: float, p2: float, t: float) -> float: 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 From cd0f139cdb70fdcb0e7521a0b2457c7bc1e62658 Mon Sep 17 00:00:00 2001 From: fish Date: Tue, 13 Jan 2026 02:48:23 -0800 Subject: [PATCH 2/8] PDC export working fully, it no longer rebinds the tab to the exported PDC filepath, the preview is generated by parsing the PDC buffer so it should be extra accurate, more quality controls --- assets/icons/Pebble.svg | 1 + assets/icons/Pebble.svg.import | 43 ++++ assets/icons/PebbleChecked.svg | 1 + assets/icons/PebbleChecked.svg.import | 43 ++++ src/config_classes/SaveData.gd | 14 ++ src/data_classes/AttributeColor.gd | 3 - src/data_classes/PDCImage.gd | 346 ++++++++++++++++++++++---- src/ui_parts/export_menu.gd | 22 +- src/ui_parts/export_menu.tscn | 35 ++- src/ui_parts/previews.gd | 39 ++- src/ui_parts/previews.tscn | 20 ++ src/ui_widgets/handles_manager.gd | 2 +- src/ui_widgets/preview_rect.gd | 10 +- src/utils/FileUtils.gd | 3 + src/utils/ImageExportData.gd | 8 +- src/utils/SVGPathUtils.gd | 184 ++++++++------ src/utils/ThemeUtils.gd | 67 ++++- src/utils/Utils.gd | 55 +++- 18 files changed, 745 insertions(+), 151 deletions(-) create mode 100644 assets/icons/Pebble.svg create mode 100644 assets/icons/Pebble.svg.import create mode 100644 assets/icons/PebbleChecked.svg create mode 100644 assets/icons/PebbleChecked.svg.import 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..77290fd1a --- /dev/null +++ b/assets/icons/Pebble.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvsovsw5gwmma" +path="res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/Pebble.svg" +dest_files=["res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false 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..8909a36d0 --- /dev/null +++ b/assets/icons/PebbleChecked.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8y5cq15l01p4" +path="res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/PebbleChecked.svg" +dest_files=["res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/src/config_classes/SaveData.gd b/src/config_classes/SaveData.gd index dec880113..40e10f17a 100644 --- a/src/config_classes/SaveData.gd +++ b/src/config_classes/SaveData.gd @@ -1191,6 +1191,20 @@ const DEFAULT_PREVIEW_SIZES: PackedInt32Array = [16, 24, 32, 48, 64] emit_changed() +@export var previews_use_pebble_preview := false: + set(new_value): + if previews_use_pebble_preview != new_value: + previews_use_pebble_preview = new_value + emit_changed() + + +@export var previews_precise_path_mode := PDCImage.PrecisePathMode.AUTODETECT: + set(new_value): + if previews_precise_path_mode != new_value: + previews_precise_path_mode = new_value + emit_changed() + + # Utility func get_validity_color(error_condition: bool, warning_condition := false) -> Color: diff --git a/src/data_classes/AttributeColor.gd b/src/data_classes/AttributeColor.gd index 216f6c8fa..7f81fb37f 100644 --- a/src/data_classes/AttributeColor.gd +++ b/src/data_classes/AttributeColor.gd @@ -4,9 +4,6 @@ class_name AttributeColor extends Attribute # No direct color representation for this attribute type. There are too many quirks. func set_value(new_value: String) -> void: - print(new_value, ": ", ColorParser.is_valid(new_value, false, - name in DB.COLOR_ATTRIBUTES_WITH_URL_ALLOWED, name in DB.COLOR_ATTRIBUTES_WITH_NONE_ALLOWED, - name in DB.COLOR_ATTRIBUTES_WITH_CURRENT_COLOR_ALLOWED)) super(new_value if ColorParser.is_valid(new_value, false, name in DB.COLOR_ATTRIBUTES_WITH_URL_ALLOWED, name in DB.COLOR_ATTRIBUTES_WITH_NONE_ALLOWED, name in DB.COLOR_ATTRIBUTES_WITH_CURRENT_COLOR_ALLOWED) else "") diff --git a/src/data_classes/PDCImage.gd b/src/data_classes/PDCImage.gd index 9fe7cd151..06a1b11f6 100644 --- a/src/data_classes/PDCImage.gd +++ b/src/data_classes/PDCImage.gd @@ -22,9 +22,16 @@ enum PrecisePathMode { ONLY_IMPRECISE_PATHS, } +enum PDCLoadingError { + OK, + UNKNOWN, + INVALID_MAGIC_WORD, +} + var size: Vector2i -var curve_tolerance: float +var path_angle_tolerance: float = 10.0 var precise_path_mode: PrecisePathMode +var viewbox_transform: Transform2D var draw_commands: Array[PebbleCommand] @@ -49,7 +56,7 @@ func encode() -> PackedByteArray: func encode_image() -> PackedByteArray: var buffer: PackedByteArray - buffer.resize(6) + buffer.resize(8) buffer.encode_u8(0, DRAW_COMMAND_VERSION) # Must be 0 for some reason buffer.encode_u8(1, 0) @@ -57,18 +64,65 @@ func encode_image() -> PackedByteArray: 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 + # 4 chars * 1 byte / char = 32 bits + if sp.get_u32() != "PDCI".to_ascii_buffer().decode_u32(0): + 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: int + var stroke_width: float var fill_color: int var flags: DrawFlags @@ -82,12 +136,12 @@ func add_command(command: PebbleCommand) -> void: # Command stroke color buffer.encode_u8(2, stroke_color) # Command stroke width (unsigned byte) - buffer.encode_u8(3, stroke_width) + buffer.encode_u8(3, int(stroke_width)) buffer.encode_u8(4, fill_color) buffer.encode_u16(5, get_path_open_or_radius()) - var points := get_points() - buffer.encode_u16(7, points.size()) - buffer.append_array(encode_points(points)) + 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 @@ -109,6 +163,7 @@ func add_command(command: PebbleCommand) -> void: 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) @@ -119,7 +174,11 @@ func add_command(command: PebbleCommand) -> void: 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: @@ -129,18 +188,61 @@ class PebblePathCommand extends PebbleCommand: 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(points[0].x, points[0].y)) - for point_index in range(1, points.size()): - var point := points[point_index] + 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(int(point.x), int(point.y)) + 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: @@ -155,30 +257,42 @@ class PebbleCircleCommand extends PebbleCommand: 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 to_svg() -> Array[Element]: - var path := _setup_svg_element(ElementPath.new()) - var commands: Array[PathCommand] - commands.append(PathCommand.MoveCommand.new(points[0].x, points[0].y)) - for point_index in range(1, points.size()): - var point := points[point_index] - commands.append( - PathCommand.LineCommand.new(floorf(point.x * (1 << 3)) / (1 << 3), floorf(point.y * (1 << 3)) / (1 << 3)) - ) - 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 * (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: - size.x = int(svg.get_attribute_num("width")) - size.y = int(svg.get_attribute_num("height")) + 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) + path_generator.angle_tolerance = path_angle_tolerance + else: + viewbox_transform = Transform2D.IDENTITY for element in svg.get_all_valid_element_descendants(): match element.name: "circle": @@ -234,31 +348,153 @@ func load_from_svg(svg: ElementRoot) -> void: element.get_precise_transform(), points ) - var cmd := _setup_cmd(element, (PebblePrecisePathCommand if requires_precise_path(points) else PebblePathCommand).new()) + 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 polylines: Array[PackedVector2Array] - var multilines := PackedVector2Array() - SVGPathUtils.get_path_element_points(element, polylines, multilines, curve_tolerance) - for points in polylines: - 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) - for multiline_index in multilines.size() / 2: - var points: PackedVector2Array = [ - multilines[multiline_index * 2], - multilines[multiline_index * 2 + 1], - ] - 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) + 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] == 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) @@ -271,7 +507,7 @@ func to_svg() -> String: static var _path := PebblePathCommand.new() -static func _setup_cmd(element: Element, cmd: PebbleCommand) -> PebbleCommand: +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")) @@ -306,3 +542,23 @@ static func from_pebble_color(color: int) -> Color: ((color >> 4) & 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/ui_parts/export_menu.gd b/src/ui_parts/export_menu.gd index ff2c55ee8..ff476ddd8 100644 --- a/src/ui_parts/export_menu.gd +++ b/src/ui_parts/export_menu.gd @@ -22,6 +22,8 @@ var dimensions := Vector2.ZERO @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: GridContainer = %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 @@ -47,6 +49,7 @@ 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) @@ -75,6 +78,10 @@ func _ready() -> void: %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("Tesselation quality") + ":" + %PDCPathQualityContainer/HBoxContainer/SuffixLabel.text = Translator.translate("degrees") + path_quality_edit.tooltip_text = Translator.translate("The angular tolerance at which to tesselate paths.") %QualityHBox/Label.text = Translator.translate("Quality") + ":" %ScaleContainer/Label.text = Translator.translate("Scale") %WidthContainer/Label.text = Translator.translate("Width") + ":" @@ -129,6 +136,13 @@ func _on_precise_path_mode_dropdown_toggled(precise_path_mode: PDCImage.PreciseP 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.tesselation_tolerance_degrees + undo_redo.create_action() + undo_redo.add_do_property(export_data, "tesselation_tolerance_degrees", new_value) + undo_redo.add_undo_property(export_data, "tesselation_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() @@ -165,11 +179,11 @@ func _dimension_component_change_common_logic(component_index: int, new_value: f # Everything gets updated at once when export config changes for simplicity. func update() -> void: # Determine which fields are visible. - quality_related_container.visible = export_data.format in ["jpg", "jpeg", "webp", "pdc"] + 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"] - precise_path_mode_dropdown.visible = export_data.format in ["pdc"] var file_name := Utils.get_file_name(Configs.savedata.get_active_tab().svg_file_path) if not file_name.is_empty(): @@ -189,7 +203,7 @@ func update() -> void: quality_edit.set_value(export_data.quality * 100, false) lossless_checkbox.set_pressed_no_signal(not export_data.lossy) - final_size_label.visible = export_data.format in ["svg", "pdc"] + #final_size_label.visible = export_data.format in ["svg", "pdc"] var export_size: int match export_data.format: "svg": @@ -201,7 +215,7 @@ func update() -> void: 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\ + info_tooltip.visible = (export_data.format != "svg" and roundi(export_data.upscale_amount * maxf(dimensions.x, dimensions.y)) > texture_preview.MAX_IMAGE_DIMENSION) 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 753619700..c9b152514 100644 --- a/src/ui_parts/export_menu.tscn +++ b/src/ui_parts/export_menu.tscn @@ -143,22 +143,42 @@ 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="PreciseModeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer"] +layout_mode = 2 theme_override_font_sizes/font_size = 12 -[node name="PrecisePathModeDropdown" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer" instance=ExtResource("5_y6ex0")] +[node name="PrecisePathModeDropdown" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" instance=ExtResource("5_y6ex0")] unique_name_in_owner = true +custom_minimum_size = Vector2(96, 22) layout_mode = 2 -size_flags_horizontal = 3 values = [0, 1, 2] value_text_map = { 0: "Autodetect", -1: "Only precise paths", -2: "Only imprecise paths" +1: "Always", +2: "Never" } +[node name="QualityLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 + +[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer"] +layout_mode = 2 + +[node name="PathQualityNumberEdit" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer/HBoxContainer" 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"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 + [node name="QualityRelatedContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 @@ -260,6 +280,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_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..ef8fa42a9 100644 --- a/src/ui_parts/previews.gd +++ b/src/ui_parts/previews.gd @@ -28,6 +28,8 @@ const MAX_ICON_PREVIEW_SIZE = 128 @onready var preview_top_panel: PanelContainer = $SplitContainer/PreviewTopPanel @onready var more_button: Button = $ActionContainer/MoreButton @onready var size_label_margins: MarginContainer = %SizeLabelMargins +@onready var precise_path_mode_dropdown: Control = %PrecisePathModeDropdown +@onready var precise_path_mode_container: HBoxContainer = %PrecisePathModeContainer class IconPreviewTileData extends RefCounted: var index := -1 @@ -38,7 +40,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 @@ -66,7 +68,16 @@ class IconPreviewTileData extends RefCounted: label_rect = Rect2(Vector2(TILE_TOP_PADDING + 1, TILE_LEFT_PADDING + preview_size.y + ICON_TEXT_SPACING), label_size - Vector2(1, 0)) more_button_rect = Rect2(Vector2(TILE_TOP_PADDING + label_size.x, label_rect.position.y + 2), Vector2(MORE_ICON_SIZE, MORE_ICON_SIZE)) - preview_texture = DPITexture.create_from_string(State.stable_export_markup, multiplier) + if Configs.savedata.previews_use_pebble_preview: + var svg := SVGParser.markup_to_root(State.stable_export_markup).svg + if svg != null: + var pdc := PDCImage.new() + pdc.precise_path_mode = Configs.savedata.previews_precise_path_mode + # Convert the SVG to PDCImage then back to SVG for accurate display + pdc.load_from_svg(svg) + preview_texture = DPITexture.create_from_string(pdc.to_svg(), multiplier) + else: + preview_texture = DPITexture.create_from_string(State.stable_export_markup, multiplier) var tiles: Array[IconPreviewTileData] = [] var hovered_tile_index := -1 @@ -75,6 +86,7 @@ var edited_tile_index := -1 var edit_field: NumberEdit func _ready() -> void: + %PrecisePathModeContainer/Label.text = Translator.translate("Precise") + ":" icon_preview_tiles.draw.connect(_on_preview_tiles_draw) icon_preview_tiles.gui_input.connect(_on_tiles_gui_input) icon_preview_tiles.mouse_exited.connect(_on_tiles_mouse_exited) @@ -109,6 +121,15 @@ func _ready() -> void: sync_tile_positions() ) icon_preview_tiles.resized.connect(sync_tile_positions) + + if Configs.savedata.previews_use_pebble_preview: + _toggle_use_pebble_preview() + _toggle_use_pebble_preview() + else: + _toggle_use_pebble_preview() + precise_path_mode_dropdown.set_value(Configs.savedata.previews_precise_path_mode, 0) + precise_path_mode_dropdown.value_changed.connect(_on_precise_path_mode_dropdown_value_changed) + sync_tiles() HandlerGUI.register_focus_sequence(self, [add_new_preview_button, transparent_color_swatch, black_color_swatch, white_color_swatch, color_edit, more_button]) @@ -345,7 +366,16 @@ func _update_preview_background(new_value: String) -> void: scaled_preview_panel.add_theme_stylebox_override("panel", colored_sb) +func _toggle_use_pebble_preview() -> void: + Configs.savedata.previews_use_pebble_preview = not Configs.savedata.previews_use_pebble_preview + precise_path_mode_container.visible = Configs.savedata.previews_use_pebble_preview + sync_tiles() + + func _on_more_button_pressed() -> void: + # var checkbox := ContextPopup.create_checkbox(Translator.translate("Preview as PDC"), + # _toggle_use_pebble_preview, Configs.savedata.previews_use_pebble_preview, false) + # checkbox.theme_type_variation = "PebbleCheckBox" var btn_array: Array[ContextButton] = [ ContextButton.create_custom(Translator.translate("Reset to default"), reset_tiles, preload("res://assets/icons/Reload.svg"), are_tiles_default()), @@ -353,5 +383,10 @@ func _on_more_button_pressed() -> void: preload("res://assets/icons/Clear.svg"), Configs.savedata.preview_sizes.is_empty()), ContextButton.create_custom(Translator.translate("Sort"), sort_tiles, preload("res://assets/icons/Sort.svg"), are_tiles_sorted()), + # checkbox, ] HandlerGUI.popup_under_rect_center(ContextPopup.create(btn_array), more_button.get_global_rect(), get_viewport()) + +func _on_precise_path_mode_dropdown_value_changed(new_precise_path_mode: PDCImage.PrecisePathMode) -> void: + Configs.savedata.previews_precise_path_mode = new_precise_path_mode + sync_tiles() diff --git a/src/ui_parts/previews.tscn b/src/ui_parts/previews.tscn index 07b04d223..487f79a71 100644 --- a/src/ui_parts/previews.tscn +++ b/src/ui_parts/previews.tscn @@ -4,6 +4,7 @@ [ext_resource type="Texture2D" uid="uid://eif2ioi0mw17" path="res://assets/icons/Plus.svg" id="2_pm7dr"] [ext_resource type="PackedScene" uid="uid://bujllg1bqlub6" path="res://src/ui_widgets/color_swatch.tscn" id="3_auodr"] [ext_resource type="PackedScene" uid="uid://5f8uxavn1or1" path="res://src/ui_widgets/color_edit.tscn" id="4_mkhk3"] +[ext_resource type="PackedScene" uid="uid://bpr5yv7pbv13g" path="res://src/ui_widgets/dropdown_basic.tscn" id="5_pm7dr"] [ext_resource type="Texture2D" uid="uid://ccbta5q43jobk" path="res://assets/icons/More.svg" id="5_ue2r5"] [ext_resource type="Script" uid="uid://bniafq6ma1iln" path="res://src/ui_widgets/ProceduralControl.gd" id="6_6v74c"] @@ -58,6 +59,25 @@ layout_mode = 2 size_flags_vertical = 4 enable_alpha = true +[node name="PrecisePathModeContainer" type="HBoxContainer" parent="ActionContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 + +[node name="Label" type="Label" parent="ActionContainer/PrecisePathModeContainer"] +layout_mode = 2 + +[node name="PrecisePathModeDropdown" parent="ActionContainer/PrecisePathModeContainer" instance=ExtResource("5_pm7dr")] +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="MoreButton" type="Button" parent="ActionContainer" unique_id=1528240669] layout_mode = 2 mouse_default_cursor_shape = 2 diff --git a/src/ui_widgets/handles_manager.gd b/src/ui_widgets/handles_manager.gd index 11debcdbd..54ad4cbc1 100644 --- a/src/ui_widgets/handles_manager.gd +++ b/src/ui_widgets/handles_manager.gd @@ -462,7 +462,7 @@ func _draw() -> void: element, normal_polylines, normal_multiline, - 1.0, + SVGPathUtils.default_path_generator, hovered_polylines, hovered_multiline, selected_polylines, diff --git a/src/ui_widgets/preview_rect.gd b/src/ui_widgets/preview_rect.gd index 8f562ca4a..c4050e11c 100644 --- a/src/ui_widgets/preview_rect.gd +++ b/src/ui_widgets/preview_rect.gd @@ -30,23 +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.tesselation_tolerance_degrees = config.tesselation_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 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.precise_path_mode = config.precise_path_mode - # Convert the SVG to PDCImage then back to SVG for accurate display - pdc.load_from_svg(SVGParser.markup_to_root(buffer.get_string_from_utf8()).svg) + pdc.load_from_pdc(buffer) image.load_svg_from_string(pdc.to_svg()) - print(pdc.to_svg()) - last_image_size = pdc.encode().size() + 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 93c7fc75a..48ec2244a 100644 --- a/src/utils/ImageExportData.gd +++ b/src/utils/ImageExportData.gd @@ -35,7 +35,7 @@ var lossy := false: lossy = new_value changed.emit() -var tesselation_tolerance_degrees := 4: +var tesselation_tolerance_degrees := 10.0: set(new_value): if new_value != tesselation_tolerance_degrees: tesselation_tolerance_degrees = new_value @@ -58,6 +58,12 @@ func image_to_buffer(image: Image) -> PackedByteArray: "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 = tesselation_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 index b0ae5caca..2067b185c 100644 --- a/src/utils/SVGPathUtils.gd +++ b/src/utils/SVGPathUtils.gd @@ -1,11 +1,12 @@ 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 = [], - curve_tolerance_degrees: float = 1.0, + path_generator: PathGenerator = default_path_generator, hovered_polylines: Array[PackedVector2Array] = [], hovered_multiline: PackedVector2Array = [], selected_polylines: Array[PackedVector2Array] = [], @@ -62,7 +63,7 @@ static func get_path_element_points( var cp2 := v1 if relative else v1 - cp1 var cp3 := v2 - v - points = Utils.get_cubic_bezier_points(cp1, cp2, cp3, cp4, curve_tolerance_degrees) + 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. @@ -78,7 +79,7 @@ static func get_path_element_points( var cp2 := v1 if relative else v1 - cp1 var cp3 := v2 - v - points = Utils.get_cubic_bezier_points(cp1, cp2, cp3, cp4, curve_tolerance_degrees) + 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. @@ -88,7 +89,7 @@ static func get_path_element_points( 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, curve_tolerance_degrees) + points = path_generator.generate_quadratic(cp1, cp2, cp3) tangent_points.append_array(PackedVector2Array([cp1, cp2, cp2, cp3])) "T": # Shorthand quadratic Bezier curve contour. @@ -102,79 +103,22 @@ static func get_path_element_points( if is_nan(cp2.x) and is_nan(cp2.y): points = PackedVector2Array([cp1, cp3]) else: - points = Utils.get_quadratic_bezier_points(cp1, cp2, cp3, curve_tolerance_degrees) + 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 - # 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], curve_tolerance_degrees) + 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 @@ -211,3 +155,101 @@ static func get_path_element_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/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 7f9f195ee..3aa1953ea 100644 --- a/src/utils/Utils.gd +++ b/src/utils/Utils.gd @@ -81,14 +81,14 @@ static func rotate_flat_stylebox_90_left(stylebox: StyleBoxFlat) -> void: stylebox.expand_margin_bottom = temp4 -static func get_cubic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2, cp4: Vector2, tolerance_degrees := 1.0) -> PackedVector2Array: +static func get_cubic_bezier_points(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 curve.tessellate(6, tolerance_degrees) + return curve.tessellate(6, 1) -static func get_quadratic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2, tolerance_degrees := 1.0) -> PackedVector2Array: - return Utils.get_cubic_bezier_points(cp1, 2/3.0 * (cp2 - cp1), 2/3.0 * (cp2 - cp3), cp3, tolerance_degrees) +static func get_quadratic_bezier_points(cp1: Vector2, cp2: Vector2, cp3: Vector2) -> PackedVector2Array: + return Utils.get_cubic_bezier_points(cp1, 2/3.0 * (cp2 - cp1), 2/3.0 * (cp2 - cp3), cp3) # Calculate quadratic bezier point coordinate along an axis. static func quadratic_bezier_point(p0: float, p1: float, p2: float, t: float) -> float: @@ -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()] From 46a9f70f6f1f5c9f5205c5854bbabdaa85352bc0 Mon Sep 17 00:00:00 2001 From: fish Date: Tue, 27 Jan 2026 21:12:15 -0800 Subject: [PATCH 3/8] Delete godlproject file --- godlproject | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 godlproject diff --git a/godlproject b/godlproject deleted file mode 100644 index c17f0dbb9..000000000 --- a/godlproject +++ /dev/null @@ -1,9 +0,0 @@ -description= -icon=assets/logos/icon.png -name=GodSVG -tags=project - -[version] -isMono=false -repo=/godotengine/godot-builds -tagName=4.5.1-stable From e7d6e25bdbd986e8c07d956a202f1a9132e0c50e Mon Sep 17 00:00:00 2001 From: fish Date: Tue, 27 Jan 2026 21:20:48 -0800 Subject: [PATCH 4/8] round-trip the previews so they're accurate too --- src/ui_parts/export_menu.tscn | 16 ++++++++-------- src/ui_parts/previews.gd | 5 ++++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ui_parts/export_menu.tscn b/src/ui_parts/export_menu.tscn index c9b152514..3cdca00c7 100644 --- a/src/ui_parts/export_menu.tscn +++ b/src/ui_parts/export_menu.tscn @@ -148,11 +148,11 @@ unique_name_in_owner = true layout_mode = 2 columns = 2 -[node name="PreciseModeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer"] +[node name="PreciseModeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1100694615] layout_mode = 2 theme_override_font_sizes/font_size = 12 -[node name="PrecisePathModeDropdown" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" instance=ExtResource("5_y6ex0")] +[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 @@ -163,23 +163,23 @@ value_text_map = { 2: "Never" } -[node name="QualityLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer"] +[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="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer"] +[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" instance=ExtResource("6_w1sag")] +[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"] +[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="QualityRelatedContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer"] +[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 @@ -280,7 +280,7 @@ min_value = 1.0 max_value = 16384.0 is_float = false -[node name="FinalSizeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer"] +[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 diff --git a/src/ui_parts/previews.gd b/src/ui_parts/previews.gd index ef8fa42a9..25c0057b7 100644 --- a/src/ui_parts/previews.gd +++ b/src/ui_parts/previews.gd @@ -75,7 +75,10 @@ class IconPreviewTileData extends RefCounted: pdc.precise_path_mode = Configs.savedata.previews_precise_path_mode # Convert the SVG to PDCImage then back to SVG for accurate display pdc.load_from_svg(svg) - preview_texture = DPITexture.create_from_string(pdc.to_svg(), multiplier) + var buffer := pdc.encode() + var decoded_pdc := PDCImage.new() + decoded_pdc.load_from_pdc(buffer) + preview_texture = DPITexture.create_from_string(decoded_pdc.to_svg(), multiplier) else: preview_texture = DPITexture.create_from_string(State.stable_export_markup, multiplier) From 84d65075ae68d6f6ce1a25aab4b9d3bfc73d58a0 Mon Sep 17 00:00:00 2001 From: fish Date: Thu, 5 Feb 2026 22:38:38 -0800 Subject: [PATCH 5/8] Add documentation to PDCImage --- src/data_classes/PDCImage.gd | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/data_classes/PDCImage.gd b/src/data_classes/PDCImage.gd index 06a1b11f6..036ff93bb 100644 --- a/src/data_classes/PDCImage.gd +++ b/src/data_classes/PDCImage.gd @@ -1,6 +1,17 @@ +## 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 +## 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 { @@ -46,7 +57,7 @@ func encode() -> PackedByteArray: var size_byte_offset = buffer.size() buffer.resize(8) - var encoded_commands := encode_image() + var encoded_commands := _encode_image() buffer.append_array(encoded_commands) buffer.encode_u32(size_byte_offset, encoded_commands.size()) @@ -54,7 +65,7 @@ func encode() -> PackedByteArray: return buffer -func encode_image() -> PackedByteArray: +func _encode_image() -> PackedByteArray: var buffer: PackedByteArray buffer.resize(8) buffer.encode_u8(0, DRAW_COMMAND_VERSION) From 5cca1e3636509316c8b5d060754ef0b5f4ef8071 Mon Sep 17 00:00:00 2001 From: fish Date: Sun, 8 Feb 2026 23:33:49 -0800 Subject: [PATCH 6/8] Fix pebble preview dropdown menu item for new menu system, fix gdbuild --- .github/disabled_classes.gdbuild | 1 - src/ui_parts/previews.gd | 9 +++++---- src/ui_widgets/ContextButton.gd | 31 ++++++++++++++++++++++++++++++- src/ui_widgets/ContextPopup.gd | 8 ++++---- 4 files changed, 39 insertions(+), 10 deletions(-) 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/src/ui_parts/previews.gd b/src/ui_parts/previews.gd index 25c0057b7..2ecff4daf 100644 --- a/src/ui_parts/previews.gd +++ b/src/ui_parts/previews.gd @@ -376,9 +376,10 @@ func _toggle_use_pebble_preview() -> void: func _on_more_button_pressed() -> void: - # var checkbox := ContextPopup.create_checkbox(Translator.translate("Preview as PDC"), - # _toggle_use_pebble_preview, Configs.savedata.previews_use_pebble_preview, false) - # checkbox.theme_type_variation = "PebbleCheckBox" + var pebble_preview_checkbox := ContextButton.create_custom_checkbox(Translator.translate("Preview as PDC"), + _toggle_use_pebble_preview, Configs.savedata.previews_use_pebble_preview) + pebble_preview_checkbox.auto_toggle = true + pebble_preview_checkbox.theme_type = "PebbleCheckBox" var btn_array: Array[ContextButton] = [ ContextButton.create_custom(Translator.translate("Reset to default"), reset_tiles, preload("res://assets/icons/Reload.svg"), are_tiles_default()), @@ -386,7 +387,7 @@ func _on_more_button_pressed() -> void: preload("res://assets/icons/Clear.svg"), Configs.savedata.preview_sizes.is_empty()), ContextButton.create_custom(Translator.translate("Sort"), sort_tiles, preload("res://assets/icons/Sort.svg"), are_tiles_sorted()), - # checkbox, + pebble_preview_checkbox, ] HandlerGUI.popup_under_rect_center(ContextPopup.create(btn_array), more_button.get_global_rect(), get_viewport()) 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() From 4ab332332d2c8dc98e0a095cca91729edd4fb7fd Mon Sep 17 00:00:00 2001 From: fish Date: Wed, 18 Feb 2026 01:34:24 -0800 Subject: [PATCH 7/8] pdc: fix bugs --- src/config_classes/SaveData.gd | 14 ------------ src/data_classes/PDCImage.gd | 20 ++++++++--------- src/ui_parts/export_menu.gd | 2 +- src/ui_parts/previews.gd | 40 +--------------------------------- src/ui_parts/previews.tscn | 20 ----------------- 5 files changed, 12 insertions(+), 84 deletions(-) diff --git a/src/config_classes/SaveData.gd b/src/config_classes/SaveData.gd index 40e10f17a..dec880113 100644 --- a/src/config_classes/SaveData.gd +++ b/src/config_classes/SaveData.gd @@ -1191,20 +1191,6 @@ const DEFAULT_PREVIEW_SIZES: PackedInt32Array = [16, 24, 32, 48, 64] emit_changed() -@export var previews_use_pebble_preview := false: - set(new_value): - if previews_use_pebble_preview != new_value: - previews_use_pebble_preview = new_value - emit_changed() - - -@export var previews_precise_path_mode := PDCImage.PrecisePathMode.AUTODETECT: - set(new_value): - if previews_precise_path_mode != new_value: - previews_precise_path_mode = new_value - emit_changed() - - # Utility func get_validity_color(error_condition: bool, warning_condition := false) -> Color: diff --git a/src/data_classes/PDCImage.gd b/src/data_classes/PDCImage.gd index 036ff93bb..c0fc5afc9 100644 --- a/src/data_classes/PDCImage.gd +++ b/src/data_classes/PDCImage.gd @@ -91,8 +91,7 @@ const _DRAW_TYPE_DISPATCHER: Dictionary[DrawType, Script] = { func load_from_pdc(pdc: PackedByteArray) -> PDCLoadingError: var sp := StreamPeerBuffer.new() sp.data_array = pdc - # 4 chars * 1 byte / char = 32 bits - if sp.get_u32() != "PDCI".to_ascii_buffer().decode_u32(0): + 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: @@ -301,9 +300,9 @@ func load_from_svg(svg: ElementRoot) -> void: 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) - path_generator.angle_tolerance = path_angle_tolerance else: viewbox_transform = Transform2D.IDENTITY + path_generator.angle_tolerance = path_angle_tolerance for element in svg.get_all_valid_element_descendants(): match element.name: "circle": @@ -400,7 +399,7 @@ func _generate_path_commands(element: Element) -> Array[PebblePathCommand]: 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] == commands[-1].points[-1]: + 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 @@ -539,18 +538,19 @@ func requires_precise_path(points: PackedVector2Array) -> bool: static func to_pebble_color(color: Color) -> int: return (0 - | int(color.r * 3.0) << 0 - | int(color.g * 3.0) << 2 - | int(color.b * 3.0) << 4 - | int(color.a * 3.0) << 6 + # 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 >> 0) & 0b11) / 3.0, - ((color >> 2) & 0b11) / 3.0, ((color >> 4) & 0b11) / 3.0, + ((color >> 2) & 0b11) / 3.0, + ((color >> 0) & 0b11) / 3.0, ((color >> 6) & 0b11) / 3.0, ) diff --git a/src/ui_parts/export_menu.gd b/src/ui_parts/export_menu.gd index ff476ddd8..78ef6d725 100644 --- a/src/ui_parts/export_menu.gd +++ b/src/ui_parts/export_menu.gd @@ -208,7 +208,7 @@ func update() -> void: match export_data.format: "svg": export_size = State.get_export_text().length() - "pdc": + _: export_size = texture_preview.last_image_size final_size_label.text = Translator.translate("Size") + ": " + String.humanize_size(export_size) diff --git a/src/ui_parts/previews.gd b/src/ui_parts/previews.gd index 2ecff4daf..df0e1122a 100644 --- a/src/ui_parts/previews.gd +++ b/src/ui_parts/previews.gd @@ -28,8 +28,6 @@ const MAX_ICON_PREVIEW_SIZE = 128 @onready var preview_top_panel: PanelContainer = $SplitContainer/PreviewTopPanel @onready var more_button: Button = $ActionContainer/MoreButton @onready var size_label_margins: MarginContainer = %SizeLabelMargins -@onready var precise_path_mode_dropdown: Control = %PrecisePathModeDropdown -@onready var precise_path_mode_container: HBoxContainer = %PrecisePathModeContainer class IconPreviewTileData extends RefCounted: var index := -1 @@ -68,19 +66,7 @@ class IconPreviewTileData extends RefCounted: label_rect = Rect2(Vector2(TILE_TOP_PADDING + 1, TILE_LEFT_PADDING + preview_size.y + ICON_TEXT_SPACING), label_size - Vector2(1, 0)) more_button_rect = Rect2(Vector2(TILE_TOP_PADDING + label_size.x, label_rect.position.y + 2), Vector2(MORE_ICON_SIZE, MORE_ICON_SIZE)) - if Configs.savedata.previews_use_pebble_preview: - var svg := SVGParser.markup_to_root(State.stable_export_markup).svg - if svg != null: - var pdc := PDCImage.new() - pdc.precise_path_mode = Configs.savedata.previews_precise_path_mode - # Convert the SVG to PDCImage then back to SVG for accurate display - pdc.load_from_svg(svg) - var buffer := pdc.encode() - var decoded_pdc := PDCImage.new() - decoded_pdc.load_from_pdc(buffer) - preview_texture = DPITexture.create_from_string(decoded_pdc.to_svg(), multiplier) - else: - preview_texture = DPITexture.create_from_string(State.stable_export_markup, multiplier) + preview_texture = DPITexture.create_from_string(State.stable_export_markup, multiplier) var tiles: Array[IconPreviewTileData] = [] var hovered_tile_index := -1 @@ -89,7 +75,6 @@ var edited_tile_index := -1 var edit_field: NumberEdit func _ready() -> void: - %PrecisePathModeContainer/Label.text = Translator.translate("Precise") + ":" icon_preview_tiles.draw.connect(_on_preview_tiles_draw) icon_preview_tiles.gui_input.connect(_on_tiles_gui_input) icon_preview_tiles.mouse_exited.connect(_on_tiles_mouse_exited) @@ -125,14 +110,6 @@ func _ready() -> void: ) icon_preview_tiles.resized.connect(sync_tile_positions) - if Configs.savedata.previews_use_pebble_preview: - _toggle_use_pebble_preview() - _toggle_use_pebble_preview() - else: - _toggle_use_pebble_preview() - precise_path_mode_dropdown.set_value(Configs.savedata.previews_precise_path_mode, 0) - precise_path_mode_dropdown.value_changed.connect(_on_precise_path_mode_dropdown_value_changed) - sync_tiles() HandlerGUI.register_focus_sequence(self, [add_new_preview_button, transparent_color_swatch, black_color_swatch, white_color_swatch, color_edit, more_button]) @@ -369,17 +346,7 @@ func _update_preview_background(new_value: String) -> void: scaled_preview_panel.add_theme_stylebox_override("panel", colored_sb) -func _toggle_use_pebble_preview() -> void: - Configs.savedata.previews_use_pebble_preview = not Configs.savedata.previews_use_pebble_preview - precise_path_mode_container.visible = Configs.savedata.previews_use_pebble_preview - sync_tiles() - - func _on_more_button_pressed() -> void: - var pebble_preview_checkbox := ContextButton.create_custom_checkbox(Translator.translate("Preview as PDC"), - _toggle_use_pebble_preview, Configs.savedata.previews_use_pebble_preview) - pebble_preview_checkbox.auto_toggle = true - pebble_preview_checkbox.theme_type = "PebbleCheckBox" var btn_array: Array[ContextButton] = [ ContextButton.create_custom(Translator.translate("Reset to default"), reset_tiles, preload("res://assets/icons/Reload.svg"), are_tiles_default()), @@ -387,10 +354,5 @@ func _on_more_button_pressed() -> void: preload("res://assets/icons/Clear.svg"), Configs.savedata.preview_sizes.is_empty()), ContextButton.create_custom(Translator.translate("Sort"), sort_tiles, preload("res://assets/icons/Sort.svg"), are_tiles_sorted()), - pebble_preview_checkbox, ] HandlerGUI.popup_under_rect_center(ContextPopup.create(btn_array), more_button.get_global_rect(), get_viewport()) - -func _on_precise_path_mode_dropdown_value_changed(new_precise_path_mode: PDCImage.PrecisePathMode) -> void: - Configs.savedata.previews_precise_path_mode = new_precise_path_mode - sync_tiles() diff --git a/src/ui_parts/previews.tscn b/src/ui_parts/previews.tscn index 487f79a71..07b04d223 100644 --- a/src/ui_parts/previews.tscn +++ b/src/ui_parts/previews.tscn @@ -4,7 +4,6 @@ [ext_resource type="Texture2D" uid="uid://eif2ioi0mw17" path="res://assets/icons/Plus.svg" id="2_pm7dr"] [ext_resource type="PackedScene" uid="uid://bujllg1bqlub6" path="res://src/ui_widgets/color_swatch.tscn" id="3_auodr"] [ext_resource type="PackedScene" uid="uid://5f8uxavn1or1" path="res://src/ui_widgets/color_edit.tscn" id="4_mkhk3"] -[ext_resource type="PackedScene" uid="uid://bpr5yv7pbv13g" path="res://src/ui_widgets/dropdown_basic.tscn" id="5_pm7dr"] [ext_resource type="Texture2D" uid="uid://ccbta5q43jobk" path="res://assets/icons/More.svg" id="5_ue2r5"] [ext_resource type="Script" uid="uid://bniafq6ma1iln" path="res://src/ui_widgets/ProceduralControl.gd" id="6_6v74c"] @@ -59,25 +58,6 @@ layout_mode = 2 size_flags_vertical = 4 enable_alpha = true -[node name="PrecisePathModeContainer" type="HBoxContainer" parent="ActionContainer"] -unique_name_in_owner = true -visible = false -layout_mode = 2 - -[node name="Label" type="Label" parent="ActionContainer/PrecisePathModeContainer"] -layout_mode = 2 - -[node name="PrecisePathModeDropdown" parent="ActionContainer/PrecisePathModeContainer" instance=ExtResource("5_pm7dr")] -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="MoreButton" type="Button" parent="ActionContainer" unique_id=1528240669] layout_mode = 2 mouse_default_cursor_shape = 2 From afbac4a748b36cf858ffb12ce4060a82f2af5830 Mon Sep 17 00:00:00 2001 From: fish Date: Wed, 25 Feb 2026 01:48:20 -0800 Subject: [PATCH 8/8] pdc export: fix transformations not being applied to circles and ellipses, add estimated file size --- assets/icons/Pebble.svg.import | 41 +++++------------------ assets/icons/PebbleChecked.svg.import | 41 +++++------------------ src/data_classes/PDCImage.gd | 10 +++--- src/ui_parts/export_menu.gd | 48 ++++++++++++++++++++------- src/ui_parts/export_menu.tscn | 33 +++++++++--------- src/ui_widgets/preview_rect.gd | 2 +- src/utils/ImageExportData.gd | 8 ++--- 7 files changed, 80 insertions(+), 103 deletions(-) diff --git a/assets/icons/Pebble.svg.import b/assets/icons/Pebble.svg.import index 77290fd1a..df5da66c5 100644 --- a/assets/icons/Pebble.svg.import +++ b/assets/icons/Pebble.svg.import @@ -1,43 +1,18 @@ [remap] -importer="texture" -type="CompressedTexture2D" +importer="svg" +type="DPITexture" uid="uid://dvsovsw5gwmma" -path="res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.ctex" -metadata={ -"vram_texture": false -} +path="res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.dpitex" [deps] source_file="res://assets/icons/Pebble.svg" -dest_files=["res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.ctex"] +dest_files=["res://.godot/imported/Pebble.svg-db52ccff2acc9153dc711ebb4b736da7.dpitex"] [params] -compress/mode=0 -compress/high_quality=false -compress/lossy_quality=0.7 -compress/uastc_level=0 -compress/rdo_quality_loss=0.0 -compress/hdr_compression=1 -compress/normal_map=0 -compress/channel_pack=0 -mipmaps/generate=false -mipmaps/limit=-1 -roughness/mode=0 -roughness/src_normal="" -process/channel_remap/red=0 -process/channel_remap/green=1 -process/channel_remap/blue=2 -process/channel_remap/alpha=3 -process/fix_alpha_border=true -process/premult_alpha=false -process/normal_map_invert_y=false -process/hdr_as_srgb=false -process/hdr_clamp_exposure=false -process/size_limit=0 -detect_3d/compress_to=1 -svg/scale=1.0 -editor/scale_with_editor_scale=false -editor/convert_colors_with_editor_theme=false +base_scale=1.0 +saturation=1.0 +color_map={} +compress=true diff --git a/assets/icons/PebbleChecked.svg.import b/assets/icons/PebbleChecked.svg.import index 8909a36d0..0501ed881 100644 --- a/assets/icons/PebbleChecked.svg.import +++ b/assets/icons/PebbleChecked.svg.import @@ -1,43 +1,18 @@ [remap] -importer="texture" -type="CompressedTexture2D" +importer="svg" +type="DPITexture" uid="uid://b8y5cq15l01p4" -path="res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.ctex" -metadata={ -"vram_texture": false -} +path="res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.dpitex" [deps] source_file="res://assets/icons/PebbleChecked.svg" -dest_files=["res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.ctex"] +dest_files=["res://.godot/imported/PebbleChecked.svg-8b4f46e3d1017fc1f7a8e6fe3b9be409.dpitex"] [params] -compress/mode=0 -compress/high_quality=false -compress/lossy_quality=0.7 -compress/uastc_level=0 -compress/rdo_quality_loss=0.0 -compress/hdr_compression=1 -compress/normal_map=0 -compress/channel_pack=0 -mipmaps/generate=false -mipmaps/limit=-1 -roughness/mode=0 -roughness/src_normal="" -process/channel_remap/red=0 -process/channel_remap/green=1 -process/channel_remap/blue=2 -process/channel_remap/alpha=3 -process/fix_alpha_border=true -process/premult_alpha=false -process/normal_map_invert_y=false -process/hdr_as_srgb=false -process/hdr_clamp_exposure=false -process/size_limit=0 -detect_3d/compress_to=1 -svg/scale=1.0 -editor/scale_with_editor_scale=false -editor/convert_colors_with_editor_theme=false +base_scale=1.0 +saturation=1.0 +color_map={} +compress=true diff --git a/src/data_classes/PDCImage.gd b/src/data_classes/PDCImage.gd index c0fc5afc9..af0b91217 100644 --- a/src/data_classes/PDCImage.gd +++ b/src/data_classes/PDCImage.gd @@ -304,16 +304,18 @@ func load_from_svg(svg: ElementRoot) -> void: 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 = Vector2(element.get_attribute_num("cx"), element.get_attribute_num("cy")) - cmd.radius = int(element.get_attribute_num("r")) + 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 = Vector2(element.get_attribute_num("cx"), element.get_attribute_num("cy")) - cmd.radius = int(minf(element.get_attribute_num("rx"), element.get_attribute_num("ry"))) + 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") diff --git a/src/ui_parts/export_menu.gd b/src/ui_parts/export_menu.gd index 78ef6d725..169a53ac0 100644 --- a/src/ui_parts/export_menu.gd +++ b/src/ui_parts/export_menu.gd @@ -22,7 +22,7 @@ var dimensions := Vector2.ZERO @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: GridContainer = %PDCPathQualityContainer +@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 @@ -79,9 +79,9 @@ func _ready() -> void: %FormatHBox/Label.text = Translator.translate("Format") + ":" %LosslessCheckBox.text = Translator.translate("Lossless") %PDCPathQualityContainer/PreciseModeLabel.text = Translator.translate("Precise") + ":" - %PDCPathQualityContainer/QualityLabel.text = Translator.translate("Tesselation quality") + ":" + %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 tesselate paths.") + 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") + ":" @@ -101,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: @@ -137,10 +150,10 @@ func _on_precise_path_mode_dropdown_toggled(precise_path_mode: PDCImage.PreciseP undo_redo.commit_action() func _on_path_quality_value_changed(new_value: float) -> void: - var current_quality := export_data.tesselation_tolerance_degrees + var current_quality := export_data.tessellation_tolerance_degrees undo_redo.create_action() - undo_redo.add_do_property(export_data, "tesselation_tolerance_degrees", new_value) - undo_redo.add_undo_property(export_data, "tesselation_tolerance_degrees", current_quality) + 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: @@ -203,20 +216,31 @@ func update() -> void: quality_edit.set_value(export_data.quality * 100, false) lossless_checkbox.set_pressed_no_signal(not export_data.lossy) - #final_size_label.visible = export_data.format in ["svg", "pdc"] + 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() _: - export_size = texture_preview.last_image_size + 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 3cdca00c7..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 @@ -148,21 +148,6 @@ unique_name_in_owner = true layout_mode = 2 columns = 2 -[node name="PreciseModeLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1100694615] -layout_mode = 2 -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="QualityLabel" type="Label" parent="MarginContainer/VBoxContainer/TitledPanel/VBoxContainer/CenterContainer/VBoxContainer/PDCPathQualityContainer" unique_id=1784192134] layout_mode = 2 theme_override_font_sizes/font_size = 12 @@ -179,6 +164,22 @@ initial_value = 10.0 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 diff --git a/src/ui_widgets/preview_rect.gd b/src/ui_widgets/preview_rect.gd index c4050e11c..e064b27a1 100644 --- a/src/ui_widgets/preview_rect.gd +++ b/src/ui_widgets/preview_rect.gd @@ -31,7 +31,7 @@ func setup_image(config: ImageExportData) -> void: 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.tesselation_tolerance_degrees = config.tesselation_tolerance_degrees + 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)) diff --git a/src/utils/ImageExportData.gd b/src/utils/ImageExportData.gd index 48ec2244a..220e3e88c 100644 --- a/src/utils/ImageExportData.gd +++ b/src/utils/ImageExportData.gd @@ -35,10 +35,10 @@ var lossy := false: lossy = new_value changed.emit() -var tesselation_tolerance_degrees := 10.0: +var tessellation_tolerance_degrees := 10.0: set(new_value): - if new_value != tesselation_tolerance_degrees: - tesselation_tolerance_degrees = new_value + if new_value != tessellation_tolerance_degrees: + tessellation_tolerance_degrees = new_value changed.emit() @@ -61,7 +61,7 @@ func image_to_buffer(image: Image) -> PackedByteArray: "pdc": var pdc := PDCImage.new() pdc.precise_path_mode = precise_path_mode - pdc.path_angle_tolerance = tesselation_tolerance_degrees + 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()