2374 lines
91 KiB
Python
2374 lines
91 KiB
Python
|
#!/usr/bin/env python3
|
||
|
|
||
|
# 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/>.
|
||
|
|
||
|
import gi
|
||
|
gi.require_version('Pika', '3.0')
|
||
|
from gi.repository import Pika
|
||
|
gi.require_version('PikaUi', '3.0')
|
||
|
from gi.repository import PikaUi
|
||
|
from gi.repository import GObject
|
||
|
from gi.repository import GLib
|
||
|
from gi.repository import Gio
|
||
|
gi.require_version('Gtk', '3.0')
|
||
|
from gi.repository import Gtk
|
||
|
gi.require_version('Gdk', '3.0')
|
||
|
from gi.repository import Gdk
|
||
|
import time
|
||
|
import sys
|
||
|
|
||
|
def N_(message): return message
|
||
|
def _(message): return GLib.dgettext(None, message)
|
||
|
|
||
|
|
||
|
from math import pi, sin, cos, atan, atan2, fmod, radians, sqrt
|
||
|
import math
|
||
|
import time
|
||
|
|
||
|
|
||
|
def result_success():
|
||
|
return procedure.new_return_values(Pika.PDBStatusType.SUCCESS, GLib.Error())
|
||
|
|
||
|
|
||
|
PROC_NAME = "plug-in-spyropika"
|
||
|
|
||
|
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
|
||
|
|
||
|
RESPONSE_REDRAW, RESPONSE_RESET_PARAMS = range(2) # Button responses in dialog.
|
||
|
|
||
|
# Save options of the dialog
|
||
|
SAVE_AS_NEW_LAYER, SAVE_BY_REDRAW, SAVE_AS_PATH = range(3)
|
||
|
save_options = [
|
||
|
_("As New Layer"),
|
||
|
_("Redraw on last active layer"),
|
||
|
_("As Path")
|
||
|
]
|
||
|
|
||
|
# Mapping of pattern notation to the corresponding tab in the pattern 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]
|
||
|
|
||
|
|
||
|
def lcm(a, b):
|
||
|
""" Least common multiplier """
|
||
|
return a * b // math.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()
|
||
|
|
||
|
|
||
|
# Naive hash that gets only a limited amount of points from the selection.
|
||
|
# We use this hash to detect whether the selection has changed or not.
|
||
|
def naive_hash(img):
|
||
|
selection = img.get_selection()
|
||
|
|
||
|
# Get bounds of selection.
|
||
|
flag, non_empty, x1, y1, x2, y2 = selection.bounds(img)
|
||
|
|
||
|
# We want to compute a hash of the selection, but getting all the points in the selection
|
||
|
# will take too long. We will get at most 25 points in each axis, so at most 25**2 points.
|
||
|
step_x = 1 if (x2 - x1) <= 25 else (x2 - x1) // 25 + 1
|
||
|
step_y = 1 if (y2 - y1) <= 25 else (y2 - y1) // 25 + 1
|
||
|
hash = x1 * y1 + x2 * y2
|
||
|
|
||
|
for x in range(x1, x2, step_x):
|
||
|
for y in range(y1, y2, step_y):
|
||
|
hash += selection.get_pixel(x, y)[0] * x * y
|
||
|
return hash
|
||
|
|
||
|
|
||
|
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 Pika.Selection.is_empty(self.image):
|
||
|
selection_was_empty = True
|
||
|
Pika.Selection.all(self.image)
|
||
|
else:
|
||
|
selection_was_empty = False
|
||
|
|
||
|
result = Pika.get_pdb().run_procedure('plug-in-sel2path', [
|
||
|
GObject.Value(Pika.RunMode, Pika.RunMode.NONINTERACTIVE),
|
||
|
GObject.Value(Pika.Image, self.image),
|
||
|
GObject.Value(GObject.TYPE_INT, 0),
|
||
|
# XXX: I could use self.image.list_selected_layers() but for
|
||
|
# this call, it doesn't matter anyway.
|
||
|
GObject.Value(Pika.ObjectArray,
|
||
|
Pika.ObjectArray.new(Pika.Drawable, [], False)),
|
||
|
])
|
||
|
|
||
|
self.path = self.image.list_vectors()[0]
|
||
|
self.stroke_ids = self.path.get_strokes()
|
||
|
|
||
|
# 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 len(self.stroke_ids) > 1:
|
||
|
# Lets compute what a stroke of the image borders should look like.
|
||
|
w, h = float(self.image.get_width()), float(self.image.get_height())
|
||
|
frame_strokes = [0.0] * 6 + [0.0, h] * 3 + [w, h] * 3 + [w, 0.0] * 3
|
||
|
|
||
|
for stroke in range(len(self.stroke_ids)):
|
||
|
stroke_id = self.stroke_ids[stroke]
|
||
|
vectors_stroke_type, control_points, closed = self.path.stroke_get_points(stroke_id)
|
||
|
if control_points == frame_strokes:
|
||
|
del self.stroke_ids[stroke]
|
||
|
break
|
||
|
|
||
|
self.set_current_stroke(0)
|
||
|
|
||
|
if selection_was_empty:
|
||
|
# Restore empty selection if it was empty.
|
||
|
Pika.Selection.none(self.image)
|
||
|
|
||
|
def compute_selection_hash(self):
|
||
|
return naive_hash(self.image)
|
||
|
# In pika 2 we used this:
|
||
|
#px = self.image.get_selection(). get_pixel_rgn(0, 0, self.image.get_width(), self.image.get_height())
|
||
|
#return px[0:self.image.get_width(), 0:self.image.get_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 len(self.stroke_ids)
|
||
|
|
||
|
def set_current_stroke(self, stroke_id=0):
|
||
|
# Compute path length.
|
||
|
self.path_length = self.path.stroke_get_length(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 self.path.stroke_get_point_at_dist(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
|
||
|
another_bool, 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 = Pika.Selection.value(self.image, xp, yp)
|
||
|
xp, yp = x + step_size * cos(perpendicular_m), y + step_size * sin(perpendicular_m)
|
||
|
value_minus = Pika.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 = Pika.context_get_gradient()
|
||
|
reverse_mode = Pika.context_get_gradient_reverse()
|
||
|
repeat_mode = Pika.context_get_gradient_repeat_mode()
|
||
|
|
||
|
if repeat_mode == Pika.RepeatMode.TRIANGULAR:
|
||
|
# Get two uniform samples, which are reversed from each other, and connect them.
|
||
|
|
||
|
sample_count = num_samples/2 + 1
|
||
|
success, color_samples = gradient.get_uniform_samples(sample_count, reverse_mode)
|
||
|
|
||
|
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:
|
||
|
sample_count += 1
|
||
|
|
||
|
success, color_samples2 = gradient.get_uniform_samples(sample_count, 1 - reverse_mode)
|
||
|
|
||
|
del color_samples2[-4:] # Delete last color because it will appear in the very first sample
|
||
|
|
||
|
color_samples = tuple(color_samples)
|
||
|
else:
|
||
|
success, color_samples = gradient.get_uniform_samples(num_samples, reverse_mode)
|
||
|
|
||
|
return color_samples
|
||
|
|
||
|
|
||
|
class PencilTool():
|
||
|
name = _("Pencil")
|
||
|
can_color = True
|
||
|
|
||
|
def draw(self, layer, strokes, color=None):
|
||
|
if color:
|
||
|
Pika.context_push()
|
||
|
Pika.context_enable_dynamics(False)
|
||
|
Pika.context_set_foreground(color)
|
||
|
|
||
|
Pika.pencil(layer, strokes)
|
||
|
|
||
|
if color:
|
||
|
Pika.context_pop()
|
||
|
|
||
|
|
||
|
class AirBrushTool():
|
||
|
name = _("AirBrush")
|
||
|
can_color = True
|
||
|
|
||
|
def draw(self, layer, strokes, color=None):
|
||
|
if color:
|
||
|
Pika.context_push()
|
||
|
Pika.context_enable_dynamics(False)
|
||
|
Pika.context_set_foreground(color)
|
||
|
|
||
|
Pika.airbrush_default(layer, strokes)
|
||
|
|
||
|
if color:
|
||
|
Pika.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 = Pika.Vectors.new(layer.get_image(), 'temp_path')
|
||
|
layer.get_image().insert_vectors(path, None, 0)
|
||
|
sid = path.stroke_new_from_points(Pika.VectorsStrokeType.BEZIER,
|
||
|
control_points, False)
|
||
|
|
||
|
# Draw it.
|
||
|
|
||
|
Pika.context_push()
|
||
|
|
||
|
# Call template method to set the kind of stroke to draw.
|
||
|
self.prepare_stroke_context(color)
|
||
|
|
||
|
layer.edit_stroke_item(path)
|
||
|
Pika.context_pop()
|
||
|
|
||
|
# Get rid of the path.
|
||
|
layer.get_image().remove_vectors(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):
|
||
|
success, foreground = Pika.context_get_foreground()
|
||
|
Pika.context_push()
|
||
|
Pika.context_set_defaults()
|
||
|
Pika.context_set_foreground(foreground)
|
||
|
Pika.context_enable_dynamics(False)
|
||
|
|
||
|
# TODO: in the future, there could be several brushes with this name. We
|
||
|
# should have methods to loop through all of them and to know whether
|
||
|
# they are default/system brushes or not.
|
||
|
brush = Pika.Brush.get_by_name('1. Pixel')
|
||
|
assert brush is not None
|
||
|
|
||
|
Pika.context_set_brush(brush)
|
||
|
Pika.context_set_brush_size(1.0)
|
||
|
Pika.context_set_brush_spacing(3.0)
|
||
|
Pika.pencil(layer, strokes)
|
||
|
Pika.context_pop()
|
||
|
|
||
|
name = _("Preview")
|
||
|
can_color = False
|
||
|
|
||
|
|
||
|
class StrokeTool(AbstractStrokeTool):
|
||
|
name = _("Stroke")
|
||
|
can_color = True
|
||
|
|
||
|
def prepare_stroke_context(self, color):
|
||
|
if color:
|
||
|
Pika.context_enable_dynamics(False)
|
||
|
Pika.context_set_foreground(color)
|
||
|
|
||
|
Pika.context_set_stroke_method(Pika.StrokeMethod.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:
|
||
|
Pika.context_enable_dynamics(False)
|
||
|
Pika.context_set_foreground(color)
|
||
|
|
||
|
Pika.context_set_stroke_method(Pika.StrokeMethod.PAINT_METHOD)
|
||
|
Pika.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 = Pika.Vectors.new(img, path_name)
|
||
|
img.insert_vectors(self.path, None, 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
|
||
|
|
||
|
self.path.stroke_new_from_points(Pika.VectorsStrokeType.BEZIER,
|
||
|
control_points, False)
|
||
|
|
||
|
|
||
|
tools = [
|
||
|
PreviewTool(),
|
||
|
StrokePaintTool(_("PaintBrush"), "pika-paintbrush"),
|
||
|
PencilTool(), AirBrushTool(), StrokeTool(),
|
||
|
StrokePaintTool(_("Ink"), 'pika-ink'),
|
||
|
StrokePaintTool(_("MyPaintBrush"), 'pika-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", 'pika-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():
|
||
|
# TODO: we'd usually use Pika.PDB.set_data() but this won't work on
|
||
|
# introspection bindings. We will need to work on this.
|
||
|
#if shelf.has_key("p"):
|
||
|
#parameters = shelf["p"]
|
||
|
#parameters.__init__() # Fill in missing values with defaults.
|
||
|
#return parameters
|
||
|
return PatternParameters()
|
||
|
|
||
|
|
||
|
def shelf_parameters(pp):
|
||
|
# TODO: see unshelf_parameters() which explains why we can't use
|
||
|
# Pika.PDB.get_data().
|
||
|
pass
|
||
|
#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 patterns 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)
|
||
|
Pika.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.
|
||
|
success, exists, x1, y1, x2, y2 = Pika.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 = math.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):
|
||
|
colors = self.gradients[4*n:4*(n+1)]
|
||
|
color = Pika.RGB()
|
||
|
color.r = colors[0]
|
||
|
color.g = colors[1]
|
||
|
color.b = colors[2]
|
||
|
color.a = colors[3]
|
||
|
return color
|
||
|
|
||
|
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()
|
||
|
|
||
|
Pika.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 metaphor. This widget replaces two scale widgets.
|
||
|
class DoughnutWidget(Gtk.DrawingArea):
|
||
|
__gtype_name__ = 'DoughnutWidget'
|
||
|
|
||
|
def __init__(self, *args, **kwds):
|
||
|
super().__init__(*args, **kwds)
|
||
|
self.set_size_request(80, 40)
|
||
|
self.set_margin_start(2)
|
||
|
self.set_margin_end(2)
|
||
|
self.set_margin_top(2)
|
||
|
self.set_margin_bottom(2)
|
||
|
|
||
|
self.add_events(
|
||
|
Gdk.EventMask.BUTTON1_MOTION_MASK |
|
||
|
Gdk.EventMask.BUTTON_PRESS_MASK |
|
||
|
Gdk.EventMask.BUTTON_RELEASE_MASK |
|
||
|
Gdk.EventMask.POINTER_MOTION_MASK
|
||
|
)
|
||
|
|
||
|
self.resize_cursor = Gdk.Cursor.new_for_display(self.get_display(),
|
||
|
Gdk.CursorType.SB_H_DOUBLE_ARROW)
|
||
|
|
||
|
self.button_pressed = False
|
||
|
self.target = TARGET_NONE
|
||
|
|
||
|
self.hole_radius = 30
|
||
|
self.doughnut_width = 30
|
||
|
|
||
|
def set_hole_radius(self, hole_radius):
|
||
|
self.hole_radius = hole_radius
|
||
|
self.queue_draw()
|
||
|
|
||
|
def get_hole_radius(self):
|
||
|
return self.hole_radius
|
||
|
|
||
|
def set_width(self, width):
|
||
|
self.doughnut_width = width
|
||
|
self.queue_draw()
|
||
|
|
||
|
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, flag):
|
||
|
""" Set the mouse to be a double arrow, if flag is true.
|
||
|
Otherwise, use the cursor of the parent window. """
|
||
|
gdk_window = self.get_window()
|
||
|
gdk_window.set_cursor(self.resize_cursor if flag else None)
|
||
|
|
||
|
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 = math.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 do_draw(self, cr):
|
||
|
|
||
|
allocation = self.get_allocation()
|
||
|
center_x, center_y, hole_radius, outer_radius = self.compute_doughnut()
|
||
|
|
||
|
# Paint background
|
||
|
Gtk.render_background(self.get_style_context(), cr,
|
||
|
0, 0, allocation.width, allocation.height)
|
||
|
|
||
|
fg_color = self.get_style_context().get_color(Gtk.StateFlags.NORMAL)
|
||
|
|
||
|
# Draw doughnut interior
|
||
|
arc = math.pi*3/2.0
|
||
|
cr.set_source_rgba(fg_color.red, fg_color.green, fg_color.blue, fg_color.alpha/2);
|
||
|
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_rgba(*list(fg_color));
|
||
|
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 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)
|
||
|
self.set_cursor_h_resize(target != TARGET_NONE)
|
||
|
|
||
|
# Create signal that returns change parameters.
|
||
|
GObject.type_register(DoughnutWidget)
|
||
|
GObject.signal_new("values_changed", DoughnutWidget, GObject.SignalFlags.RUN_LAST,
|
||
|
GObject.TYPE_NONE, (GObject.TYPE_INT, GObject.TYPE_INT))
|
||
|
|
||
|
|
||
|
class SpyroWindow():
|
||
|
|
||
|
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 on_adj_changed(self, widget):
|
||
|
self.adj_changed = True
|
||
|
|
||
|
def on_adj_release(self, widget, event, callback):
|
||
|
# Force update to accommodate manually typing numbers into the entry.
|
||
|
if isinstance(widget, Gtk.SpinButton):
|
||
|
widget.update()
|
||
|
if self.adj_changed:
|
||
|
callback(widget)
|
||
|
self.adj_changed = False
|
||
|
|
||
|
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(border_width):
|
||
|
table = Gtk.Grid()
|
||
|
table.set_column_homogeneous(False)
|
||
|
table.set_border_width(border_width)
|
||
|
table.set_column_spacing(10)
|
||
|
table.set_row_spacing(10)
|
||
|
return table
|
||
|
|
||
|
def label_in_table(label_text, table, row, tooltip_text=None, col=0):
|
||
|
""" Create a label and set it in first col of table. """
|
||
|
label = Gtk.Label(label=label_text)
|
||
|
label.set_xalign(0.0)
|
||
|
label.set_yalign(0.5)
|
||
|
if tooltip_text:
|
||
|
label.set_tooltip_text(tooltip_text)
|
||
|
table.attach(label, col, row, 1, 1)
|
||
|
label.show()
|
||
|
|
||
|
def spin_in_table(adj, table, row, callback, digits=0, col=0):
|
||
|
spin = Gtk.SpinButton.new(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, row, 1, 1)
|
||
|
spin.show()
|
||
|
adj.connect("value_changed", self.on_adj_changed)
|
||
|
spin.connect("button_release_event", self.on_adj_release, callback)
|
||
|
spin.connect("activate", self.on_adj_release, None, callback)
|
||
|
spin.connect("focus_out_event", self.on_adj_release, 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.Scale.new(Gtk.Orientation.HORIZONTAL, adj)
|
||
|
scale.set_size_request(150, -1)
|
||
|
scale.set_digits(digits)
|
||
|
scale.set_hexpand(True)
|
||
|
scale.set_halign(Gtk.Align.FILL)
|
||
|
table.attach(scale, col, row, cols, 1)
|
||
|
scale.show()
|
||
|
|
||
|
spin = Gtk.SpinButton.new(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, row, 1, 1)
|
||
|
spin.show()
|
||
|
|
||
|
adj.connect("value_changed", self.on_adj_changed)
|
||
|
scale.connect("button_release_event", self.on_adj_release, callback)
|
||
|
spin.connect("button_release_event", self.on_adj_release, callback)
|
||
|
spin.connect("activate", self.on_adj_release, None, callback)
|
||
|
spin.connect("focus_out_event", self.on_adj_release, callback)
|
||
|
|
||
|
return self.MyScale(scale, spin)
|
||
|
|
||
|
def rotation_in_table(val, table, row, callback):
|
||
|
adj = Gtk.Adjustment.new(val, -180.0, 180.0, 1.0, 10.0, 0.0)
|
||
|
myscale = hscale_in_table(adj, table, row, callback, digits=1)
|
||
|
myscale.scale.add_mark(0.0, Gtk.PositionType.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.ComboBoxText.new()
|
||
|
for txt in txt_list:
|
||
|
combo.append_text(txt)
|
||
|
|
||
|
combo.set_halign(Gtk.Align.FILL)
|
||
|
table.attach(combo, 1, row, 1, 1)
|
||
|
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(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(label=_("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, row, 1, 1)
|
||
|
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(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, False, False, 0)
|
||
|
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(10)
|
||
|
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(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(value=self.p.outer_teeth,
|
||
|
lower=10, upper=180,
|
||
|
step_increment=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.new(self.p.inner_teeth, 2, 100, 1, 10, 0)
|
||
|
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.new(self.p.hole_percent, 2.5, 100.0, 0.5, 10, 0)
|
||
|
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(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.new(self.p.hole_number, 1, self.p.kit_max_hole_number(), 1, 10, 0)
|
||
|
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(5)
|
||
|
|
||
|
row = 0
|
||
|
label_in_table(_("Flower Petals"), visual_table, row,
|
||
|
_("The number of petals in the pattern."))
|
||
|
self.petals_adj = Gtk.Adjustment.new(self.p.petals, 2, 100, 1, 5, 0)
|
||
|
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.new(self.p.petal_skip, 1, 50, 1, 5, 0)
|
||
|
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.new(self.p.doughnut_hole, 0.0, 99.0, 0.1, 5.0, 0.0)
|
||
|
self.doughnut_hole_myscale = spin_in_table(self.doughnut_hole_adj, visual_table,
|
||
|
row, self.doughnut_hole_changed, 1, 1)
|
||
|
|
||
|
self.doughnut = DoughnutWidget()
|
||
|
frame = Gtk.Frame()
|
||
|
frame.add(self.doughnut)
|
||
|
visual_table.attach(frame, 2, row, 1, 1)
|
||
|
self.doughnut.set_hexpand(True)
|
||
|
self.doughnut.set_halign(Gtk.Align.FILL)
|
||
|
frame.set_hexpand(True)
|
||
|
frame.set_halign(Gtk.Align.FILL)
|
||
|
|
||
|
self.doughnut.connect('values_changed', self.doughnut_changed)
|
||
|
frame.show()
|
||
|
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.new(self.p.doughnut_width, 1.0, 100.0, 0.1, 5.0, 0.0)
|
||
|
self.doughnut_width_myscale = spin_in_table(self.doughnut_width_adj, visual_table,
|
||
|
row, self.doughnut_width_changed, 1, 4)
|
||
|
|
||
|
# Add tables to 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.child_set_property(visual_table, 'tab-expand', False)
|
||
|
self.pattern_notebook.child_set_property(visual_table, 'tab-fill', False)
|
||
|
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.child_set_property(kit_table, 'tab-expand', False)
|
||
|
self.pattern_notebook.child_set_property(kit_table, 'tab-fill', False)
|
||
|
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.child_set_property(gear_table, 'tab-expand', False)
|
||
|
self.pattern_notebook.child_set_property(gear_table, 'tab-fill', False)
|
||
|
gear_table.show()
|
||
|
|
||
|
add_to_box(vbox, self.pattern_notebook)
|
||
|
|
||
|
add_vertical_space(vbox, 14)
|
||
|
|
||
|
hbox = Gtk.HBox(spacing=5)
|
||
|
pattern_table = create_table(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(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.new(self.p.sides, 3, 16, 1, 2, 2)
|
||
|
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.new(self.p.morph, 0.0, 1.0, 0.01, 0.1, 0.1)
|
||
|
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(10)
|
||
|
|
||
|
row = 0
|
||
|
label_in_table(_("Margin (px)"), table, row, _("Margin from edge of selection."))
|
||
|
self.margin_adj = Gtk.Adjustment.new(self.p.margin_pixels, 0, max(img.get_height(), img.get_width()), 1, 10, 10)
|
||
|
hscale_in_table(self.margin_adj, table, row, self.margin_changed)
|
||
|
|
||
|
row += 1
|
||
|
self.equal_w_h_checkbox = Gtk.CheckButton(label=_("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(30)
|
||
|
table.attach(self.equal_w_h_checkbox, 0, row, 3, 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 dialog_button_box():
|
||
|
|
||
|
self.dialog.add_button(_("_Cancel"), Gtk.ResponseType.CANCEL)
|
||
|
self.ok_btn = self.dialog.add_button(_("_OK"), Gtk.ResponseType.OK)
|
||
|
btn = self.dialog.add_button(_("Re_draw"), RESPONSE_REDRAW)
|
||
|
btn.set_tooltip_text(
|
||
|
_("If you change the settings of a tool, change color, or change the selection, "
|
||
|
"press this to preview how the pattern looks.")
|
||
|
)
|
||
|
self.dialog.add_button(_("_Reset"), RESPONSE_RESET_PARAMS)
|
||
|
|
||
|
hbox = Gtk.HBox(homogeneous=True, spacing=20)
|
||
|
hbox.set_border_width(10)
|
||
|
|
||
|
table = create_table(5)
|
||
|
|
||
|
row = 0
|
||
|
label_in_table(_("Save"), table, row,
|
||
|
_("Choose whether to save as new layer, redraw on last active layer, or save to path"))
|
||
|
self.save_option_combo = set_combo_in_table(save_options, table, row,
|
||
|
self.save_option_changed)
|
||
|
self.save_option_combo.show()
|
||
|
|
||
|
hbox.add(table)
|
||
|
table.show()
|
||
|
|
||
|
return hbox
|
||
|
|
||
|
def create_ui():
|
||
|
|
||
|
use_header_bar = Gtk.Settings.get_default().get_property("gtk-dialogs-use-header")
|
||
|
self.dialog = PikaUi.Dialog(use_header_bar=use_header_bar,
|
||
|
title=_("Spyropika"))
|
||
|
#self.set_default_size(350, -1)
|
||
|
#self.set_border_width(10)
|
||
|
|
||
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
||
|
homogeneous=False, spacing=10)
|
||
|
self.dialog.get_content_area().add(vbox)
|
||
|
vbox.show()
|
||
|
|
||
|
box = PikaUi.HintBox.new(_("Draw spyrographs using current tool settings and selection."))
|
||
|
vbox.pack_start(box, False, False, 0)
|
||
|
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.new(_("Curve Pattern")))
|
||
|
pattern_frame.show()
|
||
|
fixed_g_page = fixed_gear_page()
|
||
|
self.main_notebook.append_page(fixed_g_page, Gtk.Label.new(_("Fixed Gear")))
|
||
|
fixed_g_page.show()
|
||
|
size_p = size_page()
|
||
|
self.main_notebook.append_page(size_p, Gtk.Label.new(_("Size")))
|
||
|
size_p.show()
|
||
|
|
||
|
vbox.add(self.main_notebook)
|
||
|
self.main_notebook.show()
|
||
|
|
||
|
# add_horizontal_separator(vbox)
|
||
|
|
||
|
add_to_box(vbox, dialog_button_box())
|
||
|
|
||
|
self.progress_bar = Gtk.ProgressBar() # pikaui.ProgressBar() - causes pikapdbprogress error message.
|
||
|
self.progress_bar.set_show_text(True)
|
||
|
vbox.add(self.progress_bar)
|
||
|
self.progress_bar.show()
|
||
|
|
||
|
self.dialog.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 PIKA layer to draw on
|
||
|
self.spyro_layer = Pika.Layer.new(img, layer_name, img.get_width(), img.get_height(),
|
||
|
layer.type_with_alpha(), 100, Pika.LayerMode.NORMAL)
|
||
|
img.insert_layer(self.spyro_layer, None, 0)
|
||
|
self.drawing_layer = self.spyro_layer
|
||
|
|
||
|
# Create the UI.
|
||
|
PikaUi.init(sys.argv[0])
|
||
|
create_ui()
|
||
|
self.update_view() # Update UI to reflect the parameter values.
|
||
|
|
||
|
# Map button responses to callback this method
|
||
|
self.dialog.connect('response', self.handle_response)
|
||
|
|
||
|
# Setup for Handling incremental/interactive drawing of pattern
|
||
|
self.idle_task = None
|
||
|
self.enable_incremental_drawing = True
|
||
|
|
||
|
self.adj_changed = False
|
||
|
|
||
|
# Draw pattern of the current settings.
|
||
|
self.start_new_incremental_drawing()
|
||
|
|
||
|
def handle_response(self, dialog, response_id):
|
||
|
if response_id in [Gtk.ResponseType.CANCEL, Gtk.ResponseType.CLOSE, Gtk.ResponseType.DELETE_EVENT]:
|
||
|
self.cancel_window(self.dialog)
|
||
|
elif response_id == Gtk.ResponseType.OK:
|
||
|
self.ok_window(self.dialog)
|
||
|
elif response_id == RESPONSE_REDRAW:
|
||
|
self.redraw()
|
||
|
elif response_id == RESPONSE_RESET_PARAMS:
|
||
|
self.reset_params()
|
||
|
else:
|
||
|
print("Unhandled response: " + str(response_id))
|
||
|
#GTK_RESPONSE_APPLY
|
||
|
#GTK_RESPONSE_HELP
|
||
|
|
||
|
def clear_idle_task(self):
|
||
|
if self.idle_task:
|
||
|
GLib.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
|
||
|
|
||
|
# 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.save_option == SAVE_AS_NEW_LAYER:
|
||
|
if self.spyro_layer in self.img.list_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()
|
||
|
GLib.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.list_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()
|
||
|
|
||
|
Pika.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)
|
||
|
GLib.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.list_layers():
|
||
|
self.img.remove_layer(self.spyro_layer)
|
||
|
Pika.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=None):
|
||
|
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.get_value()
|
||
|
self.redraw()
|
||
|
|
||
|
# Callbacks: pattern changes using the Gears notation.
|
||
|
|
||
|
def outer_teeth_changed(self, val):
|
||
|
self.p.outer_teeth = val.get_value()
|
||
|
self.redraw()
|
||
|
|
||
|
def inner_teeth_changed(self, val):
|
||
|
self.p.inner_teeth = val.get_value()
|
||
|
self.redraw()
|
||
|
|
||
|
def hole_percent_changed(self, val):
|
||
|
self.p.hole_percent = val.get_value()
|
||
|
self.redraw()
|
||
|
|
||
|
def pattern_rotation_changed(self, val):
|
||
|
self.p.pattern_rotation = val.get_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.get_value())
|
||
|
self.petals_changed_side_effects()
|
||
|
self.redraw()
|
||
|
|
||
|
def petal_skip_changed(self, val):
|
||
|
self.p.petal_skip = int(val.get_value())
|
||
|
self.redraw()
|
||
|
|
||
|
def doughnut_hole_changed(self, val):
|
||
|
self.p.doughnut_hole = val.get_value()
|
||
|
|
||
|
self.doughnut.set_hole_radius(val.get_value())
|
||
|
self.redraw()
|
||
|
|
||
|
def doughnut_width_changed(self, val):
|
||
|
self.p.doughnut_width = val.get_value()
|
||
|
self.doughnut.set_width(val.get_value())
|
||
|
self.redraw()
|
||
|
|
||
|
def doughnut_changed(self, widget, hole, width):
|
||
|
self.doughnut_hole_adj.set_value(hole)
|
||
|
self.p.doughnut_hole = hole
|
||
|
self.doughnut_width_adj.set_value(width)
|
||
|
self.p.doughnut_width = width
|
||
|
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.get_value()
|
||
|
self.redraw()
|
||
|
|
||
|
def morph_changed(self, val):
|
||
|
self.p.morph = val.get_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.get_value()
|
||
|
self.redraw()
|
||
|
|
||
|
def margin_changed(self, val) :
|
||
|
self.p.margin_pixels = val.get_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)
|
||
|
Pika.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()
|
||
|
Pika.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()
|
||
|
|
||
|
Pika.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 = GLib.idle_add(task.__next__)
|
||
|
|
||
|
def clear(self):
|
||
|
""" Clear current drawing. """
|
||
|
# pdb.pika_edit_clear(self.spyro_layer)
|
||
|
self.spyro_layer.fill(Pika.FillType.TRANSPARENT)
|
||
|
|
||
|
def redraw(self, data=None):
|
||
|
if self.enable_incremental_drawing:
|
||
|
self.clear()
|
||
|
self.start_new_incremental_drawing()
|
||
|
|
||
|
|
||
|
class SpyropikaPlusPlugin(Pika.PlugIn):
|
||
|
|
||
|
## Parameters ##
|
||
|
__gproperties__ = {
|
||
|
"curve_type" : (int,
|
||
|
_("The curve type { Spyrograph (0), Epitrochoid (1), Sine (2), Lissajous(3) }"),
|
||
|
_("The curve type { Spyrograph (0), Epitrochoid (1), Sine (2), Lissajous(3) }"),
|
||
|
0, 3, 0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"shape": (int,
|
||
|
_("Shape of fixed gear"),
|
||
|
_("Shape of fixed gear"),
|
||
|
0, GLib.MAXINT, 0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"sides": (int,
|
||
|
_("Number of sides of fixed gear (3 or greater). Only used by some shapes."),
|
||
|
_("Number of sides of fixed gear (3 or greater). Only used by some shapes."),
|
||
|
3, GLib.MAXINT, 3,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"morph": (float,
|
||
|
_("Morph shape of fixed gear, between 0 and 1. Only used by some shapes."),
|
||
|
_("Morph shape of fixed gear, between 0 and 1. Only used by some shapes."),
|
||
|
0.0, 1.0, 0.0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"fixed_teeth": (int,
|
||
|
_("Number of teeth for fixed gear"),
|
||
|
_("Number of teeth for fixed gear"),
|
||
|
0, GLib.MAXINT, 96,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"moving_teeth": (int,
|
||
|
_("Number of teeth for moving gear"),
|
||
|
_("Number of teeth for moving gear"),
|
||
|
0, GLib.MAXINT, 36,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"hole_percent": (float,
|
||
|
_("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"),
|
||
|
_("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"),
|
||
|
0.0, 100.0, 100.0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"margin": (int,
|
||
|
_("Margin from selection, in pixels"),
|
||
|
_("Margin from selection, in pixels"),
|
||
|
0, GLib.MAXINT, 0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"equal_w_h": (bool,
|
||
|
_("Make height and width equal"),
|
||
|
_("Make height and width equal"),
|
||
|
False,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"pattern_rotation": (float,
|
||
|
_("Pattern rotation, in degrees"),
|
||
|
_("Pattern rotation, in degrees"),
|
||
|
-360.0, 360.0, 0.0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"shape_rotation": (float,
|
||
|
_("Shape rotation of fixed gear, in degrees"),
|
||
|
_("Shape rotation of fixed gear, in degrees"),
|
||
|
-360.0, 360.0, 0.0,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"tool": (int,
|
||
|
_("Tool to use for drawing the pattern."),
|
||
|
_("Tool to use for drawing the pattern."),
|
||
|
0, GLib.MAXINT, 1,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
"long_gradient" : (bool,
|
||
|
_("Whether to apply a long gradient to match the length of the pattern. "
|
||
|
"Only applicable to some of the tools."),
|
||
|
_("Whether to apply a long gradient to match the length of the pattern. "
|
||
|
"Only applicable to some of the tools."),
|
||
|
False,
|
||
|
GObject.ParamFlags.READWRITE),
|
||
|
}
|
||
|
|
||
|
## PikaPlugIn virtual methods ##
|
||
|
def do_set_i18n(self, procname):
|
||
|
return True, 'pika30-python', None
|
||
|
|
||
|
def do_query_procedures(self):
|
||
|
return [PROC_NAME]
|
||
|
|
||
|
def do_create_procedure(self, name):
|
||
|
if name == PROC_NAME:
|
||
|
procedure = Pika.ImageProcedure.new(self, name,
|
||
|
Pika.PDBProcType.PLUGIN,
|
||
|
self.plug_in_spyropika, None)
|
||
|
procedure.set_image_types("*")
|
||
|
procedure.set_sensitivity_mask (Pika.ProcedureSensitivityMask.DRAWABLE)
|
||
|
procedure.set_documentation (_("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."),
|
||
|
name)
|
||
|
procedure.set_menu_label(_("Spyropika..."))
|
||
|
procedure.set_attribution("Elad Shahar",
|
||
|
"Elad Shahar",
|
||
|
"2018")
|
||
|
procedure.add_menu_path ("<Image>/Filters/Render/")
|
||
|
|
||
|
procedure.add_argument_from_property(self, "curve_type")
|
||
|
procedure.add_argument_from_property(self, "shape")
|
||
|
procedure.add_argument_from_property(self, "sides")
|
||
|
procedure.add_argument_from_property(self, "morph")
|
||
|
procedure.add_argument_from_property(self, "fixed_teeth")
|
||
|
procedure.add_argument_from_property(self, "moving_teeth")
|
||
|
procedure.add_argument_from_property(self, "hole_percent")
|
||
|
procedure.add_argument_from_property(self, "margin")
|
||
|
procedure.add_argument_from_property(self, "equal_w_h")
|
||
|
procedure.add_argument_from_property(self, "pattern_rotation")
|
||
|
procedure.add_argument_from_property(self, "shape_rotation")
|
||
|
procedure.add_argument_from_property(self, "tool")
|
||
|
procedure.add_argument_from_property(self, "long_gradient")
|
||
|
|
||
|
return procedure
|
||
|
|
||
|
# Implementation of plugin.
|
||
|
def plug_in_spyropika(self, procedure, run_mode, image, n_layers, layers, args, data):
|
||
|
curve_type=args.index(0)
|
||
|
shape=args.index(1)
|
||
|
sides=args.index(2)
|
||
|
morph=args.index(3)
|
||
|
fixed_teeth=args.index(4)
|
||
|
moving_teeth=args.index(5)
|
||
|
hole_percent=args.index(6)
|
||
|
margin=args.index(7)
|
||
|
equal_w_h=args.index(8)
|
||
|
pattern_rotation=args.index(9)
|
||
|
shape_rotation=args.index(10)
|
||
|
tool=args.index(11)
|
||
|
long_gradient=args.index(12)
|
||
|
|
||
|
if run_mode == Pika.RunMode.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(layers[0])
|
||
|
|
||
|
elif run_mode == Pika.RunMode.INTERACTIVE:
|
||
|
window = SpyroWindow(image, layers[0])
|
||
|
Gtk.main()
|
||
|
|
||
|
elif run_mode == Pika.RunMode.WITH_LAST_VALS:
|
||
|
pp = unshelf_parameters()
|
||
|
engine = DrawingEngine(image, pp)
|
||
|
engine.draw_full(layers[0])
|
||
|
|
||
|
return procedure.new_return_values(Pika.PDBStatusType.SUCCESS, GLib.Error())
|
||
|
|
||
|
|
||
|
Pika.main(SpyropikaPlusPlugin.__gtype__, sys.argv)
|