diff options
Diffstat (limited to 'plug-ins/pygimp/plug-ins/spyro_plus.py')
-rw-r--r-- | plug-ins/pygimp/plug-ins/spyro_plus.py | 2212 |
1 files changed, 2212 insertions, 0 deletions
diff --git a/plug-ins/pygimp/plug-ins/spyro_plus.py b/plug-ins/pygimp/plug-ins/spyro_plus.py new file mode 100644 index 0000000..7d31c54 --- /dev/null +++ b/plug-ins/pygimp/plug-ins/spyro_plus.py @@ -0,0 +1,2212 @@ +#!/usr/bin/env python2 + +# Draw Spyrographs, Epitrochoids, and Lissajous curves with interactive feedback. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +from gimpshelf import shelf +from gimpenums import * +import gimp +import gimpplugin +import gimpui +import gobject +import gtk +gdk = gtk.gdk + +from math import pi, sin, cos, atan, atan2, fmod, radians, sqrt +import gettext +import fractions +import time + + +# i18n +t = gettext.translation("gimp20-python", gimp.locale_directory, fallback=True) +_ = t.ugettext + +def N_(message): + return message + + +pdb = gimp.pdb + +two_pi, half_pi = 2 * pi, pi / 2 +layer_name = _("Spyro Layer") +path_name = _("Spyro Path") + +# "Enums" +GEAR_NOTATION, TOY_KIT_NOTATION, VISUAL_NOTATION = range(3) # Pattern notations + +# Mapping of pattern notation to the corresponding tab in the pattern notation notebook. +pattern_notation_page = {} + +# Save options of the dialog +SAVE_AS_NEW_LAYER, SAVE_BY_REDRAW, SAVE_AS_PATH = range(3) +save_options = [ + _("Save\nas New Layer"), + _("Redraw on\nActive layer"), + _("Save\nas Path") +] + +ring_teeth = [96, 144, 105, 150] + +# Moving gear. Each gear is a pair of (#teeth, #holes) +# Hole #1 is closest to the edge of the wheel. +# The last hole is closest to the center. +wheel = [ + (24, 5), (30, 8), (32, 9), (36, 11), (40, 13), (42, 14), (45, 16), + (48, 17), (50, 18), (52, 19), (56, 21), (60, 23), (63, 25), (64, 25), + (72, 29), (75, 31), (80, 33), (84, 35) +] +wheel_teeth = [wh[0] for wh in wheel] + + +def lcm(a, b): + """ Least common multiplier """ + return a * b // fractions.gcd(a, b) + + +### Shapes + + +class CanRotateShape: + pass + + +class Shape: + def configure(self, img, pp, cp): + self.image, self.pp, self.cp = img, pp, cp + + def can_equal_w_h(self): + return True + + def has_sides(self): + return isinstance(self, SidedShape) + + def can_rotate(self): + return isinstance(self, CanRotateShape) + + def can_morph(self): + return self.has_sides() + + +class CircleShape(Shape): + name = _("Circle") + + def get_center_of_moving_gear(self, oangle, dist=None): + """ + :return: x,y - position where the center of the moving gear should be, + after going over oangle/two_pi of a full cycle over the outer gear. + """ + cp = self.cp + if dist is None: + dist = cp.moving_gear_radius + + return (cp.x_center + (cp.x_half_size - dist) * cos(oangle), + cp.y_center + (cp.y_half_size - dist) * sin(oangle)) + + +class SidedShape(CanRotateShape, Shape): + + def configure(self, img, pp, cp): + Shape.configure(self, img, pp, cp) + self.angle_of_each_side = two_pi / pp.sides + self.half_angle = self.angle_of_each_side / 2.0 + self.cos_half_angle = cos(self.half_angle) + + def get_center_of_moving_gear(self, oangle, dist=None): + if dist is None: + dist = self.cp.moving_gear_radius + shape_factor = self.get_shape_factor(oangle) + return ( + self.cp.x_center + + (self.cp.x_half_size - dist) * shape_factor * cos(oangle), + self.cp.y_center + + (self.cp.y_half_size - dist) * shape_factor * sin(oangle) + ) + + +class PolygonShape(SidedShape): + name = _("Polygon-Star") + + def get_shape_factor(self, oangle): + oangle_mod = fmod(oangle + self.cp.shape_rotation_radians, self.angle_of_each_side) + if oangle_mod > self.half_angle: + oangle_mod = self.angle_of_each_side - oangle_mod + + # When oangle_mod = 0, the shape_factor will be cos(half_angle)) - which is the minimal shape_factor. + # When oangle_mod is near the half_angle, the shape_factor will near 1. + shape_factor = self.cos_half_angle / cos(oangle_mod) + shape_factor -= self.pp.morph * (1 - shape_factor) * (1 + (self.pp.sides - 3) * 2) + return shape_factor + + +class SineShape(SidedShape): + # Sine wave on a circle ring. + name = _("Sine") + + def get_shape_factor(self, oangle): + oangle_mod = fmod(oangle + self.cp.shape_rotation_radians, self.angle_of_each_side) + oangle_stretched = oangle_mod * self.pp.sides + return 1 - self.pp.morph * (cos(oangle_stretched) + 1) + + +class BumpShape(SidedShape): + # Semi-circles, based on a polygon + name = _("Bumps") + + def get_shape_factor(self, oangle): + oangle_mod = fmod(oangle + self.cp.shape_rotation_radians, self.angle_of_each_side) + # Stretch back to angle between 0 and pi + oangle_stretched = oangle_mod/2.0 * self.pp.sides + + # Compute factor for polygon. + poly_angle = oangle_mod + if poly_angle > self.half_angle: + poly_angle = self.angle_of_each_side - poly_angle + # When poly_oangle = 0, the shape_factor will be cos(half_angle)) - the minimal shape_factor. + # When poly_angle is near the half_angle, the shape_factor will near 1. + polygon_factor = self.cos_half_angle / cos(poly_angle) + + # Bump + return polygon_factor - self.pp.morph * (1 - abs(cos(oangle_stretched))) + + +class ShapePart(object): + def set_bounds(self, start, end): + self.bound_start, self.bound_end = start, end + self.bound_diff = self.bound_end - self.bound_start + + +class StraightPart(ShapePart): + + def __init__(self, teeth, perp_direction, x1, y1, x2, y2): + self.teeth, self.perp_direction = max(teeth, 1), perp_direction + self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2 + self.x_diff = self.x2 - self.x1 + self.y_diff = self.y2 - self.y1 + + angle = atan2(self.y_diff, self.x_diff) # - shape_rotation_radians + perp_angle = angle + perp_direction * half_pi + self.sin_angle = sin(perp_angle) + self.cos_angle = cos(perp_angle) + + def perpendicular_at_oangle(self, oangle, perp_distance): + factor = (oangle - self.bound_start) / self.bound_diff + return (self.x1 + factor * self.x_diff + perp_distance * self.cos_angle, + self.y1 + factor * self.y_diff + perp_distance * self.sin_angle) + + +class RoundPart(ShapePart): + + def __init__(self, teeth, x, y, start_angle, end_angle): + self.teeth = max(teeth, 1) + self.start_angle, self.end_angle = start_angle, end_angle + self.x, self.y = x, y + + self.diff_angle = self.end_angle - self.start_angle + + def perpendicular_at_oangle(self, oangle, perp_distance): + angle = ( + self.start_angle + + self.diff_angle * (oangle - self.bound_start) / self.bound_diff + ) + return (self.x + perp_distance * cos(angle), + self.y + perp_distance * sin(angle)) + + +class ShapeParts(list): + """ A list of shape parts. """ + + def __init__(self): + list.__init__(self) + self.total_teeth = 0 + + def finish(self): + for part in self: + self.total_teeth += part.teeth + teeth = 0 + bound_end = 0.0 + for part in self: + bound_start = bound_end + teeth += part.teeth + bound_end = teeth/float(self.total_teeth) * two_pi + part.set_bounds(bound_start, bound_end) + + def perpendicular_at_oangle(self, oangle, perp_distance): + for part in self: + if oangle <= part.bound_end: + return part.perpendicular_at_oangle(oangle, perp_distance) + + # We shouldn't reach here + return 0.0, 0.0 + + +class AbstractShapeFromParts(Shape): + def __init__(self): + self.parts = None + + def get_center_of_moving_gear(self, oangle, dist=None): + """ + :param oangle: an angle in radians, between 0 and 2*pi + :return: x,y - position where the center of the moving gear should be, + after going over oangle/two_pi of a full cycle over the outer gear. + """ + if dist is None: + dist = self.cp.moving_gear_radius + return self.parts.perpendicular_at_oangle(oangle, dist) + + +class RackShape(CanRotateShape, AbstractShapeFromParts): + name = _("Rack") + + def configure(self, img, pp, cp): + Shape.configure(self, img, pp, cp) + + round_teeth = 12 + side_teeth = (cp.fixed_gear_teeth - 2 * round_teeth) / 2 + + # Determine start and end points of rack. + + cos_rot = cos(cp.shape_rotation_radians) + sin_rot = sin(cp.shape_rotation_radians) + + x_size = cp.x2 - cp.x1 - cp.moving_gear_radius * 4 + y_size = cp.y2 - cp.y1 - cp.moving_gear_radius * 4 + + size = ((x_size * cos_rot)**2 + (y_size * sin_rot)**2) ** 0.5 + + x1 = cp.x_center - size/2.0 * cos_rot + y1 = cp.y_center - size/2.0 * sin_rot + x2 = cp.x_center + size/2.0 * cos_rot + y2 = cp.y_center + size/2.0 * sin_rot + + # Build shape from shape parts. + self.parts = ShapeParts() + self.parts.append(StraightPart(side_teeth, -1, x2, y2, x1, y1)) + self.parts.append( + RoundPart( + round_teeth, x1, y1, + half_pi + cp.shape_rotation_radians, + 3 * half_pi + cp.shape_rotation_radians + ) + ) + self.parts.append(StraightPart(side_teeth, -1, x1, y1, x2, y2)) + self.parts.append( + RoundPart( + round_teeth, x2, y2, + 3 * half_pi + cp.shape_rotation_radians, + 5 * half_pi + cp.shape_rotation_radians) + ) + self.parts.finish() + + +class FrameShape(AbstractShapeFromParts): + name = _("Frame") + + def configure(self, img, pp, cp): + Shape.configure(self, img, pp, cp) + + x1, x2 = cp.x1 + cp.moving_gear_radius, cp.x2 - cp.moving_gear_radius + y1, y2 = cp.y1 + cp.moving_gear_radius, cp.y2 - cp.moving_gear_radius + x_diff, y_diff = abs(x2 - x1), abs(y2 - y1) + + # Build shape from shape parts. + self.parts = ShapeParts() + self.parts.append(StraightPart(x_diff, 1, x2, cp.y2, x1, cp.y2)) + self.parts.append(StraightPart(y_diff, 1, cp.x1, y2, cp.x1, y1)) + self.parts.append(StraightPart(x_diff, 1, x1, cp.y1, x2, cp.y1)) + self.parts.append(StraightPart(y_diff, 1, cp.x2, y1, cp.x2, y2)) + self.parts.finish() + + +class SelectionToPath: + """ Converts a selection to a path """ + + def __init__(self, image): + self.image = image + + # Compute hash of selection, so we can detect when it was modified. + self.last_selection_hash = self.compute_selection_hash() + + self.convert_selection_to_path() + + def convert_selection_to_path(self): + + if pdb.gimp_selection_is_empty(self.image): + selection_was_empty = True + pdb.gimp_selection_all(self.image) + else: + selection_was_empty = False + + pdb.plug_in_sel2path(self.image, self.image.active_layer) + + self.path = self.image.vectors[0] + + self.num_strokes, self.stroke_ids = pdb.gimp_vectors_get_strokes(self.path) + self.stroke_ids = list(self.stroke_ids) + + # A path may contain several strokes. If so lets throw away a stroke that + # simply describes the borders of the image, if one exists. + if self.num_strokes > 1: + # Lets compute what a stroke of the image borders should look like. + w, h = float(self.image.width), float(self.image.height) + frame_strokes = [0.0] * 6 + [0.0, h] * 3 + [w, h] * 3 + [w, 0.0] * 3 + + for stroke in range(self.num_strokes): + strokes = self.path.strokes[stroke].points[0] + if strokes == frame_strokes: + del self.stroke_ids[stroke] + self.num_strokes -= 1 + break + + self.set_current_stroke(0) + + if selection_was_empty: + # Restore empty selection if it was empty. + pdb.gimp_selection_none(self.image) + + def compute_selection_hash(self): + px = self.image.selection.get_pixel_rgn(0, 0, self.image.width, self.image.height) + return px[0:self.image.width, 0:self.image.height].__hash__() + + def regenerate_path_if_selection_changed(self): + current_selection_hash = self.compute_selection_hash() + if self.last_selection_hash != current_selection_hash: + self.last_selection_hash = current_selection_hash + self.convert_selection_to_path() + + def get_num_strokes(self): + return self.num_strokes + + def set_current_stroke(self, stroke_id=0): + # Compute path length. + self.path_length = pdb.gimp_vectors_stroke_get_length(self.path, self.stroke_ids[stroke_id], 1.0) + self.current_stroke = stroke_id + + def point_at_angle(self, oangle): + oangle_mod = fmod(oangle, two_pi) + dist = self.path_length * oangle_mod / two_pi + return pdb.gimp_vectors_stroke_get_point_at_dist(self.path, self.stroke_ids[self.current_stroke], dist, 1.0) + + +class SelectionShape(Shape): + name = _("Selection") + + def __init__(self): + self.path = None + + def process_selection(self, img): + if self.path is None: + self.path = SelectionToPath(img) + else: + self.path.regenerate_path_if_selection_changed() + + def configure(self, img, pp, cp): + """ Set bounds of pattern """ + Shape.configure(self, img, pp, cp) + self.drawing_no = cp.current_drawing + self.path.set_current_stroke(self.drawing_no) + + def get_num_drawings(self): + return self.path.get_num_strokes() + + def can_equal_w_h(self): + return False + + def get_center_of_moving_gear(self, oangle, dist=None): + """ + :param oangle: an angle in radians, between 0 and 2*pi + :return: x,y - position where the center of the moving gear should be, + after going over oangle/two_pi of a full cycle over the outer gear. + """ + cp = self.cp + if dist is None: + dist = cp.moving_gear_radius + x, y, slope, valid = self.path.point_at_angle(oangle) + slope_angle = atan(slope) + # We want to find an angle perpendicular to the slope, but in which direction? + # Lets try both sides and see which of them is inside the selection. + perpendicular_p, perpendicular_m = slope_angle + half_pi, slope_angle - half_pi + step_size = 2 # The distance we are going to go in the direction of each angle. + xp, yp = x + step_size * cos(perpendicular_p), y + step_size * sin(perpendicular_p) + value_plus = pdb.gimp_selection_value(self.image, xp, yp) + xp, yp = x + step_size * cos(perpendicular_m), y + step_size * sin(perpendicular_m) + value_minus = pdb.gimp_selection_value(self.image, xp, yp) + + perpendicular = perpendicular_p if value_plus > value_minus else perpendicular_m + return x + dist * cos(perpendicular), y + dist * sin(perpendicular) + + +shapes = [ + CircleShape(), RackShape(), FrameShape(), SelectionShape(), + PolygonShape(), SineShape(), BumpShape() +] + + +### Tools + + +def get_gradient_samples(num_samples): + gradient_name = pdb.gimp_context_get_gradient() + reverse_mode = pdb.gimp_context_get_gradient_reverse() + repeat_mode = pdb.gimp_context_get_gradient_repeat_mode() + + if repeat_mode == REPEAT_TRIANGULAR: + # Get two uniform samples, which are reversed from each other, and connect them. + + samples = num_samples/2 + 1 + num, color_samples = pdb.gimp_gradient_get_uniform_samples(gradient_name, + samples, reverse_mode) + + color_samples = list(color_samples) + del color_samples[-4:] # Delete last color because it will appear in the next sample + + # If num_samples is odd, lets get an extra sample this time. + if num_samples % 2 == 1: + samples += 1 + + num, color_samples2 = pdb.gimp_gradient_get_uniform_samples(gradient_name, + samples, 1 - reverse_mode) + + color_samples2 = list(color_samples2) + del color_samples2[-4:] # Delete last color because it will appear in the very first sample + + color_samples.extend(color_samples2) + color_samples = tuple(color_samples) + else: + num, color_samples = pdb.gimp_gradient_get_uniform_samples(gradient_name, num_samples, reverse_mode) + + return color_samples + + +class PencilTool(): + name = _("Pencil") + can_color = True + + def draw(self, layer, strokes, color=None): + if color: + pdb.gimp_context_push() + pdb.gimp_context_set_dynamics('Dynamics Off') + pdb.gimp_context_set_foreground(color) + + pdb.gimp_pencil(layer, len(strokes), strokes) + + if color: + pdb.gimp_context_pop() + + +class AirBrushTool(): + name = _("AirBrush") + can_color = True + + def draw(self, layer, strokes, color=None): + if color: + pdb.gimp_context_push() + pdb.gimp_context_set_dynamics('Dynamics Off') + pdb.gimp_context_set_foreground(color) + + pdb.gimp_airbrush_default(layer, len(strokes), strokes) + + if color: + pdb.gimp_context_pop() + + +class AbstractStrokeTool(): + + def draw(self, layer, strokes, color=None): + # We need to multiply every point by 3, because we are creating a path, + # where each point has two additional control points. + control_points = [] + for i, k in zip(strokes[0::2], strokes[1::2]): + control_points += [i, k] * 3 + + # Create path + path = pdb.gimp_vectors_new(layer.image, 'temp_path') + pdb.gimp_image_add_vectors(layer.image, path, 0) + sid = pdb.gimp_vectors_stroke_new_from_points(path, 0, len(control_points), + control_points, False) + + # Draw it. + + pdb.gimp_context_push() + + # Call template method to set the kind of stroke to draw. + self.prepare_stroke_context(color) + + pdb.gimp_drawable_edit_stroke_item(layer, path) + pdb.gimp_context_pop() + + # Get rid of the path. + pdb.gimp_image_remove_vectors(layer.image, path) + + +# Drawing tool that should be quick, for purposes of previewing the pattern. +class PreviewTool: + + # Implementation using pencil. (A previous implementation using stroke was slower, and thus removed). + def draw(self, layer, strokes, color=None): + foreground = pdb.gimp_context_get_foreground() + pdb.gimp_context_push() + pdb.gimp_context_set_defaults() + pdb.gimp_context_set_foreground(foreground) + pdb.gimp_context_set_dynamics('Dynamics Off') + pdb.gimp_context_set_brush('1. Pixel') + pdb.gimp_context_set_brush_size(1.0) + pdb.gimp_context_set_brush_spacing(3.0) + pdb.gimp_pencil(layer, len(strokes), strokes) + pdb.gimp_context_pop() + + name = _("Preview") + can_color = False + + +class StrokeTool(AbstractStrokeTool): + name = _("Stroke") + can_color = True + + def prepare_stroke_context(self, color): + if color: + pdb.gimp_context_set_dynamics('Dynamics Off') + pdb.gimp_context_set_foreground(color) + + pdb.gimp_context_set_stroke_method(STROKE_LINE) + + +class StrokePaintTool(AbstractStrokeTool): + def __init__(self, name, paint_method, can_color=True): + self.name = name + self.paint_method = paint_method + self.can_color = can_color + + def prepare_stroke_context(self, color): + if self.can_color and color is not None: + pdb.gimp_context_set_dynamics('Dynamics Off') + pdb.gimp_context_set_foreground(color) + + pdb.gimp_context_set_stroke_method(STROKE_PAINT_METHOD) + pdb.gimp_context_set_paint_method(self.paint_method) + + +class SaveToPathTool(): + """ This tool cannot be chosen by the user from the tools menu. + We dont add this to the list of tools. """ + + def __init__(self, img): + self.path = pdb.gimp_vectors_new(img, path_name) + pdb.gimp_image_add_vectors(img, self.path, 0) + + def draw(self, layer, strokes, color=None): + # We need to multiply every point by 3, because we are creating a path, + # where each point has two additional control points. + control_points = [] + for i, k in zip(strokes[0::2], strokes[1::2]): + control_points += [i, k] * 3 + + sid = pdb.gimp_vectors_stroke_new_from_points(self.path, 0, len(control_points), + control_points, False) + + +tools = [ + PreviewTool(), + StrokePaintTool(_("PaintBrush"), "gimp-paintbrush"), + PencilTool(), AirBrushTool(), StrokeTool(), + StrokePaintTool(_("Ink"), 'gimp-ink'), + StrokePaintTool(_("MyPaintBrush"), 'gimp-mybrush') + # Clone does not work properly when an image is not set. When that happens, drawing fails, and + # I am unable to catch the error. This causes the plugin to crash, and subsequent problems with undo. + # StrokePaintTool("Clone", 'gimp-clone', False) +] + + +class PatternParameters: + """ + All the parameters that define a pattern live in objects of this class. + If you serialize and saved this class, you should reproduce + the pattern that the plugin would draw. + """ + def __init__(self): + if not hasattr(self, 'curve_type'): + self.curve_type = 0 + + # Pattern + if not hasattr(self, 'pattern_notation'): + self.pattern_notation = 0 + if not hasattr(self, 'outer_teeth'): + self.outer_teeth = 96 + if not hasattr(self, 'inner_teeth'): + self.inner_teeth = 36 + if not hasattr(self, 'pattern_rotation'): + self.pattern_rotation = 0 + # Location of hole as a percent of the radius of the inner gear - runs between 0 and 100. + # A value of 0 means, the hole is at the center of the wheel, which would produce a boring circle. + # A value of 100 means the edge of the wheel. + if not hasattr(self, 'hole_percent'): + self.hole_percent = 100.0 + + # Toy Kit parameters + # Hole number in Toy Kit notation. Hole #1 is at the edge of the wheel, and the last hole is + # near the center of the wheel, but not exactly at the center. + if not hasattr(self, 'hole_number'): + self.hole_number = 1 + if not hasattr(self, 'kit_fixed_gear_index'): + self.kit_fixed_gear_index = 1 + if not hasattr(self, 'kit_moving_gear_index'): + self.kit_moving_gear_index = 1 + + # Visual notation parameters + if not hasattr(self, 'petals'): + self.petals = 5 + if not hasattr(self, 'petal_skip'): + self.petal_skip = 2 + if not hasattr(self, 'doughnut_hole'): + self.doughnut_hole = 50.0 + if not hasattr(self, 'doughnut_width'): + self.doughnut_width = 50.0 + + # Shape + if not hasattr(self, 'shape_index'): + self.shape_index = 0 # Index in the shapes array + if not hasattr(self, 'sides'): + self.sides = 5 + if not hasattr(self, 'morph'): + self.morph = 0.5 + if not hasattr(self, 'shape_rotation'): + self.shape_rotation = 0 + + if not hasattr(self, 'equal_w_h'): + self.equal_w_h = False + if not hasattr(self, 'margin_pixels'): + self.margin_pixels = 0 # Distance between the drawn shape, and the selection borders. + + # Drawing style + if not hasattr(self, 'tool_index'): + self.tool_index = 0 # Index in the tools array. + if not hasattr(self, 'long_gradient'): + self.long_gradient = False + + if not hasattr(self, 'save_option'): + self.save_option = SAVE_AS_NEW_LAYER + + def kit_max_hole_number(self): + return wheel[self.kit_moving_gear_index][1] + + +# Handle shelving of plugin parameters + +def unshelf_parameters(): + if shelf.has_key("p"): + parameters = shelf["p"] + parameters.__init__() # Fill in missing values with defaults. + return parameters + + return PatternParameters() + + +def shelf_parameters(pp): + shelf["p"] = pp + + +class ComputedParameters: + """ + Stores computations performed on a PatternParameters object. + The results of these computations are used to perform the drawing. + Having all these computations in one place makes it convenient to pass + around as a parameter. + + If the pattern parameters should result in multiple pattern to be drawn, the + compute parameters also stores which one is currently being drawn. + """ + + def __init__(self, pp, img): + + def compute_gradients(): + self.use_gradient = self.pp.long_gradient and tools[self.pp.tool_index].can_color + + # If gradient is used, determine how the lines are two be split to different colors. + if self.use_gradient: + # We want to use enough samples to be beautiful, but not too many, that would + # force us to make many separate calls for drawing the pattern. + if self.rotations > 30: + self.chunk_num = self.rotations + self.chunk_size_lines = self.fixed_gear_teeth + else: + # Lets try to find a chunk size, such that it divides num_lines, and we get at least 30 chunks. + # In the worse case, we will just use "1" + for chunk_size in range(self.fixed_gear_teeth - 1, 0, -1): + if self.num_lines % chunk_size == 0: + if self.num_lines / chunk_size > 30: + break + + self.chunk_num = self.num_lines / chunk_size + self.chunk_size_lines = chunk_size + + self.gradients = get_gradient_samples(self.chunk_num) + else: + self.chunk_num, self.chunk_size_lines = None, None + + def compute_sizes(): + # Get rid of the margins. + self.x1 = x1 + pp.margin_pixels + self.y1 = y1 + pp.margin_pixels + self.x2 = x2 - pp.margin_pixels + self.y2 = y2 - pp.margin_pixels + + # Compute size and position of the pattern + self.x_half_size, self.y_half_size = (self.x2 - self.x1) / 2, (self.y2 - self.y1) / 2 + self.x_center, self.y_center = (self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0 + + if pp.equal_w_h: + if self.x_half_size < self.y_half_size: + self.y_half_size = self.x_half_size + self.y1, self.y2 = self.y_center - self.y_half_size, self.y_center + self.y_half_size + elif self.x_half_size > self.y_half_size: + self.x_half_size = self.y_half_size + self.x1, self.x2 = self.x_center - self.x_half_size, self.x_center + self.x_half_size + + # Find the distance between the hole and the center of the inner circle. + # To do this, we compute the size of the gears, by the number of teeth. + # The circumference of the outer ring is 2 * pi * outer_R = #fixed_gear_teeth * tooth size. + outer_R = min(self.x_half_size, self.y_half_size) + if self.pp.pattern_notation == VISUAL_NOTATION: + doughnut_width = self.pp.doughnut_width + if doughnut_width + self.pp.doughnut_hole > 100: + doughnut_width = 100.0 - self.pp.doughnut_hole + + # Let R, r be the radius of fixed and moving gear, and let hp be the hole percent. + # Let dwp, dhp be the doughnut width and hole in percents of R. + # The two sides of the following equation calculate how to reach the center of the moving + # gear from the center of the fixed gear: + # I) R * (dhp/100 + dwp/100/2) = R - r + # The following equation expresses which r and hp would generate a doughnut of width dw. + # II) R * dw/100 = 2 * r * hp/100 + # We solve the two above equations to calculate hp and r: + self.hole_percent = doughnut_width / (2.0 * (1 - (self.pp.doughnut_hole + doughnut_width/2.0)/100.0)) + self.moving_gear_radius = outer_R * doughnut_width / (2 * self.hole_percent) + else: + size_of_tooth_in_pixels = two_pi * outer_R / self.fixed_gear_teeth + self.moving_gear_radius = size_of_tooth_in_pixels * self.moving_gear_teeth / two_pi + + self.hole_dist_from_center = self.hole_percent / 100.0 * self.moving_gear_radius + + self.pp = pp + + # Check if the shape is made of multiple shapes, as in using Selection as fixed gear. + if (isinstance(shapes[self.pp.shape_index], SelectionShape) and + curve_types[self.pp.curve_type].supports_shapes()): + shapes[self.pp.shape_index].process_selection(img) + pdb.gimp_displays_flush() + self.num_drawings = shapes[self.pp.shape_index].get_num_drawings() + else: + self.num_drawings = 1 + self.current_drawing = 0 + + # Get bounds. We don't care weather a selection exists or not. + exists, x1, y1, x2, y2 = pdb.gimp_selection_bounds(img) + + # Combine different ways to specify patterns, into a unified set of computed parameters. + self.num_notation_drawings = 1 + self.current_notation_drawing = 0 + if self.pp.pattern_notation == GEAR_NOTATION: + self.fixed_gear_teeth = int(round(pp.outer_teeth)) + self.moving_gear_teeth = int(round(pp.inner_teeth)) + self.petals = self.num_petals() + self.hole_percent = pp.hole_percent + elif self.pp.pattern_notation == TOY_KIT_NOTATION: + self.fixed_gear_teeth = ring_teeth[pp.kit_fixed_gear_index] + self.moving_gear_teeth = wheel[pp.kit_moving_gear_index][0] + self.petals = self.num_petals() + # We want to map hole #1 to 100% and hole of max_hole_number to 2.5% + # We don't want 0% because that would be the exact center of the moving gear, + # and that would create a boring pattern. + max_hole_number = wheel[pp.kit_moving_gear_index][1] + self.hole_percent = (max_hole_number - pp.hole_number) / float(max_hole_number - 1) * 97.5 + 2.5 + elif self.pp.pattern_notation == VISUAL_NOTATION: + self.petals = pp.petals + self.fixed_gear_teeth = pp.petals + self.moving_gear_teeth = pp.petals - pp.petal_skip + if self.moving_gear_teeth < 20: + self.fixed_gear_teeth *= 10 + self.moving_gear_teeth *= 10 + self.hole_percent = 100.0 + self.num_notation_drawings = fractions.gcd(pp.petals, pp.petal_skip) + self.notation_drawings_rotation = two_pi/pp.petals + + # Rotations + self.shape_rotation_radians = self.radians_from_degrees(pp.shape_rotation) + self.pattern_rotation_start_radians = self.radians_from_degrees(pp.pattern_rotation) + self.pattern_rotation_radians = self.pattern_rotation_start_radians + # Additional fixed pattern rotation for lissajous. + self.lissajous_rotation = two_pi/self.petals/4.0 + + # Compute the total number of teeth we have to go over. + # Another way to view it is the total of lines we are going to draw. + # To find this we compute the Least Common Multiplier. + self.num_lines = lcm(self.fixed_gear_teeth, self.moving_gear_teeth) + # The number of points we are going to compute. This is the number of lines, plus 1, because to draw + # a line we need two points. + self.num_points = self.num_lines + 1 + + # Compute gradients. + + # The number or rotations needed in order to complete the pattern. + # Each rotation has cp.fixed_gear_teeth points + 1 points. + self.rotations = self.num_lines / self.fixed_gear_teeth + + compute_gradients() + + # Computations needed for the actual drawing of the patterns - how much should we advance each angle + # in each step of the computation. + + # How many radians is each tooth of outer gear. This is also the amount that we + # will step in the iterations that generate the points of the pattern. + self.oangle_factor = two_pi / self.fixed_gear_teeth + # How many radians should the moving gear be moved, for each tooth of the fixed gear + angle_factor = curve_types[pp.curve_type].get_angle_factor(self) + self.iangle_factor = self.oangle_factor * angle_factor + + compute_sizes() + + def num_petals(self): + """ The number of 'petals' (or points) that will be produced by a spirograph drawing. """ + return lcm(self.fixed_gear_teeth, self.moving_gear_teeth) / self.moving_gear_teeth + + def radians_from_degrees(self, degrees): + positive_degrees = degrees if degrees >= 0 else degrees + 360 + return radians(positive_degrees) + + def get_color(self, n): + return self.gradients[4*n:4*(n+1)] + + def next_drawing(self): + """ Multiple drawings can be drawn either when the selection is used as a fixed + gear, and/or the visual tab is used, which causes multiple drawings + to be drawn at different rotations. """ + if self.current_notation_drawing < self.num_notation_drawings - 1: + self.current_notation_drawing += 1 + self.pattern_rotation_radians = self.pattern_rotation_start_radians + ( + self.current_notation_drawing * self.notation_drawings_rotation) + else: + self.current_drawing += 1 + self.current_notation_drawing = 0 + self.pattern_rotation_radians = self.pattern_rotation_start_radians + + def has_more_drawings(self): + return (self.current_notation_drawing < self.num_notation_drawings - 1 or + self.current_drawing < self.num_drawings - 1) + + +### Curve types + + +class CurveType: + + def supports_shapes(self): + return True + +class RouletteCurveType(CurveType): + + def get_strokes(self, p, cp): + strokes = [] + for curr_tooth in range(cp.num_points): + iangle = fmod(curr_tooth * cp.iangle_factor + cp.pattern_rotation_radians, two_pi) + oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, two_pi) + + x, y = shapes[p.shape_index].get_center_of_moving_gear(oangle) + strokes.append(x + cp.hole_dist_from_center * cos(iangle)) + strokes.append(y + cp.hole_dist_from_center * sin(iangle)) + + return strokes + + +class SpyroCurveType(RouletteCurveType): + name = _("Spyrograph") + + def get_angle_factor(self, cp): + return - (cp.fixed_gear_teeth - cp.moving_gear_teeth) / float(cp.moving_gear_teeth) + + +class EpitrochoidCurvetype(RouletteCurveType): + name = _("Epitrochoid") + + def get_angle_factor(self, cp): + return (cp.fixed_gear_teeth + cp.moving_gear_teeth) / float(cp.moving_gear_teeth) + + +class SineCurveType(CurveType): + name = _("Sine") + + def get_angle_factor(self, cp): + return cp.fixed_gear_teeth / float(cp.moving_gear_teeth) + + def get_strokes(self, p, cp): + strokes = [] + for curr_tooth in range(cp.num_points): + iangle = curr_tooth * cp.iangle_factor + oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, two_pi) + + dist = cp.moving_gear_radius + sin(iangle) * cp.hole_dist_from_center + x, y = shapes[p.shape_index].get_center_of_moving_gear(oangle, dist) + strokes.append(x) + strokes.append(y) + + return strokes + + +class LissaCurveType: + name = _("Lissajous") + + def get_angle_factor(self, cp): + return cp.fixed_gear_teeth / float(cp.moving_gear_teeth) + + def get_strokes(self, p, cp): + strokes = [] + for curr_tooth in range(cp.num_points): + iangle = curr_tooth * cp.iangle_factor + # Adding the cp.lissajous_rotation rotation makes the pattern have the same number of curves + # as the other curve types. Without it, many lissajous patterns would redraw the same lines twice, + # and thus look less dense than the other curves. + oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians + cp.lissajous_rotation, two_pi) + + strokes.append(cp.x_center + cp.x_half_size * cos(oangle)) + strokes.append(cp.y_center + cp.y_half_size * cos(iangle)) + + return strokes + + def supports_shapes(self): + return False + + +curve_types = [SpyroCurveType(), EpitrochoidCurvetype(), SineCurveType(), LissaCurveType()] + +# Drawing engine. Also implements drawing incrementally. +# We don't draw the entire stroke, because it could take several seconds, +# Instead, we break it into chunks. Incremental drawing is also used for drawing gradients. +class DrawingEngine: + + def __init__(self, img, p): + self.img, self.p = img, p + self.cp = None + + # For incremental drawing + self.strokes = [] + self.start = 0 + self.chunk_size_lines = 600 + self.chunk_no = 0 + # We are aiming for the drawing time of a chunk to be no longer than max_time. + self.max_time_sec = 0.1 + + self.dynamic_chunk_size = True + + def pre_draw(self): + """ Needs to be called before starting to draw a pattern. """ + + self.cp = ComputedParameters(self.p, self.img) + + def draw_full(self, layer): + """ Non incremental drawing. """ + + self.pre_draw() + self.img.undo_group_start() + + while true: + self.set_strokes() + + if self.cp.use_gradient: + while self.has_more_strokes(): + self.draw_next_chunk(layer, fetch_next_drawing=False) + else: + tools[self.p.tool_index].draw(layer, self.strokes) + + if self.cp.has_more_drawings(): + self.cp.next_drawing() + else: + break + + self.img.undo_group_end() + + pdb.gimp_displays_flush() + + # Methods for incremental drawing. + + def draw_next_chunk(self, layer, fetch_next_drawing=True, tool=None): + stroke_chunk, color = self.next_chunk(fetch_next_drawing) + if not tool: + tool = tools[self.p.tool_index] + tool.draw(layer, stroke_chunk, color) + return len(stroke_chunk) + + def set_strokes(self): + """ Compute the strokes of the current pattern. The heart of the plugin. """ + + shapes[self.p.shape_index].configure(self.img, self.p, self.cp) + + self.strokes = curve_types[self.p.curve_type].get_strokes(self.p, self.cp) + + self.start = 0 + self.chunk_no = 0 + + if self.cp.use_gradient: + self.chunk_size_lines = self.cp.chunk_size_lines + self.dynamic_chunk_size = False + else: + self.dynamic_chunk_size = True + + def reset_incremental(self): + """ Setup incremental drawing to start drawing from scratch. """ + self.pre_draw() + self.set_strokes() + + def next_chunk(self, fetch_next_drawing): + + # chunk_size_lines, is the number of lines we want to draw. We need 1 extra point to draw that. + end = self.start + (self.chunk_size_lines + 1) * 2 + if end > len(self.strokes): + end = len(self.strokes) + result = self.strokes[self.start:end] + # Promote the start to the last point. This is the start of the first line to draw next time. + self.start = end - 2 + color = self.cp.get_color(self.chunk_no) if self.cp.use_gradient else None + + self.chunk_no += 1 + + # If self.strokes has ended, lets fetch strokes for the next drawing. + if fetch_next_drawing and not self.has_more_strokes(): + if self.cp.has_more_drawings(): + self.cp.next_drawing() + self.set_strokes() + + return result, color + + def has_more_strokes(self): + return self.start + 2 < len(self.strokes) + + # Used for displaying progress. + def fraction_done(self): + return (self.start + 2.0) / len(self.strokes) + + def report_time(self, time_sec): + """ + Report the time it took, in seconds, to draw the last stroke chunk. + This helps to determine the size of chunks to return in future calls of 'next_chunk', + since we want the calls to be short, to not make the user interface feel stuck. + """ + if time_sec != 0 and self.dynamic_chunk_size: + self.chunk_size_lines = int(self.chunk_size_lines * self.max_time_sec / time_sec) + # Don't let chunk size be too large or small. + self.chunk_size_lines = max(10, self.chunk_size_lines) + self.chunk_size_lines = min(1000, self.chunk_size_lines) + + +# Constants for DoughnutWidget + +# Enum - When the mouse is pressed, which target value is being changed. +TARGET_NONE, TARGET_HOLE, TARGET_WIDTH = range(3) + +CIRCLE_CENTER_X = 4 +RIGHT_MARGIN = 2 +TOTAL_MARGIN = CIRCLE_CENTER_X + RIGHT_MARGIN + +# A widget for displaying and setting the pattern of a spirograph, using a "doughnut" as +# a visual metaphore. This widget replaces two scale widgets. +class DoughnutWidget(gtk.DrawingArea): + __gtype_name__ = 'DoughnutWidget' + + def __init__(self, *args, **kwds): + super(DoughnutWidget, self).__init__(*args, **kwds) + self.set_size_request(80, 40) + + self.add_events( + gdk.BUTTON1_MOTION_MASK | + gdk.BUTTON_PRESS_MASK | + gdk.BUTTON_RELEASE_MASK | + gdk.POINTER_MOTION_MASK + ) + + self.default_cursor = self.get_screen().get_root_window().get_cursor() + self.resize_cursor = gdk.Cursor(gdk.SB_H_DOUBLE_ARROW) + + self.button_pressed = False + self.target = TARGET_NONE + + self.hole_radius = 30 + self.doughnut_width = 30 + self.connect("expose-event", self.expose) + + def set_hole_radius(self, hole_radius): + self.queue_draw() + self.hole_radius = hole_radius + + def get_hole_radius(self): + return self.hole_radius + + def set_width(self, width): + self.queue_draw() + self.doughnut_width = width + + def get_width(self): + return self.doughnut_width + + def compute_doughnut(self): + """ Compute the location of the doughnut circles. + Returns (circle center x, circle center y, radius of inner circle, radius of outer circle) """ + allocation = self.get_allocation() + alloc_width = allocation.width - TOTAL_MARGIN + return ( + CIRCLE_CENTER_X, allocation.height / 2, + alloc_width * self.hole_radius / 100.0, + alloc_width * min(self.hole_radius + self.doughnut_width, 100.0) / 100.0 + ) + + def set_cursor_h_resize(self): + """Set the mouse to be a double arrow.""" + gdk_window = self.get_window() + gdk_window.set_cursor(self.resize_cursor) + + def set_default_cursor(self): + gdk_window = self.get_window() + gdk_window.set_cursor(self.default_cursor) + + def get_target(self, x, y): + # Find out if x, y is over one of the circle edges. + + center_x, center_y, hole_radius, outer_radius = self.compute_doughnut() + + # Compute distance from circle center to point + dist = sqrt((center_x - x) ** 2 + (center_y - y) ** 2) + + if abs(dist - hole_radius) <= 3: + return TARGET_HOLE + if abs(dist - outer_radius) <= 3: + return TARGET_WIDTH + + return TARGET_NONE + + def expose(self, widget, event): + + cr = widget.window.cairo_create() + center_x, center_y, hole_radius, outer_radius = self.compute_doughnut() + fg_color = gtk.widget_get_default_style().fg[gtk.STATE_NORMAL] + + # Draw doughnut interior + arc = pi * 3 / 2.0 + cr.set_source_rgba(fg_color.red, fg_color.green, fg_color.blue, 0.5) + cr.arc(center_x, center_y, hole_radius, -arc, arc) + cr.arc_negative(center_x, center_y, outer_radius, arc, -arc) + cr.close_path() + cr.fill() + + # Draw doughnut border. + cr.set_source_rgb(fg_color.red, fg_color.green, fg_color.blue) + cr.set_line_width(3) + cr.arc_negative(center_x, center_y, outer_radius, arc, -arc) + cr.stroke() + if hole_radius < 1.0: + # If the radius is too small, nothing will be drawn, so draw a small cross marker instead. + cr.set_line_width(2) + cr.move_to(center_x - 4, center_y) + cr.line_to(center_x + 4, center_y) + cr.move_to(center_x, center_y - 4) + cr.line_to(center_x, center_y + 4) + else: + cr.arc(center_x, center_y, hole_radius, -arc, arc) + cr.stroke() + + def compute_new_radius(self, x): + """ This method is called during mouse dragging of the widget. + Compute the new radius based on the current x location of the mouse pointer. """ + allocation = self.get_allocation() + + # How much does a single pixel difference in x, change the radius? + # Note that: allocation.width - TOTAL_MARGIN = 100 radius units, + radius_per_pixel = 100.0 / (allocation.width - TOTAL_MARGIN) + new_radius = self.start_radius + (x - self.start_x) * radius_per_pixel + + if self.target == TARGET_HOLE: + self.hole_radius = max(min(new_radius, 99.0), 0.0) + else: + self.doughnut_width = max(min(new_radius, 100.0), 1.0) + + self.queue_draw() + + def do_button_press_event(self, event): + self.button_pressed = True + + # If we clicked on one of the doughnut borders, remember which + # border we clicked on, and setup variable to start dragging it. + target = self.get_target(event.x, event.y) + if target == TARGET_HOLE or target == TARGET_WIDTH: + self.target = target + self.start_x = event.x + self.start_radius = ( + self.hole_radius if target == TARGET_HOLE else + self.doughnut_width + ) + + def do_button_release_event(self, event): + # If one the doughnut borders was being dragged, recompute the doughnut size. + if self.target != TARGET_NONE: + self.compute_new_radius(event.x) + # Clip the width, if it is too large to fit. + if self.hole_radius + self.doughnut_width > 100: + self.doughnut_width = 100 - self.hole_radius + self.emit("values_changed", self.hole_radius, self.doughnut_width) + + self.button_pressed = False + self.target = TARGET_NONE + + def do_motion_notify_event(self, event): + if self.button_pressed: + # We are dragging one of the doughnut borders; recompute its size. + if self.target != TARGET_NONE: + self.compute_new_radius(event.x) + else: + # Set cursor according to whether we are over one of the + # doughnut borders. + target = self.get_target(event.x, event.y) + if target == TARGET_NONE: + self.set_default_cursor() + else: + self.set_cursor_h_resize() + + +# Create signal that returns change parameters. +gobject.type_register(DoughnutWidget) +gobject.signal_new("values_changed", DoughnutWidget, gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, (gobject.TYPE_INT, gobject.TYPE_INT)) + + +class SpyroWindow(gtk.Window): + + # Define signal to catch escape key. + __gsignals__ = dict( + myescape=(gobject.SIGNAL_RUN_LAST | gobject.SIGNAL_ACTION, + None, # return type + (str,)) # arguments + ) + + class MyScale(): + """ Combintation of scale and spin that control the same adjuster. """ + def __init__(self, scale, spin): + self.scale, self.spin = scale, spin + + def set_sensitive(self, val): + self.scale.set_sensitive(val) + self.spin.set_sensitive(val) + + def __init__(self, img, layer): + + def add_horizontal_separator(vbox): + hsep = gtk.HSeparator() + vbox.add(hsep) + hsep.show() + + def add_vertical_space(vbox, height): + hbox = gtk.HBox() + hbox.set_border_width(height/2) + vbox.add(hbox) + hbox.show() + + def add_to_box(box, w): + box.add(w) + w.show() + + def create_table(rows, columns, border_width): + table = gtk.Table(rows=rows, columns=columns, homogeneous=False) + table.set_border_width(border_width) + table.set_col_spacings(10) + table.set_row_spacings(10) + return table + + def label_in_table(label_text, table, row, tooltip_text=None, col=0, col_add=1): + """ Create a label and set it in first col of table. """ + label = gtk.Label(label_text) + label.set_alignment(xalign=0.0, yalign=1.0) + if tooltip_text: + label.set_tooltip_text(tooltip_text) + table.attach(label, col, col + col_add, row, row + 1, xoptions=gtk.FILL, yoptions=0) + label.show() + + def spin_in_table(adj, table, row, callback, digits=0, col=0): + spin = gtk.SpinButton(adj, climb_rate=0.5, digits=digits) + spin.set_numeric(True) + spin.set_snap_to_ticks(True) + spin.set_max_length(5) + spin.set_width_chars(5) + table.attach(spin, col, col + 1, row, row + 1, xoptions=0, yoptions=0) + spin.show() + adj.connect("value_changed", callback) + return spin + + def hscale_in_table(adj, table, row, callback, digits=0, col=1, cols=1): + """ Create an hscale and a spinner using the same Adjustment, and set it in table. """ + scale = gtk.HScale(adj) + scale.set_size_request(150, -1) + scale.set_digits(digits) + scale.set_update_policy(gtk.UPDATE_DISCONTINUOUS) + table.attach(scale, col, col + cols, row, row + 1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0) + scale.show() + + spin = gtk.SpinButton(adj, climb_rate=0.5, digits=digits) + spin.set_numeric(True) + spin.set_snap_to_ticks(True) + spin.set_max_length(5) + spin.set_width_chars(5) + table.attach(spin, col + cols , col + cols + 1, row, row + 1, xoptions=0, yoptions=0) + spin.show() + + adj.connect("value_changed", callback) + + return self.MyScale(scale, spin) + + def rotation_in_table(val, table, row, callback): + adj = gtk.Adjustment(val, -180.0, 180.0, 1.0) + myscale = hscale_in_table(adj, table, row, callback, digits=1) + myscale.scale.add_mark(0.0, gtk.POS_BOTTOM, None) + myscale.spin.set_max_length(6) + myscale.spin.set_width_chars(6) + return adj, myscale + + def set_combo_in_table(txt_list, table, row, callback): + combo = gtk.combo_box_new_text() + for txt in txt_list: + combo.append_text(txt) + table.attach(combo, 1, 2, row, row + 1, xoptions=gtk.FILL, yoptions=0) + combo.show() + combo.connect("changed", callback) + return combo + + # Return table which is at the top of the dialog, and has several major input widgets. + def top_table(): + + # Add table for displaying attributes, each having a label and an input widget. + table = create_table(2, 3, 10) + + # Curve type + row = 0 + label_in_table(_("Curve Type"), table, row, + _("An Epitrochoid pattern is when the moving gear is on the outside of the fixed gear.")) + self.curve_type_combo = set_combo_in_table([ct.name for ct in curve_types], table, row, + self.curve_type_changed) + + row += 1 + label_in_table(_("Tool"), table, row, + _("The tool with which to draw the pattern. " + "The Preview tool just draws quickly.")) + self.tool_combo = set_combo_in_table([tool.name for tool in tools], table, row, + self.tool_combo_changed) + + self.long_gradient_checkbox = gtk.CheckButton(_("Long Gradient")) + self.long_gradient_checkbox.set_tooltip_text( + _("When unchecked, the current tool settings will be used. " + "When checked, will use a long gradient to match the length of the pattern, " + "based on current gradient and repeat mode from the gradient tool settings.") + ) + self.long_gradient_checkbox.set_border_width(0) + table.attach(self.long_gradient_checkbox, 2, 3, row, row + 1, xoptions=0, yoptions=0) + self.long_gradient_checkbox.show() + self.long_gradient_checkbox.connect("toggled", self.long_gradient_changed) + + return table + + def pattern_notation_frame(): + + vbox = gtk.VBox(spacing=0, homogeneous=False) + + add_vertical_space(vbox, 14) + + hbox = gtk.HBox(spacing=5) + hbox.set_border_width(5) + + label = gtk.Label(_("Specify pattern using one of the following tabs:")) + label.set_tooltip_text(_( + "The pattern is specified only by the active tab. Toy Kit is similar to Gears, " + "but it uses gears and hole numbers which are found in toy kits. " + "If you follow the instructions from the toy kit manuals, results should be similar.")) + hbox.pack_start(label) + label.show() + + alignment = gtk.Alignment(xalign=0.0, yalign=0.0, xscale=0.0, yscale=0.0) + alignment.add(hbox) + hbox.show() + vbox.add(alignment) + alignment.show() + + self.pattern_notebook = gtk.Notebook() + self.pattern_notebook.set_border_width(0) + self.pattern_notebook.connect('switch-page', self.pattern_notation_tab_changed) + + # "Gear" pattern notation. + + # Add table for displaying attributes, each having a label and an input widget. + gear_table = create_table(3, 3, 5) + + # Teeth + row = 0 + fixed_gear_tooltip = _( + "Number of teeth of fixed gear. The size of the fixed gear is " + "proportional to the number of teeth." + ) + label_in_table(_("Fixed Gear Teeth"), gear_table, row, fixed_gear_tooltip) + self.outer_teeth_adj = gtk.Adjustment(self.p.outer_teeth, 10, 180, 1) + hscale_in_table(self.outer_teeth_adj, gear_table, row, self.outer_teeth_changed) + + row += 1 + moving_gear_tooltip = _( + "Number of teeth of moving gear. The size of the moving gear is " + "proportional to the number of teeth." + ) + label_in_table(_("Moving Gear Teeth"), gear_table, row, moving_gear_tooltip) + self.inner_teeth_adj = gtk.Adjustment(self.p.inner_teeth, 2, 100, 1) + hscale_in_table(self.inner_teeth_adj, gear_table, row, self.inner_teeth_changed) + + row += 1 + label_in_table(_("Hole percent"), gear_table, row, + _("How far is the hole from the center of the moving gear. " + "100% means that the hole is at the gear's edge.")) + self.hole_percent_adj = gtk.Adjustment(self.p.hole_percent, 2.5, 100.0, 0.5) + self.hole_percent_myscale = hscale_in_table(self.hole_percent_adj, gear_table, + row, self.hole_percent_changed, digits=1) + + # "Kit" pattern notation. + + kit_table = create_table(3, 3, 5) + + row = 0 + label_in_table(_("Fixed Gear Teeth"), kit_table, row, fixed_gear_tooltip) + self.kit_outer_teeth_combo = set_combo_in_table([str(t) for t in ring_teeth], kit_table, row, + self.kit_outer_teeth_combo_changed) + + row += 1 + label_in_table(_("Moving Gear Teeth"), kit_table, row, moving_gear_tooltip) + self.kit_inner_teeth_combo = set_combo_in_table([str(t) for t in wheel_teeth], kit_table, row, + self.kit_inner_teeth_combo_changed) + + row += 1 + label_in_table(_("Hole Number"), kit_table, row, + _("Hole #1 is at the edge of the gear. " + "The maximum hole number is near the center. " + "The maximum hole number is different for each gear.")) + self.kit_hole_adj = gtk.Adjustment(self.p.hole_number, 1, self.p.kit_max_hole_number(), 1) + self.kit_hole_myscale = hscale_in_table(self.kit_hole_adj, kit_table, row, self.kit_hole_changed) + + # "Visual" pattern notation. + + visual_table = create_table(3, 5, 5) + + row = 0 + label_in_table(_("Flower Petals"), visual_table, row, _("The number of petals in the pattern.")) + self.petals_adj = gtk.Adjustment(self.p.petals, 2, 100, 1) + hscale_in_table(self.petals_adj, visual_table, row, self.petals_changed, cols=3) + + row += 1 + label_in_table(_("Petal Skip"), visual_table, row, + _("The number of petals to advance for drawing the next petal.")) + self.petal_skip_adj = gtk.Adjustment(self.p.petal_skip, 1, 50, 1) + hscale_in_table(self.petal_skip_adj, visual_table, row, self.petal_skip_changed, cols=3) + + row += 1 + label_in_table(_("Hole Radius(%)"), visual_table, row, + _("The radius of the hole in the center of the pattern " + "where nothing will be drawn. Given as a percentage of the " + "size of the pattern. A value of 0 will produce no hole. " + "A Value of 99 will produce a thin line on the edge.")) + self.doughnut_hole_adj = gtk.Adjustment(self.p.doughnut_hole, 0.0, 99.0, 0.1) + self.doughnut_hole_myscale = spin_in_table(self.doughnut_hole_adj, + visual_table, row, self.doughnut_hole_changed, 1, 1) + + self.doughnut = DoughnutWidget() + visual_table.attach(self.doughnut, 2, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0) + self.doughnut.connect('values_changed', self.doughnut_changed) + self.doughnut.show() + + label_in_table(_("Width(%)"), visual_table, row, + _("The width of the pattern as a percentage of the " + "size of the pattern. A Value of 1 will just draw a thin pattern. " + "A Value of 100 will fill the entire fixed gear."), 3) + self.doughnut_width_adj = gtk.Adjustment(self.p.doughnut_width, 1.0, 100.0, 0.1) + self.doughnut_width_myscale = spin_in_table(self.doughnut_width_adj, + visual_table, row, self.doughnut_width_changed, 1, 4) + + # Add tables as children of the pattern notebook + + pattern_notation_page[VISUAL_NOTATION] = self.pattern_notebook.append_page(visual_table) + self.pattern_notebook.set_tab_label_text(visual_table, _("Visual")) + self.pattern_notebook.set_tab_label_packing(visual_table, 0, 0, gtk.PACK_END) + visual_table.show() + + pattern_notation_page[TOY_KIT_NOTATION] = self.pattern_notebook.append_page(kit_table) + self.pattern_notebook.set_tab_label_text(kit_table, _("Toy Kit")) + self.pattern_notebook.set_tab_label_packing(kit_table, 0, 0, gtk.PACK_END) + kit_table.show() + + pattern_notation_page[GEAR_NOTATION] = self.pattern_notebook.append_page(gear_table) + self.pattern_notebook.set_tab_label_text(gear_table, _("Gears")) + self.pattern_notebook.set_tab_label_packing(gear_table, 0, 0, gtk.PACK_END) + gear_table.show() + + add_to_box(vbox, self.pattern_notebook) + + add_vertical_space(vbox, 14) + + hbox = gtk.HBox(spacing=5) + pattern_table = create_table(1, 3, 5) + + row = 0 + label_in_table(_("Rotation"), pattern_table, row, + _("Rotation of the pattern, in degrees. " + "The starting position of the moving gear in the fixed gear.")) + self.pattern_rotation_adj, myscale = rotation_in_table( + self.p.pattern_rotation, pattern_table, row, self.pattern_rotation_changed + ) + + hbox.pack_end(pattern_table, expand=True, fill=True, padding=0) + pattern_table.show() + + vbox.add(hbox) + hbox.show() + + return vbox + + def fixed_gear_page(): + + vbox = gtk.VBox(spacing=0, homogeneous=False) + + add_vertical_space(vbox, 14) + + table = create_table(4, 2, 10) + + row = 0 + label_in_table(_("Shape"), table, row, + _("The shape of the fixed gear to be used inside current selection. " + "Rack is a long round-edged shape provided in the toy kits. " + "Frame hugs the boundaries of the rectangular selection, " + "use hole=100 in Gear notation to touch boundary. " + "Selection will hug boundaries of current selection - try something non-rectangular.")) + self.shape_combo = set_combo_in_table([shape.name for shape in shapes], table, row, + self.shape_combo_changed) + + row += 1 + label_in_table(_("Sides"), table, row, _("Number of sides of the shape.")) + self.sides_adj = gtk.Adjustment(self.p.sides, 3, 16, 1) + self.sides_myscale = hscale_in_table(self.sides_adj, table, row, self.sides_changed) + + row += 1 + label_in_table(_("Morph"), table, row, _("Morph fixed gear shape. Only affects some of the shapes.")) + self.morph_adj = gtk.Adjustment(self.p.morph, 0.0, 1.0, 0.01) + self.morph_myscale = hscale_in_table(self.morph_adj, table, row, self.morph_changed, digits=2) + + row += 1 + label_in_table(_("Rotation"), table, row, _("Rotation of the fixed gear, in degrees")) + self.shape_rotation_adj, self.shape_rotation_myscale = rotation_in_table( + self.p.shape_rotation, table, row, self.shape_rotation_changed + ) + + add_to_box(vbox, table) + return vbox + + def size_page(): + + vbox = gtk.VBox(spacing=0, homogeneous=False) + add_vertical_space(vbox, 14) + table = create_table(2, 2, 10) + + row = 0 + label_in_table(_("Margin (px)"), table, row, _("Margin from edge of selection.")) + self.margin_adj = gtk.Adjustment(self.p.margin_pixels, 0, max(img.height, img.width), 1) + hscale_in_table(self.margin_adj, table, row, self.margin_changed) + + row += 1 + self.equal_w_h_checkbox = gtk.CheckButton(_("Make width and height equal")) + self.equal_w_h_checkbox.set_tooltip_text( + _("When unchecked, the pattern will fill the current image or selection. " + "When checked, the pattern will have same width and height, and will be centered.") + ) + self.equal_w_h_checkbox.set_border_width(15) + table.attach(self.equal_w_h_checkbox, 0, 2, row, row + 1) + self.equal_w_h_checkbox.show() + self.equal_w_h_checkbox.connect("toggled", self.equal_w_h_checkbox_changed) + + + add_to_box(vbox, table) + return vbox + + def add_button_to_box(box, text, callback, tooltip_text=None): + btn = gtk.Button(text) + if tooltip_text: + btn.set_tooltip_text(tooltip_text) + box.add(btn) + btn.show() + btn.connect("clicked", callback) + return btn + + def dialog_button_box(): + hbox = gtk.HBox(homogeneous=True, spacing=20) + + add_button_to_box(hbox, _("Re_draw"), self.redraw, + _("If you change the settings of a tool, change color, or change the selection, " + "press this to preview how the pattern looks.")) + add_button_to_box(hbox, _("_Reset"), self.reset_params) + add_button_to_box(hbox, _("_Cancel"), self.cancel_window) + self.ok_btn = add_button_to_box(hbox, _("_OK"), self.ok_window) + + self.save_option_combo = gtk.combo_box_new_text() + for txt in save_options: + self.save_option_combo.append_text(txt) + self.save_option_combo.set_tooltip_text( + _("Choose whether to save as new layer, redraw on last active layer, or save to path") + ) + hbox.add(self.save_option_combo) + self.save_option_combo.show() + self.save_option_combo.connect("changed", self.save_option_changed) + + return hbox + + def create_ui(): + + # Create the dialog + gtk.Window.__init__(self) + self.set_title(_("Spyrogimp")) + self.set_default_size(350, -1) + self.set_border_width(10) + # self.set_keep_above(True) # keep the window on top + + # Vertical box in which we will add all the UI elements. + vbox = gtk.VBox(spacing=10, homogeneous=False) + self.add(vbox) + + box = gimpui.HintBox(_("Draw spyrographs using current tool settings and selection.")) + vbox.pack_start(box, expand=False) + box.show() + + add_horizontal_separator(vbox) + + add_to_box(vbox, top_table()) + + self.main_notebook = gtk.Notebook() + self.main_notebook.set_show_tabs(True) + self.main_notebook.set_border_width(5) + + pattern_frame = pattern_notation_frame() + self.main_notebook.append_page(pattern_frame, gtk.Label(_("Curve Pattern"))) + pattern_frame.show() + fixed_g_page = fixed_gear_page() + self.main_notebook.append_page(fixed_g_page, gtk.Label(_("Fixed Gear"))) + fixed_g_page.show() + size_p = size_page() + self.main_notebook.append_page(size_p, gtk.Label(_("Size"))) + size_p.show() + + vbox.add(self.main_notebook) + self.main_notebook.show() + + add_horizontal_separator(vbox) + + self.progress_bar = gtk.ProgressBar() # gimpui.ProgressBar() - causes gimppdbprogress error message. + self.progress_bar.set_size_request(-1, 30) + vbox.add(self.progress_bar) + self.progress_bar.show() + + add_to_box(vbox, dialog_button_box()) + + vbox.show() + self.show() + + self.enable_incremental_drawing = False + + self.img = img + # Remember active layer, so we can restore it when the plugin is done. + self.active_layer = layer + + self.p = unshelf_parameters() # Model + + self.engine = DrawingEngine(img, self.p) + + # Make a new GIMP layer to draw on + self.spyro_layer = gimp.Layer(img, layer_name, img.width, img.height, + layer.type_with_alpha, 100, NORMAL_MODE) + img.add_layer(self.spyro_layer, 0) + + self.drawing_layer = self.spyro_layer + + gimpui.gimp_ui_init() + create_ui() + self.update_view() + + # Obey the window manager quit signal + self.connect("destroy", self.cancel_window) + # Connect Escape key to quit the window as well. + self.connect('myescape', self.cancel_window) + + # Setup for Handling incremental/interactive drawing of pattern + self.idle_task = None + self.enable_incremental_drawing = True + + # Draw pattern of the current settings. + self.start_new_incremental_drawing() + + # Callbacks for closing the plugin + + def clear_idle_task(self): + if self.idle_task: + gobject.source_remove(self.idle_task) + # Close the undo group in the likely case the idle task left it open. + self.img.undo_group_end() + self.idle_task = None + + def ok_window(self, widget): + """ Called when clicking on the 'close' button. """ + + self.ok_btn.set_sensitive(False) + + shelf_parameters(self.p) + + if self.p.save_option == SAVE_AS_NEW_LAYER: + if self.spyro_layer in self.img.layers: + self.img.active_layer = self.spyro_layer + + # If we are in the middle of incremental draw, we want to complete it, and only then to exit. + # However, in order to complete it, we need to create another idle task. + if self.idle_task: + def quit_dialog_on_completion(): + while self.idle_task: + yield True + + gtk.main_quit() # This will quit the dialog. + yield False + + task = quit_dialog_on_completion() + gobject.idle_add(task.next) + else: + gtk.main_quit() + else: + # If there is an incremental drawing taking place, lets stop it. + self.clear_idle_task() + + if self.spyro_layer in self.img.layers: + self.img.remove_layer(self.spyro_layer) + self.img.active_layer = self.active_layer + + self.drawing_layer = self.active_layer + + def draw_full(tool): + self.progress_start() + yield True + + self.engine.reset_incremental() + + self.img.undo_group_start() + + while self.engine.has_more_strokes(): + yield True + self.draw_next_chunk(tool=tool) + + self.img.undo_group_end() + + pdb.gimp_displays_flush() + + gtk.main_quit() + yield False + + tool = SaveToPathTool(self.img) if self.p.save_option == SAVE_AS_PATH else None + task = draw_full(tool) + gobject.idle_add(task.next) + + def cancel_window(self, widget, what=None): + self.clear_idle_task() + + # We want to delete the temporary layer, but as a precaution, lets ask first, + # maybe it was already deleted by the user. + if self.spyro_layer in self.img.layers: + self.img.remove_layer(self.spyro_layer) + pdb.gimp_displays_flush() + gtk.main_quit() + + def update_view(self): + """ Update the UI to reflect the values in the Pattern Parameters. """ + self.curve_type_combo.set_active(self.p.curve_type) + self.curve_type_side_effects() + + self.pattern_notebook.set_current_page(pattern_notation_page[self.p.pattern_notation]) + + self.outer_teeth_adj.set_value(self.p.outer_teeth) + self.inner_teeth_adj.set_value(self.p.inner_teeth) + self.hole_percent_adj.set_value(self.p.hole_percent) + self.pattern_rotation_adj.set_value(self.p.pattern_rotation) + + self.kit_outer_teeth_combo.set_active(self.p.kit_fixed_gear_index) + self.kit_inner_teeth_combo.set_active(self.p.kit_moving_gear_index) + self.kit_hole_adj.set_value(self.p.hole_number) + self.kit_inner_teeth_combo_side_effects() + + self.petals_adj.set_value(self.p.petals) + self.petal_skip_adj.set_value(self.p.petal_skip) + self.doughnut_hole_adj.set_value(self.p.doughnut_hole) + self.doughnut.set_hole_radius(self.p.doughnut_hole) + self.doughnut_width_adj.set_value(self.p.doughnut_width) + self.doughnut.set_width(self.p.doughnut_width) + self.petals_changed_side_effects() + + self.shape_combo.set_active(self.p.shape_index) + self.shape_combo_side_effects() + self.sides_adj.set_value(self.p.sides) + self.morph_adj.set_value(self.p.morph) + self.equal_w_h_checkbox.set_active(self.p.equal_w_h) + self.shape_rotation_adj.set_value(self.p.shape_rotation) + + self.margin_adj.set_value(self.p.margin_pixels) + self.tool_combo.set_active(self.p.tool_index) + self.long_gradient_checkbox.set_active(self.p.long_gradient) + self.save_option_combo.set_active(self.p.save_option) + + def reset_params(self, widget): + self.engine.p = self.p = PatternParameters() + self.update_view() + + # Callbacks to handle changes in dialog parameters. + + def curve_type_side_effects(self): + if curve_types[self.p.curve_type].supports_shapes(): + self.shape_combo.set_sensitive(True) + + self.sides_myscale.set_sensitive(shapes[self.p.shape_index].has_sides()) + self.morph_myscale.set_sensitive(shapes[self.p.shape_index].can_morph()) + self.shape_rotation_myscale.set_sensitive(shapes[self.p.shape_index].can_rotate()) + + self.hole_percent_myscale.set_sensitive(True) + self.kit_hole_myscale.set_sensitive(True) + + self.doughnut_hole_myscale.set_sensitive(True) + self.doughnut_width_myscale.set_sensitive(True) + else: + # Lissajous curves do not have shapes, or holes for moving gear + self.shape_combo.set_sensitive(False) + + self.sides_myscale.set_sensitive(False) + self.morph_myscale.set_sensitive(False) + self.shape_rotation_myscale.set_sensitive(False) + + self.hole_percent_myscale.set_sensitive(False) + self.kit_hole_myscale.set_sensitive(False) + + self.doughnut_hole_myscale.set_sensitive(False) + self.doughnut_width_myscale.set_sensitive(False) + + def curve_type_changed(self, val): + self.p.curve_type = val.get_active() + self.curve_type_side_effects() + self.redraw() + + def pattern_notation_tab_changed(self, notebook, page, page_num, user_param1=None): + if self.enable_incremental_drawing: + for notation in pattern_notation_page: + if pattern_notation_page[notation] == page_num: + self.p.pattern_notation = notation + + self.redraw() + + # Callbacks: pattern changes using the Toy Kit notation. + + def kit_outer_teeth_combo_changed(self, val): + self.p.kit_fixed_gear_index = val.get_active() + self.redraw() + + def kit_inner_teeth_combo_side_effects(self): + # Change the max hole number according to the newly activated wheel. + # We might also need to update the hole value, if it is larger than the new max. + max_hole_number = self.p.kit_max_hole_number() + if self.p.hole_number > max_hole_number: + self.p.hole_number = max_hole_number + self.kit_hole_adj.set_value(max_hole_number) + self.kit_hole_adj.set_upper(max_hole_number) + + def kit_inner_teeth_combo_changed(self, val): + self.p.kit_moving_gear_index = val.get_active() + self.kit_inner_teeth_combo_side_effects() + self.redraw() + + def kit_hole_changed(self, val): + self.p.hole_number = val.value + self.redraw() + + # Callbacks: pattern changes using the Gears notation. + + def outer_teeth_changed(self, val): + self.p.outer_teeth = val.value + self.redraw() + + def inner_teeth_changed(self, val): + self.p.inner_teeth = val.value + self.redraw() + + def hole_percent_changed(self, val): + self.p.hole_percent = val.value + self.redraw() + + def pattern_rotation_changed(self, val): + self.p.pattern_rotation = val.value + self.redraw() + + # Callbacks: pattern changes using the Visual notation. + + def petals_changed_side_effects(self): + max_petal_skip = int(self.p.petals/2) + if self.p.petal_skip > max_petal_skip: + self.p.petal_skip = max_petal_skip + self.petal_skip_adj.set_value(max_petal_skip) + self.petal_skip_adj.set_upper(max_petal_skip) + + def petals_changed(self, val): + self.p.petals = int(val.value) + self.petals_changed_side_effects() + self.redraw() + + def petal_skip_changed(self, val): + self.p.petal_skip = int(val.value) + self.redraw() + + def doughnut_hole_changed(self, val): + self.p.doughnut_hole = val.value + self.doughnut.set_hole_radius(val.value) + self.redraw() + + def doughnut_width_changed(self, val): + self.p.doughnut_width = val.value + self.doughnut.set_width(val.value) + self.redraw() + + def doughnut_changed(self, widget, hole, width): + self.doughnut_hole_adj.set_value(hole) + self.doughnut_width_adj.set_value(width) + # We don't need to redraw, because the callbacks of the doughnut hole and + # width spinners will be triggered by the above lines. + + # Callbacks: Fixed gear + + def shape_combo_side_effects(self): + self.sides_myscale.set_sensitive(shapes[self.p.shape_index].has_sides()) + self.morph_myscale.set_sensitive(shapes[self.p.shape_index].can_morph()) + self.shape_rotation_myscale.set_sensitive(shapes[self.p.shape_index].can_rotate()) + self.equal_w_h_checkbox.set_sensitive(shapes[self.p.shape_index].can_equal_w_h()) + + def shape_combo_changed(self, val): + self.p.shape_index = val.get_active() + self.shape_combo_side_effects() + self.redraw() + + def sides_changed(self, val): + self.p.sides = val.value + self.redraw() + + def morph_changed(self, val): + self.p.morph = val.value + self.redraw() + + def equal_w_h_checkbox_changed(self, val): + self.p.equal_w_h = val.get_active() + self.redraw() + + def shape_rotation_changed(self, val): + self.p.shape_rotation = val.value + self.redraw() + + def margin_changed(self, val) : + self.p.margin_pixels = val.value + self.redraw() + + # Style callbacks + + def tool_changed_side_effects(self): + self.long_gradient_checkbox.set_sensitive(tools[self.p.tool_index].can_color) + + def tool_combo_changed(self, val): + self.p.tool_index = val.get_active() + self.tool_changed_side_effects() + self.redraw() + + def long_gradient_changed(self, val): + self.p.long_gradient = val.get_active() + self.redraw() + + def save_option_changed(self, val): + self.p.save_option = self.save_option_combo.get_active() + + # Progress bar of plugin window. + + def progress_start(self): + self.progress_bar.set_text(_("Rendering Pattern")) + self.progress_bar.set_fraction(0.0) + pdb.gimp_displays_flush() + + def progress_end(self): + self.progress_bar.set_text("") + self.progress_bar.set_fraction(0.0) + + def progress_update(self): + self.progress_bar.set_fraction(self.engine.fraction_done()) + + def progress_unknown(self): + self.progress_bar.set_text(_("Please wait : Rendering Pattern")) + self.progress_bar.pulse() + pdb.gimp_displays_flush() + + # Incremental drawing. + + def draw_next_chunk(self, tool=None): + """ Incremental drawing """ + + t = time.time() + + chunk_size = self.engine.draw_next_chunk(self.drawing_layer, tool=tool) + + draw_time = time.time() - t + self.engine.report_time(draw_time) + print("Chunk size " + str(chunk_size) + " time " + str(draw_time)) + + if self.engine.has_more_strokes(): + self.progress_update() + else: + self.progress_end() + + pdb.gimp_displays_flush() + + def start_new_incremental_drawing(self): + """ + Compute strokes for the current pattern, and store then in the IncrementalDraw object, + so they can be drawn in pieces without blocking the user. + Finally, draw the first chunk of strokes. + """ + + def incremental_drawing(): + self.progress_start() + yield True + self.engine.reset_incremental() + + self.img.undo_group_start() + while self.engine.has_more_strokes(): + yield True + self.draw_next_chunk() + self.img.undo_group_end() + + self.idle_task = None + yield False + + # Start new idle task to perform incremental drawing in the background. + self.clear_idle_task() + task = incremental_drawing() + self.idle_task = gobject.idle_add(task.next) + + def clear(self): + """ Clear current drawing. """ + # pdb.gimp_edit_clear(self.spyro_layer) + self.spyro_layer.fill(FILL_TRANSPARENT) + + def redraw(self, data=None): + if self.enable_incremental_drawing: + self.clear() + self.start_new_incremental_drawing() + + +# Bind escape to the new signal we created, named "myescape". +gobject.type_register(SpyroWindow) +gtk.binding_entry_add_signal(SpyroWindow, gtk.keysyms.Escape, 0, 'myescape', str, 'escape') + + +class SpyrogimpPlusPlugin(gimpplugin.plugin): + + # Implementation of plugin. + def plug_in_spyrogimp(self, run_mode, image, layer, + curve_type=0, shape=0, sides=3, morph=0.0, + fixed_teeth=96, moving_teeth=36, hole_percent=100.0, + margin=0, equal_w_h=0, + pattern_rotation=0.0, shape_rotation=0.0, + tool=1, long_gradient=False): + if run_mode == RUN_NONINTERACTIVE: + pp = PatternParameters() + pp.curve_type = curve_type + pp.shape_index = shape + pp.sides = sides + pp.morph = morph + pp.outer_teeth = fixed_teeth + pp.inner_teeth = moving_teeth + pp.hole_percent = hole_percent + pp.margin_pixels = margin + pp.equal_w_h = equal_w_h + pp.pattern_rotation = pattern_rotation + pp.shape_rotation = shape_rotation + pp.tool_index = tool + pp.long_gradient = long_gradient + + engine = DrawingEngine(image, pp) + engine.draw_full(layer) + + elif run_mode == RUN_INTERACTIVE: + window = SpyroWindow(image, layer) + gtk.main() + + elif run_mode == RUN_WITH_LAST_VALS: + pp = unshelf_parameters() + engine = DrawingEngine(image, pp) + engine.draw_full(layer) + + def query(self): + plugin_name = "plug_in_spyrogimp" + label = N_("Spyrogimp...") + menu = "<Image>/Filters/Render/" + + params = [ + # (type, name, description + (PDB_INT32, "run-mode", "The run mode { RUN-INTERACTIVE (0), RUN-NONINTERACTIVE (1) }"), + (PDB_IMAGE, "image", "Input image"), + (PDB_DRAWABLE, "drawable", "Input drawable"), + (PDB_INT32, "curve_type", + "The curve type { Spyrograph (0), Epitrochoid (1), Sine (2), Lissajous(3) }"), + (PDB_INT32, "shape", "Shape of fixed gear"), + (PDB_INT32, "sides", "Number of sides of fixed gear (3 or greater). Only used by some shapes."), + (PDB_FLOAT, "morph", "Morph shape of fixed gear, between 0 and 1. Only used by some shapes."), + (PDB_INT32, "fixed_teeth", "Number of teeth for fixed gear"), + (PDB_INT32, "moving_teeth", "Number of teeth for moving gear"), + (PDB_FLOAT, "hole_percent", "Location of hole in moving gear in percent, where 100 means that " + "the hole is at the edge of the gear, and 0 means the hole is at the center"), + (PDB_INT32, "margin", "Margin from selection, in pixels"), + (PDB_INT32, "equal_w_h", "Make height and width equal (TRUE or FALSE)"), + (PDB_FLOAT, "pattern_rotation", "Pattern rotation, in degrees"), + (PDB_FLOAT, "shape_rotation", "Shape rotation of fixed gear, in degrees"), + (PDB_INT32, "tool", "Tool to use for drawing the pattern."), + (PDB_INT32, "long_gradient", + "Whether to apply a long gradient to match the length of the pattern (TRUE or FALSE). " + "Only applicable to some of the tools.") + ] + + gimp.domain_register("gimp20-python", gimp.locale_directory) + + gimp.install_procedure( + plugin_name, + N_("Draw spyrographs using current tool settings and selection."), + "Uses current tool settings to draw Spyrograph patterns. " + "The size and location of the pattern is based on the current selection.", + "Elad Shahar", + "Elad Shahar", + "2018", + label, + "*", + PLUGIN, + params, + [] + ) + + gimp.menu_register(plugin_name, menu) + + +if __name__ == '__main__': + SpyrogimpPlusPlugin().start() |