Commit a702c6a3 authored by Elad Shahar's avatar Elad Shahar Committed by Jehan
Browse files

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

(cherry picked from commit 52958343)
parent c0fed5af
......@@ -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 \
......
#!/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(),
</