[gimp/gimp-2-10] plug-ins: Spyrogimp plugin rewrite.
- From: Jehan <jehanp src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [gimp/gimp-2-10] plug-ins: Spyrogimp plugin rewrite.
- Date: Thu, 24 Jan 2019 00:12:52 +0000 (UTC)
commit 529583430d641ade0ada35278bd01abf083bcb39
Author: Elad Shahar <dawn ever gmail com>
Date: Thu Jan 24 00:31:15 2019 +0100
plug-ins: Spyrogimp plugin rewrite.
Comment by reviewer (Jehan):
This was submitted through gimp-developer mailing list, by the same
author as the original Spyrogimp in script-fu, but this time in Python.
It does more than the original plug-in, with some automatic preview (by
drawing directly on a temporary layer, not as a GEGL preview), and using
the current tool options (current brush, etc.). The new API is similar
yet different. The much evolved possibilities makes that I don't think
it is worth trying to map 1-1 the new API to the old one, so I just let
the old plug-in next to the new one, with a different name.
Note finally that the author also contributed a new Spyrograph operation
to GEGL, yet with the comment: "The GEGL spyrograph operation is very
basic, and untested from gimp. I intend to keep developing it, since I
thought that on-canvas interaction would be very user-friendly. However,
I am not sure I will be able to get it work in a way that makes the
on-canvas interaction interactive enough.
Even if I do, it will not do what the Python plugin can do. It will be
much more basic."
So let's just integrate this evolved version of Spyrogimp for now. :-)
See: https://mail.gnome.org/archives/gimp-developer-list/2018-September/msg00008.html
plug-ins/pygimp/plug-ins/Makefile.am | 4 +-
plug-ins/pygimp/plug-ins/spyro_plus.py | 1807 ++++++++++++++++++++++++++++++++
2 files changed, 1810 insertions(+), 1 deletion(-)
---
diff --git a/plug-ins/pygimp/plug-ins/Makefile.am b/plug-ins/pygimp/plug-ins/Makefile.am
index eefe8f860a..4c200c80e4 100644
--- a/plug-ins/pygimp/plug-ins/Makefile.am
+++ b/plug-ins/pygimp/plug-ins/Makefile.am
@@ -13,6 +13,7 @@ source_scripts = \
palette-to-gradient.py \
py-slice.py \
python-eval.py \
+ spyro_plus.py \
\
benchmark-foreground-extract.py \
clothify.py \
@@ -30,7 +31,8 @@ scripts = \
palette-sort/palette-sort.py \
palette-to-gradient/palette-to-gradient.py \
py-slice/py-slice.py \
- python-eval/python-eval.py
+ python-eval/python-eval.py \
+ spyro_plus/spyro_plus.py
test_scripts = \
benchmark-foreground-extract/benchmark-foreground-extract.py \
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 0000000000..57d750a47b
--- /dev/null
+++ b/plug-ins/pygimp/plug-ins/spyro_plus.py
@@ -0,0 +1,1807 @@
+#!/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
+
+from math import pi, sin, cos, atan, atan2, fmod, radians
+import fractions
+import time
+
+pdb = gimp.pdb
+
+two_pi, half_pi = 2 * pi, pi / 2
+layer_name = "Spyro Layer"
+
+# "Enums"
+GEAR_NOTATION, TOY_KIT_NOTATION = range(2) # Pattern notations
+
+# Mapping of pattern notation to the corresponding tab in the patttern notation notebook.
+pattern_notation_page = {}
+
+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]
+
+
+### Shapes
+
+
+class CanRotateShape:
+ pass
+
+
+class Shape:
+ def configure(self, img, pp, cp, drawing_no):
+ 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, drawing_no):
+ Shape.configure(self, img, pp, cp, drawing_no)
+ 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, drawing_no):
+ Shape.configure(self, img, pp, cp, drawing_no)
+
+ 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, drawing_no):
+ Shape.configure(self, img, pp, cp, drawing_no)
+
+ 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, drawing_no):
+ """ Set bounds of pattern """
+ Shape.configure(self, img, pp, cp, drawing_no)
+ self.drawing_no = drawing_no
+ self.path.set_current_stroke(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 mutiply 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)
+
+
+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
+
+ # 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, 'keep_separate_layer'):
+ self.keep_separate_layer = True
+
+ 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.
+ """
+ def __init__(self, pp, x1, y1, x2, y2):
+
+ def lcm(a, b):
+ """ Least common multiplier """
+ return a * b // fractions.gcd(a, b)
+
+ 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.
+ self.outer_R = min(self.x_half_size, self.y_half_size)
+ size_of_tooth_in_pixels = two_pi * self.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
+
+ # Combine different ways to specify patterns, into a unified set of computed parameters.
+ 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.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]
+ # 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
+
+ # Rotations
+ self.shape_rotation_radians = self.radians_from_degrees(pp.shape_rotation)
+ self.pattern_rotation_radians = self.radians_from_degrees(pp.pattern_rotation)
+
+ # 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 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)]
+
+
+### 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 = curr_tooth * cp.iangle_factor
+ 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
+ oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, 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
+ self.num_drawings = 0
+
+ # For incremental drawing
+ self.strokes = []
+ self.start = 0
+ self.current_drawing = 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.current_drawing = 0
+
+ if isinstance(shapes[self.p.shape_index], SelectionShape) and
curve_types[self.p.curve_type].supports_shapes():
+ shapes[self.p.shape_index].process_selection(self.img)
+ pdb.gimp_displays_flush()
+ self.num_drawings = shapes[self.p.shape_index].get_num_drawings()
+ else:
+ self.num_drawings = 1
+
+ # Get bounds. We don't care weather a selection exists or not.
+ exists, x1, y1, x2, y2 = pdb.gimp_selection_bounds(self.img)
+
+ self.cp = ComputedParameters(self.p, x1, y1, x2, y2)
+
+ def draw_full(self, layer):
+ """ Non incremental drawing. """
+
+ self.pre_draw()
+ self.img.undo_group_start()
+
+ for drawing_no in range(self.num_drawings):
+ self.current_drawing = drawing_no
+ 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)
+
+ self.img.undo_group_end()
+
+ pdb.gimp_displays_flush()
+
+ # Methods for incremental drawing.
+
+ def draw_next_chunk(self, layer, fetch_next_drawing=True):
+ stroke_chunk, color = self.next_chunk(fetch_next_drawing)
+ tools[self.p.tool_index].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, drawing_no=self.current_drawing)
+
+ 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():
+ self.current_drawing += 1
+ if self.current_drawing < self.num_drawings:
+ 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)
+
+
+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):
+ """ 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, 0, 1, row, row + 1, xoptions=gtk.FILL, yoptions=0)
+ label.show()
+
+ def hscale_in_table(adj, table, row, callback, digits=0):
+ """ 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, 1, 2, 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, 2, 3, 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)
+ 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)
+
+ # Add tables as childs of the pattern notebook
+
+ 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, "Redraw", 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.keep_separate_layer_checkbox = gtk.CheckButton("Keep\nLayer")
+ self.keep_separate_layer_checkbox.set_tooltip_text(
+ "If checked, then once OK is pressed, the spyro layer is kept, and the plugin exits quickly.
"
+ "If unchecked, the spyro layer is deleted, and the pattern is redrawn on the layer that was "
+ "active when the plugin was launched."
+ )
+ hbox.add(self.keep_separate_layer_checkbox)
+ self.keep_separate_layer_checkbox.show()
+ self.keep_separate_layer_checkbox.connect("toggled", self.keep_separate_layer_checkbox_changed)
+
+ return hbox
+
+ def create_ui():
+
+ # Create the dialog
+ gtk.Window.__init__(self)
+ self.set_title("Spyrogimp Plus")
+ 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 errror
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, RGBA_IMAGE, 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 ok_window(self, widget):
+ """ Called when clicking on the 'close' button. """
+
+ self.ok_btn.set_sensitive(False)
+
+ shelf_parameters(self.p)
+
+ if self.p.keep_separate_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.
+ if self.idle_task:
+ gobject.source_remove(self.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():
+ 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(undo_group=False)
+
+ self.img.undo_group_end()
+
+ pdb.gimp_displays_flush()
+
+ gtk.main_quit()
+ yield False
+
+ task = draw_full()
+ gobject.idle_add(task.next)
+
+ def cancel_window(self, widget, what=None):
+
+ # Note that once we call gtk.main_quit, the idle task is stopped automatically.
+
+ # 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.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.keep_separate_layer_checkbox.set_active(self.p.keep_separate_layer)
+
+ 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)
+ 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)
+
+
+ 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: 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 keep_separate_layer_checkbox_changed(self, val):
+ self.p.keep_separate_layer = self.keep_separate_layer_checkbox.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, undo_group=True):
+ """ Incremental drawing """
+
+ t = time.time()
+
+ if undo_group:
+ self.img.undo_group_start()
+ chunk_size = self.engine.draw_next_chunk(self.drawing_layer)
+ if undo_group:
+ self.img.undo_group_end()
+
+ 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()
+ while self.engine.has_more_strokes():
+ yield True
+ self.draw_next_chunk()
+
+ self.idle_task = None
+ yield False
+
+ # Remove old idle task if exists.
+ if self.idle_task:
+ gobject.source_remove(self.idle_task)
+
+ # Start new idle task to perform incremental drawing in the background.
+ 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_plus(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_plus"
+ label = "Spyrogimp Plus..."
+ 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.install_procedure(
+ plugin_name,
+ "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()
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]