Skip to content
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2017 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
####################################################################################################
from .Primitive import PrimitiveNP, Primitive2DMixin
from .Segment import Segment2D
####################################################################################################
class Polyline2D(Primitive2DMixin, PrimitiveNP):
"""Class to implements 2D Polyline."""
##############################################
def __init__(self, *points):
if len(points) < 2:
raise ValueError('Polyline require at least 2 vertexes')
PrimitiveNP.__init__(self, points)
self._edges = None
##############################################
@property
def edges(self):
if self._edges is None:
self._edges = [Segment2D(self._points[i], self._points[i+1])
for i in range(self.number_of_points -1)]
return iter(self._edges)
##############################################
@property
def length(self):
return sum([edge.magnitude for edge in self.edges])
##############################################
def distance_to_point(self, point):
distance = None
for edge in self.edges:
edge_distance = edge.distance_to_point(point)
if distance is None or edge_distance < distance:
distance = edge_distance
return distance
...@@ -31,6 +31,8 @@ __all__ = [ ...@@ -31,6 +31,8 @@ __all__ = [
import collections import collections
import numpy as np
from .BoundingBox import bounding_box_from_points from .BoundingBox import bounding_box_from_points
#################################################################################################### ####################################################################################################
...@@ -143,6 +145,18 @@ class Primitive: ...@@ -143,6 +145,18 @@ class Primitive:
self._set_points([transformation*p for p in self.points]) self._set_points([transformation*p for p in self.points])
##############################################
@property
def geometry_matrix(self):
return np.array(list(self.points)).transpose()
##############################################
def is_close(self, other):
# Fixme: verus is_closed
return np.allclose(self.geometry_matrix, other.geometry_matrix)
#################################################################################################### ####################################################################################################
class Primitive2DMixin: class Primitive2DMixin:
...@@ -386,12 +400,19 @@ class PrimitiveNP(Primitive, ReversiblePrimitiveMixin): ...@@ -386,12 +400,19 @@ class PrimitiveNP(Primitive, ReversiblePrimitiveMixin):
############################################## ##############################################
def __init__(self, *points): @staticmethod
def handle_points(points):
if len(points) == 1 and isinstance(points[0], collections.Iterable): if len(points) == 1 and isinstance(points[0], collections.Iterable):
points = points[0] points = points[0]
return points
##############################################
def __init__(self, *points):
points = self.handle_points(points)
self._points = [self.__vector_cls__(p) for p in points] self._points = [self.__vector_cls__(p) for p in points]
self._point_array = None
############################################## ##############################################
...@@ -421,10 +442,29 @@ class PrimitiveNP(Primitive, ReversiblePrimitiveMixin): ...@@ -421,10 +442,29 @@ class PrimitiveNP(Primitive, ReversiblePrimitiveMixin):
############################################## ##############################################
@property
def point_array(self):
if self._point_array is None:
self._point_array = np.array([point for point in self._points])
return self._point_array
##############################################
def _set_points(self, points): def _set_points(self, points):
self._points = points self._points = points
self._point_array = None
############################################## ##############################################
def __getitem__(self, _slice): def __getitem__(self, _slice):
return self._points[_slice] return self._points[_slice]
##############################################
def iter_on_nuplets(self, size):
if size > self.number_of_points:
raise ValueError('size {} > number of points {}'.format(size, self.number_of_points))
for i in range(self.number_of_points - size +1):
yield self._points[i:i+size]
...@@ -108,3 +108,8 @@ class Rectangle2D(Primitive2DMixin, Primitive2P): ...@@ -108,3 +108,8 @@ class Rectangle2D(Primitive2DMixin, Primitive2P):
bounding_box = self.bounding_box bounding_box = self.bounding_box
return (point.x in bounding_box.x and return (point.x in bounding_box.x and
point.y in bounding_box.y) point.y in bounding_box.y)
##############################################
def distance_to_point(self, point):
raise NotImplementedError
...@@ -50,12 +50,12 @@ class Segment2D(Primitive2DMixin, Primitive2P): ...@@ -50,12 +50,12 @@ class Segment2D(Primitive2DMixin, Primitive2P):
@property @property
def length(self): def length(self):
return self.vector.magnitude() return self.vector.magnitude
@property @property
def center(self): def center(self):
# midpoint, barycenter # midpoint, barycenter
return (self._p0 * self._p1) / 2 return (self._p0 + self._p1) / 2
############################################## ##############################################
...@@ -67,6 +67,7 @@ class Segment2D(Primitive2DMixin, Primitive2P): ...@@ -67,6 +67,7 @@ class Segment2D(Primitive2DMixin, Primitive2P):
############################################## ##############################################
def to_line(self): def to_line(self):
# Fixme: cache
return Line2D.from_two_points(self._p1, self._p0) return Line2D.from_two_points(self._p1, self._p0)
############################################## ##############################################
...@@ -160,3 +161,22 @@ class Segment2D(Primitive2DMixin, Primitive2P): ...@@ -160,3 +161,22 @@ class Segment2D(Primitive2DMixin, Primitive2P):
def is_collinear(self, point): def is_collinear(self, point):
"""Tests if a point is on line""" """Tests if a point is on line"""
return self.side_of(point) == 0 return self.side_of(point) == 0
##############################################
def distance_to_point(self, point):
line = self.to_line()
if line.v.magnitude_square == 0:
return (self._p0 - point).magnitude
d, s = line.distance_and_abscissa_to_line(point)
if 0 <= s <= self.length:
return abs(d)
else:
if s < 0:
p = self._p0
else:
p = self._p1
return (p - point).magnitude
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2017 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
r"""Module to implement Spline curve.
B-spline Basis
--------------
A nonuniform, nonrational B-spline of order `k` is a piecewise polynomial function of degree
:math:`k - 1` in a variable `t`.
.. check: k+1 knots ???
.. It is defined over :math:`k + 1` locations :math:`t_i`, called knots, which must be in
non-descending order :math:`t_i \leq t_{i+1}`. This series defines a knot vector :math:`T = (t_0,
\ldots, t_{k})`.
A set of non-descending breaking points, called knot, :math:`t_0 \le t_1 \le \ldots \le t_m` defines
a knot vector :math:`T = (t_0, \ldots, t_{m})`.
If each knot is separated by the same distance `h` (where :math:`h = t_{i+1} - t_i`) from its
predecessor, the knot vector and the corresponding B-splines are called "uniform".
Given a knot vector `T`, the associated B-spline basis functions, :math:`B_i^k(t)` are defined as:
.. t \in [t_i, t_{i+1}[
.. math::
B_i^1(t) =
\left\lbrace
\begin{array}{l}
1 \;\textrm{if}\; t_i \le t < t_{i+1} \\
0 \;\textrm{otherwise}
\end{array}
\right.
.. math::
\begin{split}
B_i^k(t) &= \frac{t - t_i}{t_{i+k-1} - t_i} B_i^{k-1}(t)
+ \frac{t_{i+k} - t}{t_{i+k} - t_{i+1}} B_{i+1}^{k-1}(t) \\
&= w_i^{k-1}(t) B_i^{k-1}(t) + [1 - w_{i+1}^{k-1}(t)] B_{i+1}^{k-1}(t)
\end{split}
where
.. math::
w_i^k(t) =
\left\lbrace
\begin{array}{l}
\frac{t - t_i}{t_{i+k} - t_i} \;\textrm{if}\; t_i < t_{i+k} \\
0 \;\textrm{otherwise}
\end{array}
\right.
These equations have the following properties, for :math:`k > 1` and :math:`i = 0, 1, \ldots, n` :
* Positivity: :math:`B_i^k(t) > 0`, for :math:`t_i < t < t_{i+k}`
* Local Support: :math:`B_i^k(t) = 0`, for :math:`t_0 \le t \le t_i` and :math:`t_{i+k} \le t \le t_{n+k}`
* Partition of unity: :math:`\sum_{i=0}^n B_i^k(t)= 1`, for :math:`t \in [t_0, t_m]`
* Continuity: :math:`B_i^k(t)` as :math:`C^{k-2}` continuity at each simple knot
.. The B-spline contributes only in the range between the first and last of these knots and is zero
elsewhere.
B-spline Curve
--------------
A B-spline curve of order `k` is defined as a linear combination of control points :math:`p_i` and
B-spline basis functions :math:`B_i^k(t)` given by
.. math::
S^k(t) = \sum_{i=0}^{n} p_i\; B_i^k(t) ,\quad n \ge k - 1,\; t \in [t_{k-1}, t_{n+1}]
In this context the control points are called De Boor points. The basis functions :math:`B_i^k(t)`
is defined on a knot vector
.. math::
T = (t_0, t_1, \ldots, t_{k-1}, t_k, t_{k+1}, \ldots, t_{n-1}, t_n, t_{n+1}, \ldots, t_{n+k})
where there are :math:`n+k+1` elements, i.e. the number of control points :math:`n+1` plus the order
of the curve `k`. Each knot span :math:`t_i \le t \le t_{i+1}` is mapped onto a polynomial curve
between two successive joints :math:`S(t_i)` and :math:`S(t_{i+1})`.
Unlike Bézier curves, B-spline curves do not in general pass through the two end control points.
Increasing the multiplicity of a knot reduces the continuity of the curve at that knot.
Specifically, the curve is :math:`(k-p-1)` times continuously differentiable at a knot with
multiplicity :math:`p (\le k)`, and thus has :math:`C^{k-p-1}` continuity. Therefore, the control
polygon will coincide with the curve at a knot of multiplicity :math:`k-1`, and a knot with
multiplicity `k` indicates :math:`C^{-1}` continuity, or a discontinuous curve. Repeating the knots
at the end `k` times will force the endpoints to coincide with the control polygon. Thus the first
and the last control points of a curve with a knot vector described by
.. math::
\begin{eqnarray}
T = (
\underbrace{t_0, t_1, \ldots, t_{k-1},}_{\mbox{$k$ equal knots}}
\quad
\underbrace{t_k, t_{k+1}, \ldots, t_{n-1}, t_n,}_{\mbox{$n$-$k$+1 internal knots}}
\quad
\underbrace{t_{n+1}, \ldots, t_{n+k}}_{\mbox{$k$ equal knots}})
\end{eqnarray}
coincide with the endpoints of the curve. Such knot vectors and curves are known as *clamped*. In
other words, *clamped/unclamped* refers to whether both ends of the knot vector have multiplicity
equal to `k` or not.
**Local support property**: A single span of a B-spline curve is controlled only by `k` control
points, and any control point affects `k` spans. Specifically, changing :math:`p_i` affects the
curve in the parameter range :math:`t_i < t < t_{i+k}` and the curve at a point where :math:`t_r < t
< t_{r+1}` is determined completely by the control points :math:`p_{r-(k-1)}, \ldots, p_r`.
**B-spline to Bézier property**: From the discussion of end points geometric property, it can be
seen that a Bézier curve of order `k` (degree :math:`k-1`) is a B-spline curve with no internal
knots and the end knots repeated `k` times. The knot vector is thus
.. math::
\begin{eqnarray}
T = (
\underbrace{t_0, t_1, \ldots, t_{k-1}}_{\mbox{$k$ equal knots}}
,\quad
\underbrace{t_{n+1}, \ldots, t_{n+k}}_{\mbox{$k$ equal knots}}
)
\end{eqnarray}
where :math:`n+k+1 = 2k` or :math:`n = k-1`.
Algorithms for B-spline curves
------------------------------
Evaluation and subdivision algorithm
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A B-spline curve can be evaluated at a specific parameter value `t` using the de Boor algorithm,
which is a generalization of the de Casteljau algorithm. The repeated substitution of the recursive
definition of the B-spline basis function into the previous definition and re-indexing leads to the
following de Boor algorithm:
.. math::
S(t) = \sum_{i=0}^{n+j} p_i^j B_i^{k-j}(t) ,\quad j = 0, 1, \ldots, k-1
where
.. math::
p_i^j = \Big[1 - w_i^j\Big] p_{i-1}^{j-1} + w_i^j p_i^{j-1}, \; j > 0
with
.. math::
w_i^j = \frac{t - t_i}{t_{i+k-j} - t_i} \quad \textrm{and} \; p_j^0 = p_j
For :math:`j = k-1`, the B-spline basis function reduces to :math:`B_l^1` for :math:`t \in [t_l,
t_{l+1}]`, and :math:`p_l^{k-1}` coincides with the curve :math:`S(t) = p_l^{k-1}`.
The de Boor algorithm is a generalization of the de Casteljau algorithm. The de Boor algorithm also
permits the subdivision of the B-spline curve into two segments of the same order.
De Boor Algorithm
~~~~~~~~~~~~~~~~~
Let the index `l` define the knot interval that contains the position, :math:`t \in [t_l ,
t_{l+1}]`. We can see in the recursion formula that only B-splines with :math:`i = l-K, \dots, l`
are non-zero for this knot interval, where :math:`K = k - 1` is the degree. Thus, the sum is
reduced to:
.. math::
S^k(t) = \sum _{i=l-K}^{l} p_{i} B_i^k(t)
The algorithm does not compute the B-spline functions :math:`B_i^k(t)` directly. Instead it
evaluates :math:`S(t)` through an equivalent recursion formula.
Let :math:`d _i^r` be new control points with :math:`d_i^1 = p_i` for :math:`i = l-K, \dots, l`.
For :math:`r = 2, \dots, k` the following recursion is applied:
.. math::
d_i^r = (1 - w_i^r) d_{i-1}^{r-1} + w_i^r d_i^{r-1} \quad i = l-K+r, \dots, l
w_i^r = \frac{t - t_i}{t_{i+1+l-r} - t_{i}}
Once the iterations are complete, we have :math:`S^k(t) = d_l^k`.
.. , meaning that :math:`d_l^k` is the desired result.
De Boor's algorithm is more efficient than an explicit calculation of B-splines :math:`B_i^k(t)`
with the Cox-de Boor recursion formula, because it does not compute terms which are guaranteed to be
multiplied by zero.
..
:math:`S(t) = p_j^k` for :math:`t \in [t_j , t_{j+1}[` for :math:`k \le j \le n` with the following relation:
.. math::
\begin{split}
p_i^{r+1} &= \frac{t - t_i}{t_{i+k-r} - t} p_i^r + \frac{t_{i+k-r} - t_i}{t_{i+k-r} - t_i} p_{i-1}^r \\
&= w_i^{k-r}(t) p_i^r + (1 - w_i^{k-r}(t)) p_{i-1}^r
\end{split}
Knot insertion
~~~~~~~~~~~~~~
A knot can be inserted into a B-spline curve without changing the
geometry of the curve. The new curve is identical to
.. math::
\begin{array}{lcl}
\sum_{i=0}^n p_i B_i^k(t) & \textrm{becomes} & \sum_{i=0}^{n+1} \bar{p}_i \bar B_i^k(t) \\
\mbox{over}\; T = (t_0, t_1, \ldots, t_l, t_{l+1}, \ldots) & &
\mbox{over}\; T = (t_0, t_1, \ldots, t_l, \bar t, t_{l+1}, \ldots) & &
\end{array}
when a new knot :math:`\bar t` is inserted between knots :math:`t_l` and :math:`t_{l+1}`. The new
de Boor points are given by
.. math::
\bar{p}_i = (1 - w_i) p_{i-1} + w_i p_i
where
.. math::
w_i =
\left\{ \begin{array}{ll}
1 & i \le l-k+1 \\
0 & i \ge l+1 \\
\frac{\bar{t} - t_i}{t_{l+k-1} - t_i} & l-k+2 \le i \leq l
\end{array}
\right.
The above algorithm is also known as **Boehm's algorithm**. A more general (but also more complex)
insertion algorithm permitting insertion of several (possibly multiple) knots into a B-spline knot
vector, known as the Oslo algorithm, was developed by Cohen et al.
A B-spline curve is :math:`C^{\infty}` continuous in the interior of a span. Within exact
arithmetic, inserting a knot does not change the curve, so it does not change the continuity.
However, if any of the control points are moved after knot insertion, the continuity at the knot
will become :math:`C^{k-p-1}`, where `p` is the multiplicity of the knot.
The B-spline curve can be subdivided into Bézier segments by knot insertion at each internal knot
until the multiplicity of each internal knot is equal to `k`.
References
----------
* Computer Graphics, Principle and Practice, Foley et al., Adison Wesley
* http://web.mit.edu/hyperbook/Patrikalakis-Maekawa-Cho/node15.html
"""
# The DeBoor-Cox algorithm permits to evaluate recursively a B-Spline in a similar way to the De
# Casteljaud algorithm for Bézier curves.
#
# Given `k` the degree of the B-spline, `n + 1` control points :math:`p_0, \ldots, p_n`, and an
# increasing series of scalars :math:`t_0 \le t_1 \le \ldots \le t_m` with :math:`m = n + k + 1`,
# called knots.
#
# The number of points must respect the condition :math:`n + 1 \le k`, e.g. a B-spline of degree 3
# must have 4 control points.
####################################################################################################
__all__ = ['BSpline2D']
####################################################################################################
# from math import log, sqrt
import numpy as np
from .Bezier import QuadraticBezier2D, CubicBezier2D
from .Primitive import Primitive3P, Primitive4P, PrimitiveNP, Primitive2DMixin
####################################################################################################
class QuadraticUniformSpline2D(Primitive2DMixin, Primitive3P):
"""Class to implements 2D Quadratic Spline Curve."""
BASIS = np.array((
(1, -2, 1),
(1, 2, -2),
(0, 0, 1),
))
INVERSE_BASIS = np.array((
(-2, 1, -2),
(-2, -3, 1),
(-1, -1, -2),
))
#######################################
def __init__(self, p0, p1, p2):
Primitive3P.__init__(self, p0, p1, p2)
##############################################
def __repr__(self):
return self.__class__.__name__ + '({0._p0}, {0._p1}, {0._p2})'.format(self)
##############################################
def to_bezier(self):
basis = np.dot(self.BASIS, QuadraticBezier2D.INVERSE_BASIS)
points = np.dot(self.geometry_matrix, basis).transpose()
return QuadraticBezier2D(*points)
##############################################
def point_at_t(self, t):
# Q(t) = (
# P0 * (1-t)**3 +
# P1 * ( 3*t**3 - 6*t**2 + 4 ) +
# P2 * ( -3*t**3 + 3*t**2 + 3*t + 1 ) +
# P3 * t**3
# ) / 6
#
# = P0*(1-t)**3/6 + P1*(3*t**3 - 6*t**2 + 4)/6 + P2*(-3*t**3 + 3*t**2 + 3*t + 1)/6 + P3*t**3/6
return (self._p0/6 + self._p1*2/3 + self._p2/6 +
(-self._p0/2 + self._p2/2)*t +
(self._p0/2 - self._p1 + self._p2/2)*t**2 +
(-self._p0/6 + self._p1/2 - self._p2/2 + self._p3/6)*t**3)
####################################################################################################
class CubicUniformSpline2D(Primitive2DMixin, Primitive4P):
"""Class to implements 2D Cubic Spline Curve."""
# T = (1 t t**2 t**3)
# P = (Pi Pi+2 Pi+2 Pi+3)
# Q(t) = T M Pt
# = P Mt Tt
# Basis = Mt
BASIS = np.array((
(1, -3, 3, -1),
(4, 0, -6, 3),
(1, 3, 3, -3),
(0, 0, 0, 1),
)) / 6
INVERSE_BASIS = np.array((
( 1, 1, 1, 1),
( -1, 0, 1, 2),
(2/3, -1/3, 2/3, 11/3),
( 0, 0, 0, 6),
))
#######################################
def __init__(self, p0, p1, p2, p3):
Primitive4P.__init__(self, p0, p1, p2, p3)
##############################################
def __repr__(self):
return self.__class__.__name__ + '({0._p0}, {0._p1}, {0._p2}, {0._p3})'.format(self)
##############################################
def to_bezier(self):
basis = np.dot(self.BASIS, CubicBezier2D.INVERSE_BASIS)
points = np.dot(self.geometry_matrix, basis).transpose()
if self._start:
# list(self.points)[:2]
points[:2] = self._p0, self._p1
elif self._stop:
# list(self.points)[-2:]
points[-2:] = self._p2, self._p3
return CubicBezier2D(*points)
##############################################
def point_at_t(self, t):
# Q(t) = (
# P0 * (1-t)**3 +
# P1 * ( 3*t**3 - 6*t**2 + 4 ) +
# P2 * ( -3*t**3 + 3*t**2 + 3*t + 1 ) +
# P3 * t**3
# ) / 6
#
# = P0*(1-t)**3/6 + P1*(3*t**3 - 6*t**2 + 4)/6 + P2*(-3*t**3 + 3*t**2 + 3*t + 1)/6 + P3*t**3/6
return (self._p0/6 + self._p1*2/3 + self._p2/6 +
(-self._p0/2 + self._p2/2)*t +
(self._p0/2 - self._p1 + self._p2/2)*t**2 +
(-self._p0/6 + self._p1/2 - self._p2/2 + self._p3/6)*t**3)
####################################################################################################
class BSpline2D(Primitive2DMixin, PrimitiveNP):
"""Class to implement a 2D B-Spline curve.
"""
##############################################
@staticmethod
def uniform_knots(degree, number_of_points):
order = degree + 1
if number_of_points < order:
raise ValueError('Inconsistent degree and number of points')
knots = list(range(number_of_points - degree +1))
return [0]*degree + knots + [knots[-1]]*degree
##############################################
@classmethod
def check_for_unifom_knots(cls, degree, number_of_points, knots):
return np.array_equal(cls.uniform_knots(degree, number_of_points), knots)
##############################################
def __init__(self, points, degree, closed=False, knots=None):
points = self.handle_points(points)
PrimitiveNP.__init__(self, points)
self._degree = int(degree)
self._closed = bool(closed) # Fixme: not implemented
if knots is not None:
self._knots = list(knots)
if not np.all(np.diff(self._knots) >= 0):
raise ValueError('Invalid knots {}'.format(knots))
# Fixme: check_for_unifom_knots
self._uniform = False # Fixme:
else:
self._knots = self.uniform_knots(self._degree, self.number_of_points)
self._uniform = True
# self._number_of_points = len(self._knots) - self._degree - 1
# assert (self.number_of_points >= self.order and
# (len(self._coefficients) >= self._number_of_points))
##############################################
@property
def degree(self):
return self._degree
@property
def order(self):
return self._degree +1
@property
def is_closed(self):
return self._closed
@property
def uniform(self):
return self._uniform
@property
def knots(self):
return self._knots
@property
def start_knot(self):
if self._uniform:
return 0
else:
return self._knots[0]
@property
def end_knot(self):
if self._uniform:
return self.number_of_points - self._degree
else:
return self._knots[-1]
@property
def knot_iter(self):
if self._uniform:
return range(self.end_knot)
else:
return iter(self._knots)
@property
def number_of_spans(self):
if self._uniform:
count = self.number_of_points - self._degree
if self._closed:
count += 1
return count
else:
# multiplicity
raise NotImplementedError
##############################################
def span(self, t):
if not(self.start_knot <= t <= self.end_knot):
raise ValueError('Invalid t {}'.format(t))
if self._uniform:
return int(t) + self._degree # start padding
else:
for i, t_span in enumerate(self._knots):
if t < t_span:
return i-1
##############################################
def knot_multiplicity(self, knot):
count = 0
for t in self._knots:
if t == knot:
count += 1
elif t > knot:
break
return count
##############################################
def basis_function(self, i, k, t):
"""De Boor-Cox recursion formula"""
if k == 0:
return 1 if self._knots[i] <= t < self._knots[i+1] else 0
ki = self._knots[i]
kik = self._knots[i+k]
if kik == ki:
c1 = 0
else:
c1 = (t - ki)/(kik - ki) * self.basis_function(i, k-1, t)
ki = self._knots[i+1]
kik = self._knots[i+k+1]
if kik == ki:
c2 = 0
else:
c2 = (kik - t)/(kik - ki) * self.basis_function(i+1, k-1, t)
return c1 + c2
##############################################
def _deboor(self, t):
"""Compute point at t using De Boor algorithm"""
# l_minus_degree = int(t) # span index
# l = l_minus_degree + self._degree # knot index
l = self.span(t)
l_minus_degree = l - self._degree
knots = self._knots
points = [self._points[j + l_minus_degree] for j in range(self.order)]
for r in range(1, self.order):
for j in range(self._degree, r-1, -1):
k = j + l_minus_degree
d = knots[j+1+l-r] - knots[k]
if d == 0:
alpha = 0
else:
alpha = (t - knots[k]) / d
if alpha == 0:
point = points[j-1]
elif alpha == 1:
point = points[j]
else:
point = points[j-1] * (1 - alpha) + points[j] * alpha
points[j] = point
return points[self._degree]
##############################################
def _naive_point_at_t(self, t):
"""Compute point at t using a naive algorithm"""
basis = np.array([self.basis_function(i, self._degree, t)
for i in range(self.number_of_points)])
points = self.point_array
return self.__vector_cls__(np.dot(basis, points))
##############################################
def point_at_t(self, t, naive=False):
# Spline curve as a Bézier span at start and end
if self._uniform:
if t == 0:
return self.start_point
elif t == self.end_knot:
# else computation fail
return self.end_point
if naive:
return self._naive_point_at_t(t)
else:
return self._deboor(t)
##############################################
def insert_knot(self, t):
# http://pages.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/B-spline/single-insertion.html
# t lie in the [t_l, t_{l+1}[ span
# this span is only affected by the control points: P_l, ..., P_{l-degree}
# (number of points = degree + 1 = order)
degree = self._degree
points = self._points
knots = self._knots
l = self.span(t)
new_points = []
for i in range(self.number_of_points +1):
# compute w
if i <= l - degree:
w = 1 # same point
elif i > l:
w = 0 # previous point
else: # blend previous and current point
ti = knots[i]
d = knots[i + degree] - ti
if d > 0:
w = (t - ti) / d
else:
w = 0
if w == 0:
point = points[i-1]
elif w == 1:
point = points[i]
else:
point = points[i-1] * (1 - w) + points[i] * w
new_points.append(point)
# l += degree
knots = self._knots[:l+1] + [t] + self._knots[l+1:]
return self.__class__(new_points, self._degree, knots=knots)
##############################################
def to_bezier_form(self, degree=None):
if not self._uniform:
raise ValueError('Must be uniform')
if degree is None:
degree = self._degree
new_spline = self
for t in range(1, self.end_knot):
multiplicity = self.knot_multiplicity(t)
for i in range(degree - multiplicity +1):
new_spline = new_spline.insert_knot(t)
return new_spline
##############################################
def to_bezier(self):
from . import Bezier
if self._degree == 2:
cls = Bezier.QuadraticBezier2D
elif self._degree >= 3:
cls = Bezier.CubicBezier2D
else:
not NotImplementedError
# Fixme: degree > 3
spline = self.to_bezier_form()
bezier_curves = []
order = spline.order
for i in range(self.number_of_spans):
i_inf = i * order
i_sup = i_inf + order
points = spline._points[i_inf:i_sup]
bezier = cls(*points)
bezier_curves.append(bezier)
return bezier_curves
...@@ -238,6 +238,8 @@ class Vector2DFloatBase(Vector2DBase): ...@@ -238,6 +238,8 @@ class Vector2DFloatBase(Vector2DBase):
@property @property
def magnitude(self): def magnitude(self):
"""Return the magnitude of the vector""" """Return the magnitude of the vector"""
# Note: To avoid float overflow use
# abs(x) * sqrt(1 + (y/x)**2) if x > y
return math.sqrt(self.magnitude_square) return math.sqrt(self.magnitude_square)
############################################## ##############################################
...@@ -463,6 +465,19 @@ class Vector2D(Vector2DFloatBase): ...@@ -463,6 +465,19 @@ class Vector2D(Vector2DFloatBase):
############################################## ##############################################
@staticmethod
def from_ellipse(x_radius, y_radius, angle):
"""Create the vector (x_radius*cos(angle), y_radius*sin(angle)). *angle* is in degree."""
angle = math.radians(angle)
x = x_radius * cos(angle)
y = y_radius * sin(angle)
return Vector2D(x, y) # Fixme: classmethod
##############################################
@staticmethod @staticmethod
def middle(p0, p1): def middle(p0, p1):
"""Return the middle point.""" """Return the middle point."""
...@@ -499,6 +514,7 @@ class Vector2D(Vector2DFloatBase): ...@@ -499,6 +514,7 @@ class Vector2D(Vector2DFloatBase):
def normalise(self): def normalise(self):
"""Normalise the vector""" """Normalise the vector"""
self._v /= self.magnitude self._v /= self.magnitude
return self
############################################## ##############################################
......
...@@ -18,8 +18,35 @@ ...@@ -18,8 +18,35 @@
# #
#################################################################################################### ####################################################################################################
"""This module implements a 2D geometry engine suitable for a low number of graphic entities. It """This module implements a 2D geometry engine which implement standard primitives like line, conic
implements standard primitives like line, segment and Bezier curve. and Bézier curve.
.. note:: This module is a candidate for a dedicated project.
Purpose
-------
The purpose of this module is to provide all the required algorithms in Python language for a 2D
geometry engine. In particular it must avoid the use of a third-party libraries which could be over
sized for our purpose and challenging to trust.
It is not designed to provide optimised algorithms for a large number of graphic entities. Such
optimisations could be provided in addition, in particular if the Python implementation has dramatic
performances.
Bibliographical References
--------------------------
All complex algorithms in this module should have strong references matching theses criteria by
preference order:
* a citation to an article from a well known peer reviewed journal,
* a citation to a reference book authored by a well known author,
* a well written article which can be easily trusted ( in this case an electronic copy of this
article should be added to the repository).
However a Wikipedia article will usually not fulfils the criteria due to the weakness of this
collaborative encyclopedia: article quality, review process, content modification over time.
""" """
......
...@@ -18,11 +18,22 @@ ...@@ -18,11 +18,22 @@
# #
#################################################################################################### ####################################################################################################
"""Module to implement graphic scene items like text, image, line, circle and Bézier curve.
"""
# Fixme: get_geometry / as argument
#################################################################################################### ####################################################################################################
import logging import logging
# from Patro.GeometryPatro.Engine.Vector import Vector2D from Patro.GeometryEngine.Bezier import CubicBezier2D
from Patro.GeometryEngine.Conic import Circle2D, Ellipse2D, AngularDomain
from Patro.GeometryEngine.Polyline import Polyline2D
from Patro.GeometryEngine.Rectangle import Rectangle2D
from Patro.GeometryEngine.Segment import Segment2D
from Patro.GraphicStyle import Colors, StrokeStyle
#################################################################################################### ####################################################################################################
...@@ -30,16 +41,40 @@ _module_logger = logging.getLogger(__name__) ...@@ -30,16 +41,40 @@ _module_logger = logging.getLogger(__name__)
#################################################################################################### ####################################################################################################
class PathStyle: class GraphicPathStyle:
##############################################
def __init__(self,
stroke_style=StrokeStyle.SolidLine,
line_width=1.0,
stroke_color=Colors.black,
fill_color=None, # only for closed path
):
"""*color* can be a defined color name, a '#rrggbb' string or a :class:`Color` instance.
"""
self.stroke_style = stroke_style
self.line_width = line_width
self.stroke_color = stroke_color
self.fill_color = fill_color
############################################## ##############################################
def __init__(self, stroke_style=None, line_width=None, stroke_color=None, fill_color=None): def clone(self):
return self.__class__(
self._stroke_style,
self._line_width,
self._stroke_color,
self._fill_color,
)
##############################################
self._stroke_style = stroke_style def __repr__(self):
self._line_width = line_width return 'GraphicPathStyle({0._stroke_style}, {0._line_width}, {0._stroke_color}, {0._fill_color})'.format(self)
self._stroke_color = stroke_color
self._fill_color = fill_color
############################################## ##############################################
...@@ -49,7 +84,9 @@ class PathStyle: ...@@ -49,7 +84,9 @@ class PathStyle:
@stroke_style.setter @stroke_style.setter
def stroke_style(self, value): def stroke_style(self, value):
self._stroke_style = value self._stroke_style = StrokeStyle(value)
##############################################
@property @property
def line_width(self): def line_width(self):
...@@ -57,7 +94,20 @@ class PathStyle: ...@@ -57,7 +94,20 @@ class PathStyle:
@line_width.setter @line_width.setter
def line_width(self, value): def line_width(self, value):
self._line_width = value self._line_width = value # Fixme: float ???
@property
def line_width_as_float(self):
line_width = self._line_width
# Fixme: use scale ?
if isinstance(line_width, float):
return line_width
else:
line_width = line_width.replace('pt', '')
line_width = line_width.replace('px', '')
return float(line_width)
##############################################
@property @property
def stroke_color(self): def stroke_color(self):
...@@ -65,7 +115,9 @@ class PathStyle: ...@@ -65,7 +115,9 @@ class PathStyle:
@stroke_color.setter @stroke_color.setter
def stroke_color(self, value): def stroke_color(self, value):
self._stroke_color = value self._stroke_color = Colors.ensure_color(value)
##############################################
@property @property
def fill_color(self): def fill_color(self):
...@@ -73,17 +125,60 @@ class PathStyle: ...@@ -73,17 +125,60 @@ class PathStyle:
@fill_color.setter @fill_color.setter
def fill_color(self, value): def fill_color(self, value):
self._fill_color = value self._fill_color = Colors.ensure_color(value)
#################################################################################################### ####################################################################################################
class GraphicItem: class GraphicBezierStyle(GraphicPathStyle):
pass
##############################################
def __init__(self,
# Fixme: duplicate
stroke_style=StrokeStyle.SolidLine,
line_width=1.0,
stroke_color=Colors.black,
fill_color=None, # only for closed path
#
show_control=False,
control_color=None,
):
super().__init__(stroke_style, line_width, stroke_color, fill_color)
self._show_control = show_control
self._control_color = Colors.ensure_color(control_color)
##############################################
def clone(self):
return self.__class__(
self.stroke_style,
self.line_width,
self.stroke_color,
self.fill_color,
self._show_control,
self._control_color,
)
##############################################
@property
def show_control(self):
return self._show_control
@show_control.setter
def show_control(self, value):
self._show_control = value
############################################## ##############################################
# def __init__(self): @property
# pass def control_color(self):
return self._control_color
@control_color.setter
def control_color(self, value):
self._control_color = value
#################################################################################################### ####################################################################################################
...@@ -109,6 +204,10 @@ class PositionMixin: ...@@ -109,6 +204,10 @@ class PositionMixin:
def positions(self): def positions(self):
return (self._position) return (self._position)
@property
def casted_position(self):
return self._scene.cast_position(self._position)
#################################################################################################### ####################################################################################################
class TwoPositionMixin: class TwoPositionMixin:
...@@ -116,7 +215,6 @@ class TwoPositionMixin: ...@@ -116,7 +215,6 @@ class TwoPositionMixin:
############################################## ##############################################
def __init__(self, position1, position2): def __init__(self, position1, position2):
# Fixme: could be Vector2D or name
self._position1 = position1 self._position1 = position1
self._position2 = position2 self._position2 = position2
...@@ -161,13 +259,57 @@ class FourPositionMixin(TwoPositionMixin): ...@@ -161,13 +259,57 @@ class FourPositionMixin(TwoPositionMixin):
#################################################################################################### ####################################################################################################
class CoordinateItem(GraphicItem, PositionMixin): class NPositionMixin:
##############################################
def __init__(self, positions):
self._positions = list(positions)
##############################################
@property
def positions(self): # Fixme: versus points
return self._positions # Fixme: iter list ???
####################################################################################################
class StartStopAngleMixin:
##############################################
def __init__(self, start_angle=0, stop_angle=360):
self._start_angle = start_angle
self._stop_angle = stop_angle
##############################################
@property
def start_angle(self):
return self._start_angle
# @start_angle.setter
# def start_angle(self, value):
# self._start_angle = value
@property
def stop_angle(self):
return self._stop_angle
# @stop_angle.setter
# def stop_angle(self, value):
# self._stop_angle = value
####################################################################################################
class CoordinateItem(PositionMixin):
############################################## ##############################################
def __init__(self, name, position): def __init__(self, name, position):
GraphicItem.__init__(self)
PositionMixin.__init__(self, position) PositionMixin.__init__(self, position)
self._name = str(name) self._name = str(name)
############################################## ##############################################
...@@ -178,14 +320,157 @@ class CoordinateItem(GraphicItem, PositionMixin): ...@@ -178,14 +320,157 @@ class CoordinateItem(GraphicItem, PositionMixin):
#################################################################################################### ####################################################################################################
class TextItem(GraphicItem, PositionMixin): class GraphicItem:
# clipping
# opacity
__subclasses__ = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.__subclasses__.append(cls)
##############################################
def __init__(self, scene, user_data):
self._scene = scene
self._user_data = user_data
self._z_value = 0
self._visible = True
self._selected = False
self._dirty = True
self._geometry = None
self._bounding_box = None
##############################################
@property
def scene(self):
return self._scene
@property
def user_data(self):
return self._user_data
##############################################
def __hash__(self):
return hash(self._user_data)
##############################################
@property
def z_value(self):
return self._z_value
@z_value.setter
def z_value(self, value):
self._z_value = value
@property
def visible(self):
return self._visible
@visible.setter
def visible(self, value):
self._visible = value
@property
def selected(self):
return self._selected
@selected.setter
def selected(self, value):
self._selected = bool(value)
##############################################
@property
def positions(self):
raise NotImplementedError
##############################################
@property
def casted_positions(self):
cast = self._scene.cast_position
return [cast(position) for position in self.positions]
##############################################
@property
def dirty(self):
return self._dirty
@dirty.setter
def dirty(self, value):
if bool(value):
self._dirty = True
else:
self._dirty = True
self._geometry = None
self._bounding_box = None
##############################################
@property
def bounding_box(self):
if self._bounding_box is None:
self._bounding_box = self.get_bounding_box()
return self._bounding_box
@property
def geometry(self):
if self._geometry is None:
self._geometry = self.get_geometry()
self._dirty = False
return self._geometry
##############################################
def get_geometry(self):
raise NotImplementedError
##############################################
def get_bounding_box(self):
return self.geometry.bounding_box
##############################################
def distance_to_point(self, point):
return self.geometry.distance_to_point(point)
####################################################################################################
class Font:
############################################## ##############################################
def __init__(self, position, text): def __init__(self, family, point_size):
GraphicItem.__init__(self)
self.family = family
self.point_size = point_size
####################################################################################################
class TextItem(PositionMixin, GraphicItem):
##############################################
def __init__(self, scene, position, text, font, user_data):
GraphicItem.__init__(self, scene, user_data)
PositionMixin.__init__(self, position) PositionMixin.__init__(self, position)
self._text = str(text) self._text = str(text)
self._font = font
############################################## ##############################################
...@@ -197,13 +482,32 @@ class TextItem(GraphicItem, PositionMixin): ...@@ -197,13 +482,32 @@ class TextItem(GraphicItem, PositionMixin):
# def text(self, value): # def text(self, value):
# self._text = value # self._text = value
@property
def font(self):
return self._font
# @font.setter
# def font(self, value):
# self._font = value
##############################################
def get_geometry(self):
position = self.casted_position
# Fixme: require metric !
# QFontMetrics(font).width(self._text)
return Rectangle2D(position, position)
#################################################################################################### ####################################################################################################
class PathItem: class PathStyleItemMixin(GraphicItem):
############################################## ##############################################
def __init__(self, path_style): def __init__(self, scene, path_style, user_data):
GraphicItem.__init__(self, scene, user_data)
self._path_style = path_style self._path_style = path_style
############################################## ##############################################
...@@ -218,13 +522,23 @@ class PathItem: ...@@ -218,13 +522,23 @@ class PathItem:
#################################################################################################### ####################################################################################################
class CircleItem(PathItem, PositionMixin): class CircleItem(PositionMixin, StartStopAngleMixin, PathStyleItemMixin):
############################################## ##############################################
def __init__(self, position, radius, path_style): def __init__(self, scene, position, radius, path_style, user_data,
PathItem.__init__(self, path_style) start_angle=0, # Fixme: kwargs ?
stop_angle=360,
):
PathStyleItemMixin.__init__(self, scene, path_style, user_data)
PositionMixin.__init__(self, position) PositionMixin.__init__(self, position)
StartStopAngleMixin.__init__(self, start_angle, stop_angle)
# Fixme: radius = 1pt !!!
if radius == '1pt':
radius = 10
self._radius = radius self._radius = radius
############################################## ##############################################
...@@ -237,37 +551,161 @@ class CircleItem(PathItem, PositionMixin): ...@@ -237,37 +551,161 @@ class CircleItem(PathItem, PositionMixin):
# def radius(self, value): # def radius(self, value):
# self._radius = value # self._radius = value
##############################################
def get_geometry(self):
position = self.casted_position
# Fixme: radius
domain = AngularDomain(self._start_angle, self._stop_angle)
return Circle2D(position, self._radius, domain=domain)
#################################################################################################### ####################################################################################################
class SegmentItem(PathItem, TwoPositionMixin): class EllipseItem(PositionMixin, StartStopAngleMixin, PathStyleItemMixin):
##############################################
def __init__(self, scene, position,
x_radius, y_radius,
angle,
path_style, user_data,
start_angle=0,
stop_angle=360,
):
PathStyleItemMixin.__init__(self, scene, path_style, user_data)
PositionMixin.__init__(self, position)
StartStopAngleMixin.__init__(self, start_angle, stop_angle)
self._x_radius = x_radius
self._y_radius = y_radius
self._angle = angle
############################################## ##############################################
def __init__(self, position1, position2, path_style): # segment @property
PathItem.__init__(self, path_style) def x_radius(self):
return self._x_radius
# @x_radius.setter
# def x_radius(self, value):
# self._x_radius = value
@property
def y_radius(self):
return self._y_radius
# @y_radius.setter
# def y_radius(self, value):
# self._y_radius = value
@property
def angle(self):
return self._angle
##############################################
def get_geometry(self):
position = self.casted_position
return Ellipse2D(position, self._x_radius, self._y_radius, self._angle)
####################################################################################################
class SegmentItem(TwoPositionMixin, PathStyleItemMixin):
##############################################
def __init__(self, scene, position1, position2, path_style, user_data):
PathStyleItemMixin.__init__(self, scene, path_style, user_data)
TwoPositionMixin.__init__(self, position1, position2) TwoPositionMixin.__init__(self, position1, position2)
# super(SegmentItem, self).__init__(path_style)
# self._segment = segment
############################################## ##############################################
# @property def get_geometry(self):
# def segment(self): positions = self.casted_positions
# return self._segment return Segment2D(*positions)
####################################################################################################
# @segment.setter class RectangleItem(TwoPositionMixin, PathStyleItemMixin):
# def segment(self, value):
# self._segment = value ##############################################
def __init__(self, scene, position1, position2, path_style, user_data):
# Fixme: position or W H
PathStyleItemMixin.__init__(self, scene, path_style, user_data)
TwoPositionMixin.__init__(self, position1, position2)
##############################################
def get_geometry(self):
positions = self.casted_positions
return Rectangle2D(*positions)
#################################################################################################### ####################################################################################################
class CubicBezierItem(PathItem, FourPositionMixin): class PolylineItem(NPositionMixin, PathStyleItemMixin):
##############################################
def __init__(self, scene, positions, path_style, user_data):
PathStyleItemMixin.__init__(self, scene, path_style, user_data)
NPositionMixin.__init__(self, positions)
############################################## ##############################################
def __init__(self, position1, position2, position3, position4, path_style): # , curve def get_geometry(self):
positions = self.casted_positions
return Polyline2D(*positions)
####################################################################################################
class ImageItem(TwoPositionMixin, GraphicItem):
##############################################
def __init__(self, scene, position1, position2, image, user_data):
# Fixme: position or W H
GraphicItem.__init__(self, scene, user_data)
TwoPositionMixin.__init__(self, position1, position2)
self._image = image
##############################################
@property
def image(self):
return self._image
# @image.setter
# def image(self, value):
# self._image = value
##############################################
def get_geometry(self):
positions = self.casted_positions
return Rectangle2D(*positions)
####################################################################################################
class CubicBezierItem(FourPositionMixin, PathStyleItemMixin):
##############################################
def __init__(self,
scene,
position1, position2, position3, position4,
path_style,
user_data,
):
# Fixme: curve vs path # Fixme: curve vs path
PathItem.__init__(self, path_style) PathStyleItemMixin.__init__(self, scene, path_style, user_data)
FourPositionMixin.__init__(self, position1, position2, position3, position4) FourPositionMixin.__init__(self, position1, position2, position3, position4)
# super(CubicBezierItem, self).__init__(path_style) # super(CubicBezierItem, self).__init__(path_style)
...@@ -282,3 +720,9 @@ class CubicBezierItem(PathItem, FourPositionMixin): ...@@ -282,3 +720,9 @@ class CubicBezierItem(PathItem, FourPositionMixin):
# @curve.setter # @curve.setter
# def curve(self, value): # def curve(self, value):
# self._curve = value # self._curve = value
##############################################
def get_geometry(self):
positions = self.casted_positions
return CubicBezier2D(*positions)
...@@ -18,12 +18,25 @@ ...@@ -18,12 +18,25 @@
# #
#################################################################################################### ####################################################################################################
"""Module to implement a graphic scene.
"""
####################################################################################################
__class__ = ['GraphicScene']
#################################################################################################### ####################################################################################################
import logging import logging
import rtree
from Patro.GeometryEngine.Transformation import AffineTransformation2D from Patro.GeometryEngine.Transformation import AffineTransformation2D
from .GraphicItem import CoordinateItem, TextItem, CircleItem, SegmentItem, CubicBezierItem from Patro.GeometryEngine.Vector import Vector2D
from . import GraphicItem
from .GraphicItem import CoordinateItem
#################################################################################################### ####################################################################################################
...@@ -33,6 +46,21 @@ _module_logger = logging.getLogger(__name__) ...@@ -33,6 +46,21 @@ _module_logger = logging.getLogger(__name__)
class GraphicSceneScope: class GraphicSceneScope:
"""Class to implement a graphic scene."""
__ITEM_CTOR__ = {
'circle': GraphicItem.CircleItem,
'cubic_bezier': GraphicItem.CubicBezierItem,
'ellipse': GraphicItem.EllipseItem,
'image': GraphicItem.ImageItem,
# 'path': GraphicItem.PathItem,
# 'polygon': GraphicItem.PolygonItem,
'rectangle': GraphicItem.RectangleItem,
'segment': GraphicItem.SegmentItem,
'polyline': GraphicItem.PolylineItem,
'text': GraphicItem.TextItem,
}
############################################## ##############################################
def __init__(self, transformation=None): def __init__(self, transformation=None):
...@@ -40,7 +68,15 @@ class GraphicSceneScope: ...@@ -40,7 +68,15 @@ class GraphicSceneScope:
if transformation is None: if transformation is None:
transformation = AffineTransformation2D.Identity() transformation = AffineTransformation2D.Identity()
self._transformation = transformation self._transformation = transformation
self._items = []
self._coordinates = {}
self._items = {} # id(item) -> item, e.g. for rtree query
self._user_data_map = {}
self._rtree = rtree.index.Index()
# item_id -> bounding_box, used to delete item in rtree (cf. rtree api)
self._item_bounding_box_cache = {}
############################################## ##############################################
...@@ -48,90 +84,219 @@ class GraphicSceneScope: ...@@ -48,90 +84,219 @@ class GraphicSceneScope:
def transformation(self): def transformation(self):
return self._transformation return self._transformation
# @transformation.setter
# def transformation(self, value):
# self._transformation = value
############################################## ##############################################
def __iter__(self): def __iter__(self):
return iter(self._items) # must be an ordered item list
return iter(self._items.values())
############################################## ##############################################
def _add_item(self, cls, *args, **kwargs): def z_value_iter(self):
# Fixme: cache ???
# Group by z_value and keep inserting order
z_map = {}
for item in self._items.values():
if item.visible:
items = z_map.setdefault(item.z_value, [])
items.append(item)
for z_value in sorted(z_map.keys()):
for item in z_map[z_value]:
yield item
##############################################
item = cls(*args, **kwargs) @property
self._items.append(item) def selected_items(self):
# Fixme: cache ?
return [item for item in self._items.values() if item.selected]
##############################################
def unselect_items(self):
for item in self.selected_items:
item.selected = False
##############################################
def add_coordinate(self, name, position):
item = CoordinateItem(name, position)
self._coordinates[name] = item
return item return item
############################################## ##############################################
def add_scope(self, *args, **kwargs): def remove_coordinate(self, name):
return self._add_item(GraphicSceneScope, self, *args, **kwargs) del self._coordinates[name]
def add_coordinate(self, *args, **kwargs): ##############################################
return self._add_item(CoordinateItem, *args, **kwargs)
def add_text(self, *args, **kwargs): def coordinate(self, name):
return self._add_item(TextItem, *args, **kwargs) return self._coordinates[name]
def add_segment(self, *args, **kwargs): ##############################################
return self._add_item(SegmentItem, *args, **kwargs)
def add_circle(self, *args, **kwargs): def cast_position(self, position):
return self._add_item(CircleItem, *args, **kwargs)
def add_cubic_bezier(self, *args, **kwargs): """Cast coordinate and apply scope transformation, *position* can be a coordinate name string of a
return self._add_item(CubicBezierItem, *args, **kwargs) :class:`Patro.GeometryEngine.Vector.Vector2D`.
#################################################################################################### """
class GraphicScene: # Fixme: cache ?
if isinstance(position, str):
vector = self._coordinates[position].position
elif isinstance(position, Vector2D):
vector = position
return self._transformation * vector
############################################## ##############################################
def __init__(self): def add_item(self, cls, *args, **kwargs):
item = cls(self, *args, **kwargs)
# print(item, item.user_data, hash(item))
# if item in self._items:
# print('Warning duplicate', item.user_data)
# Fixme: root scope ??? item_id = id(item) # Fixme: hash ???
self._root_scope = GraphicSceneScope() self._items[item_id] = item
# Fixme: don't want to reimplement bounding box for graphic item
# - solution 1 : pass geometric object user_data = item.user_data
# but we want to use named coordinate -> Coordinate union of Vector2D or name if user_data is not None:
# - solution 2 : item -> geometric object -> bounding box user_data_id = id(user_data) # Fixme: hash ???
# need to convert coordinate to vector2d items = self._user_data_map.setdefault(user_data_id, [])
self._bounding_box = None items.append(item)
return item
############################################## ##############################################
@property def remove_item(self, item):
def root_scope(self):
return self._root_scope
@property self.update_rtree(item, insert=False)
def bounding_box(self):
return self._bounding_box items = self.item_for_user_data(item.user_data)
if items:
items.remove(item)
del self._items[item]
##############################################
def item_for_user_data(self, user_data):
user_data_id = id(user_data)
return self._user_data_map.get(user_data_id, None)
##############################################
def update_rtree(self):
for item in self._items.values():
if item.dirty:
self.update_rtree_item(item)
##############################################
def update_rtree_item(self, item, insert=True):
item_id = id(item)
old_bounding_box = self._item_bounding_box_cache.pop(item_id, None)
if old_bounding_box is not None:
self._rtree.delete(item_id, old_bounding_box)
if insert:
# try:
bounding_box = item.bounding_box.bounding_box # Fixme: name
# print(item, bounding_box)
self._rtree.insert(item_id, bounding_box)
self._item_bounding_box_cache[item_id] = bounding_box
# except AttributeError:
# print('bounding_box not implemented for', item)
# pass # Fixme:
##############################################
@bounding_box.setter def item_in_bounding_box(self, bounding_box):
def bounding_box(self, value):
self._bounding_box = value # Fixme: Interval2D ok ?
# print('item_in_bounding_box', bounding_box)
item_ids = self._rtree.intersection(bounding_box)
if item_ids:
return [self._items[item_id] for item_id in item_ids]
else:
return None
##############################################
def item_at(self, position, radius):
x, y = list(position)
bounding_box = (
x - radius, y - radius,
x + radius, y + radius,
)
items = []
for item in self.item_in_bounding_box(bounding_box):
try: # Fixme
distance = item.distance_to_point(position)
# print('distance_to_point {:6.2f} {}'.format(distance, item))
if distance <= radius:
items.append((distance, item))
except NotImplementedError:
pass
return sorted(items, key=lambda pair: pair[0])
############################################## ##############################################
def add_scope(self, *args, **kwargs): # Fixme: !!!
return self._root_scope.add_scope(*args, **kwargs) # def add_scope(self, *args, **kwargs):
# return self.add_item(GraphicSceneScope, self, *args, **kwargs)
##############################################
def bezier_path(self, points, degree, *args, **kwargs):
if degree == 1:
method = self.segment
elif degree == 2:
method = self.quadratic_bezier
elif degree == 3:
method = self.cubic_bezier
else:
raise ValueError('Unsupported degree for Bezier curve: {}'.format(degree))
def add_coordinate(self, *args, **kwargs): # Fixme: generic code
return self._root_scope.add_coordinate(*args, **kwargs)
def add_text(self, *args, **kwargs): number_of_points = len(points)
return self._root_scope.add_text(*args, **kwargs) n = number_of_points -1
if n % degree:
raise ValueError('Wrong number of points for Bezier {} curve: {}'.format(degree, number_of_points))
def add_circle(self, *args, **kwargs): items = []
return self._root_scope.add_circle(*args, **kwargs) for i in range(number_of_points // degree):
j = degree * i
k = j + degree
item = method(*points[j:k+1], *args, **kwargs)
items.append(item)
def add_segment(self, *args, **kwargs): return items
return self._root_scope.add_segment(*args, **kwargs)
##############################################
# Register a method in GraphicSceneScope class for each type of graphic item
def _make_add_item_wrapper(cls):
def wrapper(self, *args, **kwargs):
return self.add_item(cls, *args, **kwargs)
return wrapper
for name, cls in GraphicSceneScope.__ITEM_CTOR__.items():
setattr(GraphicSceneScope, name, _make_add_item_wrapper(cls))
####################################################################################################
def add_cubic_bezier(self, *args, **kwargs): class GraphicScene(GraphicSceneScope):
return self._root_scope.add_cubic_bezier(*args, **kwargs) """Class to implement a graphic scene."""
pass
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2018 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
"""Module to implement common typography units.
"""
# See https://en.wikipedia.org/wiki/Point_(typography)
####################################################################################################
__all__ = [
'InchUnit',
'MmUnit',
'PointUnit',
]
####################################################################################################
class TypographyUnit:
INCH_2_MM = 25.4 # 1 inch = 25.4 mm
POINT_2_INCH = 72 # 1 inch = 72 point PostScript, CSS pt and TeX bp
TEX_POINT_2_INCH = 72.27 # TeX pt
PICA_2_POINT = 12 # 1 pica = 12 point
POINT_2_MM = INCH_2_MM / POINT_2_INCH
MM_2_INCH = 1 / INCH_2_MM
INCH_2_POINT = 1 / POINT_2_INCH
MM_2_POINT = 1 / POINT_2_MM
##############################################
def __init__(self, value):
self._value = value
##############################################
def __float__(self):
return self._value
####################################################################################################
class MmUnit(TypographyUnit):
def to_mm(self):
return self
def to_inch(self):
return InchUnit(self._value * self.MM_2_Point)
def to_point(self):
return PointUnit(self._value * self.MM_2_POINT)
####################################################################################################
class InchUnit(TypographyUnit):
def to_inch(self):
return self
def to_mm(self):
return MmUnit(self._value * self.INCH_2_MM)
def to_point(self):
return PointUnit(self._value * self.INCH_2_POINT)
####################################################################################################
class PointUnit(TypographyUnit):
def to_point(self):
return self
def to_mm(self):
return MmUnit(self._value * self.POINT_2_MM)
def to_inch(self):
return InchUnit(self._value * self.POINT_2_INCH)
...@@ -108,11 +108,11 @@ class EzdxfPainter(DxfPainterBase): ...@@ -108,11 +108,11 @@ class EzdxfPainter(DxfPainterBase):
self._dxf_version = dxf_version self._dxf_version = dxf_version
self._drawing = ezdxf.new(dxf_version) self._drawing = ezdxf.new(dxf_version)
self._model_space= self._drawing.modelspace() # add new entities to the model space self._model_space= self._drawing.modelspace() # add new entities to the model space
self._model_space.page_setup( # self._model_space.page_setup(
size=(self._paper.widh, self._paper.height), # size=(self._paper.width, self._paper.height),
# margins=(top, right, bottom, left), # # margins=(top, right, bottom, left)
units='mm', # units='mm',
) # )
# print('Available line types:') # print('Available line types:')
# for line_type in self._drawing.linetypes: # for line_type in self._drawing.linetypes:
...@@ -141,9 +141,11 @@ class EzdxfPainter(DxfPainterBase): ...@@ -141,9 +141,11 @@ class EzdxfPainter(DxfPainterBase):
# cf. https://ezdxf.readthedocs.io/en/latest/graphic_base_class.html#common-dxf-attributes-for-dxf-r13-or-later # cf. https://ezdxf.readthedocs.io/en/latest/graphic_base_class.html#common-dxf-attributes-for-dxf-r13-or-later
path_syle = item.path_style path_style = item.path_style
color = self.__COLOR__[path_syle.stroke_color] # see also true_color color_name (AutoCAD R2004) if path_style.stroke_color is None:
line_type = self.__STROKE_STYLE__[path_syle.stroke_style] return {'linetype': 'PHANTOM', 'color': 2} # Fixme:
color = self.__COLOR__[path_style.stroke_color] # see also true_color color_name (AutoCAD R2004)
line_type = self.__STROKE_STYLE__[path_style.stroke_style]
# https://ezdxf.readthedocs.io/en/latest/graphic_base_class.html#GraphicEntity.dxf.lineweight # https://ezdxf.readthedocs.io/en/latest/graphic_base_class.html#GraphicEntity.dxf.lineweight
# line_weight = float(path_syle.line_width.replace('pt', '')) / 3 # Fixme: pt ??? # line_weight = float(path_syle.line_width.replace('pt', '')) / 3 # Fixme: pt ???
...@@ -168,7 +170,7 @@ class EzdxfPainter(DxfPainterBase): ...@@ -168,7 +170,7 @@ class EzdxfPainter(DxfPainterBase):
def paint_SegmentItem(self, item): def paint_SegmentItem(self, item):
positions = self._cast_position(item.positions) positions = self._cast_positions(item.positions)
self._model_space.add_line( self._model_space.add_line(
*positions, *positions,
dxfattribs=self._graphic_style(item), dxfattribs=self._graphic_style(item),
...@@ -178,10 +180,12 @@ class EzdxfPainter(DxfPainterBase): ...@@ -178,10 +180,12 @@ class EzdxfPainter(DxfPainterBase):
def paint_CubicBezierItem(self, item): def paint_CubicBezierItem(self, item):
positions = self._cast_position(item.positions) positions = self._cast_positions(item.positions)
for position in positions:
position.append(0)
# https://ezdxf.readthedocs.io/en/latest/layouts.html#Layout.add_open_spline # https://ezdxf.readthedocs.io/en/latest/layouts.html#Layout.add_open_spline
self._model_space.add_open_spline( self._model_space.add_open_spline(
*positions, positions,
degree=3, degree=3,
dxfattribs=self._graphic_style(item), dxfattribs=self._graphic_style(item),
) )
...@@ -195,5 +199,6 @@ _driver_to_cls = { ...@@ -195,5 +199,6 @@ _driver_to_cls = {
def DxfPainter(*args, **kwargs): def DxfPainter(*args, **kwargs):
"""Wrapper to driver classes""" """Wrapper to driver classes"""
driver = kwargs.get('driver', 'ezdxf') driver = kwargs.get('driver', 'ezdxf')
del kwargs['driver'] if 'driver' in kwargs:
del kwargs['driver']
return _driver_to_cls[driver](*args, **kwargs) return _driver_to_cls[driver](*args, **kwargs)
...@@ -24,8 +24,9 @@ import logging ...@@ -24,8 +24,9 @@ import logging
from IntervalArithmetic import Interval2D from IntervalArithmetic import Interval2D
from Patro.GeometryEngine.Vector import Vector2D
from Patro.Common.Math.Functions import ceil_int from Patro.Common.Math.Functions import ceil_int
from Patro.GeometryEngine.Vector import Vector2D
from Patro.GraphicEngine.GraphicScene.GraphicItem import GraphicItem
#################################################################################################### ####################################################################################################
...@@ -76,6 +77,22 @@ class Painter: ...@@ -76,6 +77,22 @@ class Painter:
############################################## ##############################################
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# Register paint methods
paint_method = {}
for item_cls in GraphicItem.__subclasses__:
name = 'paint_' + item_cls.__name__
try:
paint_method[item_cls] = getattr(cls, name)
except AttributeError:
pass
cls.__paint_method__ = paint_method
##############################################
def __init__(self, scene): def __init__(self, scene):
self._scene = scene self._scene = scene
...@@ -93,10 +110,26 @@ class Painter: ...@@ -93,10 +110,26 @@ class Painter:
def paint(self): def paint(self):
for item in self._scene.root_scope: if self._scene is None:
# Fixme: GraphicItemScope return
function = getattr(self, 'paint_' + item.__class__.__name__)
function(item) # Fixme: GraphicItemScope
for item in self._scene.z_value_iter():
self.__paint_method__[item.__class__](self, item)
##############################################
def cast_position(self, position):
"""Call :meth:`GraphicSceneScope.cast_position`, cast coordinate and apply scope transformation,
*position* can be a coordinate name string of a:class:`Vector2D`.
"""
return self._scene.cast_position(position)
##############################################
def cast_item_positions(self, item):
return [self.cast_position(position) for position in item.positions]
############################################## ##############################################
......
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2017 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
####################################################################################################
import logging
import numpy as np
from IntervalArithmetic import Interval2D
from QtShim.QtCore import (
Property, Signal, Slot, QObject,
QRectF, QSizeF, QPointF, Qt,
)
from QtShim.QtGui import QColor, QFont, QFontMetrics, QImage, QPainter, QPainterPath, QBrush, QPen
# from QtShim.QtQml import qmlRegisterType
from QtShim.QtQuick import QQuickPaintedItem
from .Painter import Painter
from Patro.GeometryEngine.Vector import Vector2D
from Patro.GraphicEngine.GraphicScene.Scene import GraphicScene
from Patro.GraphicStyle import StrokeStyle
####################################################################################################
_module_logger = logging.getLogger(__name__)
####################################################################################################
class QtScene(QObject, GraphicScene):
_logger = _module_logger.getChild('QtScene')
##############################################
def __init__(self):
QObject.__init__(self)
GraphicScene.__init__(self)
####################################################################################################
class QtPainter(Painter):
__STROKE_STYLE__ = {
None: None, # Fixme: ???
StrokeStyle.NoPen: Qt.NoPen,
StrokeStyle.SolidLine: Qt.SolidLine,
StrokeStyle.DashLine: Qt.DashLine,
StrokeStyle.DotLine: Qt.DotLine,
StrokeStyle.DashDotLine: Qt.DashDotLine,
StrokeStyle.DashDotDotLine: Qt.DashDotDotLine,
}
_logger = _module_logger.getChild('QtPainter')
##############################################
def __init__(self, scene=None):
super().__init__(scene)
self._show_grid = True
# self._paper = paper
# self._translation = QPointF(0, 0)
# self._scale = 1
##############################################
# @property
# def translation(self):
# return self._translation
# @translation.setter
# def translation(self, value):
# self._translation = value
# @property
# def scale(self):
# return self._scale
# @scale.setter
# def scale(self, value):
# print('set scale', value)
# self._scale = value
##############################################
def paint(self, painter):
self._logger.info('paint')
self._painter = painter
if self._show_grid:
self._paint_grid()
super().paint()
##############################################
def length_scene_to_viewport(self, length):
raise NotImplementedError
@property
def scene_area(self):
raise NotImplementedError
def scene_to_viewport(self, position):
# Note: painter.scale apply to text as well
raise NotImplementedError
# point = QPointF(position.x, position.y)
# point += self._translation
# point *= self._scale
# point = QPointF(point.x(), -point.y())
# return point
##############################################
def cast_position(self, position):
"""Cast coordinate, apply scope transformation and convert scene to viewport, *position* can be a
coordinate name string of a:class:`Vector2D`.
"""
position = super().cast_position(position)
return self.scene_to_viewport(position)
##############################################
def _set_pen(self, item):
path_syle = item.path_style
# print('_set_pen', item, path_syle)
if item.selected:
color = QColor('red') # Fixme: style
else:
color = path_syle.stroke_color
if color is not None:
color = QColor(str(color))
else:
color = None
line_style = self.__STROKE_STYLE__[path_syle.stroke_style]
line_width = path_syle.line_width_as_float
# Fixme: selection style
if item.selected:
line_width *= 4
# print(item, color, line_style)
if color is None or line_style is StrokeStyle.NoPen:
# invisible item
pen = QPen(Qt.NoPen)
# print('Warning Pen:', item, item.user_data, color, line_style)
else:
pen = QPen(
QBrush(color),
line_width,
line_style,
)
self._painter.setPen(pen)
return pen
fill_color = path_syle.fill_color
if fill_color is not None:
color = QColor(str(fill_color))
self._painter.setBrush(color)
else:
self._painter.setBrush(Qt.NoBrush)
return None
##############################################
def _paint_grid(self):
area = self.scene_area
xinf, xsup = area.x.inf, area.x.sup
yinf, ysup = area.y.inf, area.y.sup
color = QColor('black')
brush = QBrush(color)
pen = QPen(brush, .75)
self._painter.setPen(pen)
self._painter.setBrush(Qt.NoBrush)
step = 10
self._paint_axis_grid(xinf, xsup, yinf, ysup, True, step)
self._paint_axis_grid(yinf, ysup, xinf, xsup, False, step)
color = QColor('black')
brush = QBrush(color)
pen = QPen(brush, .25)
self._painter.setPen(pen)
self._painter.setBrush(Qt.NoBrush)
step = 1
self._paint_axis_grid(xinf, xsup, yinf, ysup, True, step)
self._paint_axis_grid(yinf, ysup, xinf, xsup, False, step)
##############################################
def _paint_axis_grid(self, xinf, xsup, yinf, ysup, is_x, step):
for i in range(int(xinf // step), int(xsup // step) +1):
x = i*step
if xinf <= x <= xsup:
if is_x:
p0 = Vector2D(x, yinf)
p1 = Vector2D(x, ysup)
else:
p0 = Vector2D(yinf, x)
p1 = Vector2D(ysup, x)
p0 = self.cast_position(p0)
p1 = self.cast_position(p1)
self._painter.drawLine(p0, p1)
##############################################
def paint_CoordinateItem(self, item):
self._coordinates[item.name] = self.scene_to_viewport(item.position)
##############################################
def paint_CircleItem(self, item):
center = self.cast_position(item.position)
radius = self.length_scene_to_viewport(item.radius)
pen = self._set_pen(item)
rectangle = QRectF(
center + QPointF(-radius, radius),
center + QPointF(radius, -radius),
)
start_angle, stop_angle = [int(angle*16) for angle in (item.start_angle, item.stop_angle)]
span_angle = stop_angle - start_angle
if span_angle < 0:
span_angle = 5760 + span_angle
self._painter.drawArc(rectangle, start_angle, span_angle)
## self._painter.drawArc(center.x, center.y, radius, radius, 0, 360)
##############################################
def paint_EllipseItem(self, item):
center = self.cast_position(item.position)
x_radius = self.length_scene_to_viewport(item.x_radius)
y_radius = self.length_scene_to_viewport(item.y_radius)
pen = self._set_pen(item)
self._painter.drawEllipse(center, x_radius, y_radius)
##############################################
def paint_CubicBezierItem(self, item):
vertices = self.cast_item_positions(item)
pen = self._set_pen(item)
path = QPainterPath()
path.moveTo(vertices[0])
path.cubicTo(*vertices[1:])
self._painter.drawPath(path)
path_style = item.path_style
# if path_style.show_control:
if getattr(path_style, 'show_control', False):
color = QColor(str(path_style.control_color))
brush = QBrush(color)
pen = QPen(brush, 1) # Fixme
self._painter.setPen(pen)
self._painter.setBrush(Qt.NoBrush)
path = QPainterPath()
path.moveTo(vertices[0])
for vertex in vertices[1:]:
path.lineTo(vertex)
self._painter.drawPath(path)
# Fixme:
radius = 3
self._painter.setBrush(brush)
for vertex in vertices:
self._painter.drawEllipse(vertex, radius, radius)
self._painter.setBrush(Qt.NoBrush) # Fixme:
##############################################
def paint_ImageItem(self, item):
vertices = self.cast_item_positions(item)
rec = QRectF(vertices[0], vertices[1])
image = item.image
height, width, bytes_per_line = image.shape
bytes_per_line *= width
qimage = QImage(image, width, height, bytes_per_line, QImage.Format_RGB888)
self._painter.drawImage(rec, qimage)
##############################################
def paint_SegmentItem(self, item):
self._set_pen(item)
vertices = self.cast_item_positions(item)
self._painter.drawLine(*vertices)
##############################################
def paint_PolylineItem(self, item):
self._set_pen(item)
vertices = self.cast_item_positions(item)
path = QPainterPath()
path.moveTo(vertices[0])
for vertex in vertices[1:]:
path.lineTo(vertex)
self._painter.drawPath(path)
##############################################
def paint_TextItem(self, item):
position = self.cast_position(item.position)
font = item.font
qfont = QFont(font.family, font.point_size) # weight, italic = False
# Fixme: anchor position
# font_metrics = QFontMetrics(qfont)
# height = font_metrics.height()
# width = font_metrics.width(item.text)
self._painter.setFont(qfont)
self._painter.drawText(position, item.text)
####################################################################################################
class ViewportArea:
##############################################
def __init__(self):
self._scene = None
# self._width = None
# self._height = None
self._viewport_size = None
self._scale = 1
self._center = None
self._area = None
##############################################
def __bool__(self):
return self._scene is not None
##############################################
@property
def viewport_size(self):
return self._viewport_size
# return (self._width, self._height)
@viewport_size.setter
def viewport_size(self, geometry):
# self._width = geometry.width()
# self._height = geometry.height()
self._viewport_size = np.array((geometry.width(), geometry.height()), dtype=np.float)
if self:
self._update_viewport_area()
##############################################
@property
def scene(self):
return self._scene
@scene.setter
def scene(self, value):
self._scene = value
@property
def scene_area(self):
if self:
return self._scene.bounding_box
else:
return None
##############################################
# @property
# def scale(self):
# return self._scale # px / mm
@property
def scale_px_by_mm(self):
return self._scale
@property
def scale_mm_by_px(self):
return 1 / self._scale
@property
def center(self):
return self._center
# @property
# def center_as_point(self):
# return QPointF(self._center[0], self._center[1])
@property
def area(self):
return self._area
##############################################
def _update_viewport_area(self):
offset = self._viewport_size / 2 * self.scale_mm_by_px
x, y = list(self._center)
dx, dy = list(offset)
self._area = Interval2D(
(x - dx, x + dx),
(y - dy, y + dy),
)
# Fixme: QPointF ???
self._translation = - QPointF(self._area.x.inf, self._area.y.sup)
print('_update_viewport_area', self._center, self.scale_mm_by_px, self._area)
##############################################
def _compute_scale_to_fit_scene(self, margin=None):
# width_scale = self._width / scene_area.x.length
# height_scale = self._height / scene_area.y.length
# scale = min(width_scale, height_scale)
# scale [px/mm]
axis_scale = self._viewport_size / np.array(self.scene_area.size, dtype=np.float)
axis = axis_scale.argmin()
scale = axis_scale[axis]
return scale, axis
##############################################
def zoom_at(self, center, scale):
self._center = center
self._scale = scale
self._update_viewport_area()
##############################################
def fit_scene(self):
if self:
center = np.array(self.scene_area.center, dtype=np.float)
scale, axis = self._compute_scale_to_fit_scene()
self.zoom_at(center, scale)
##############################################
def scene_to_viewport(self, position):
point = QPointF(position.x, position.y)
point += self._translation
point *= self._scale
point = QPointF(point.x(), -point.y())
return point
##############################################
def length_scene_to_viewport(self, length):
return length * self._scale
##############################################
def viewport_to_scene(self, position):
point = QPointF(position.x(), -position.y())
point /= self._scale
point -= self._translation
return np.array((point.x(), point.y()), dtype=np.float)
##############################################
def pan_delta_to_scene(self, position):
# Fixme:
point = QPointF(position.x(), position.y())
# point /= self._scale
return np.array((point.x(), point.y()), dtype=np.float)
####################################################################################################
class QtQuickPaintedSceneItem(QQuickPaintedItem, QtPainter):
_logger = _module_logger.getChild('QtQuickPaintedSceneItem')
##############################################
def __init__(self, parent=None):
QQuickPaintedItem.__init__(self, parent)
QtPainter.__init__(self)
self.setAntialiasing(True)
# self.setRenderTarget(QQuickPaintedItem.Image) # high quality antialiasing
self.setRenderTarget(QQuickPaintedItem.FramebufferObject) # use OpenGL
self._viewport_area = ViewportArea()
##############################################
def geometryChanged(self, new_geometry, old_geometry):
print('geometryChanged', new_geometry, old_geometry)
self._viewport_area.viewport_size = new_geometry
# if self._scene:
# self._update_transformation()
QQuickPaintedItem.geometryChanged(self, new_geometry, old_geometry)
##############################################
# def _update_transformation(self):
# area = self._viewport_area.area
# self.translation = - QPointF(area.x.inf, area.y.sup)
# self.scale = self._viewport_area.scale_px_by_mm # QtPainter
##############################################
@property
def scene_area(self):
return self._viewport_area.area
##############################################
def scene_to_viewport(self, position):
return self._viewport_area.scene_to_viewport(position)
##############################################
def length_scene_to_viewport(self, length):
return self._viewport_area.length_scene_to_viewport(length)
##############################################
sceneChanged = Signal()
@Property(QtScene, notify=sceneChanged)
def scene(self):
return self._scene
@scene.setter
def scene(self, scene):
if self._scene is not scene:
print('QtQuickPaintedSceneItem set scene', scene)
self._logger.info('set scene') # Fixme: don't print ???
self._scene = scene
self._viewport_area.scene = scene
self._viewport_area.fit_scene()
# self._update_transformation()
self.update()
self.sceneChanged.emit()
##############################################
# zoomChanged = Signal()
# @Property(float, notify=zoomChanged)
# def zoom(self):
# return self._zoom
# @zoom.setter
# def zoom(self, zoom):
# if self._zoom != zoom:
# print('QtQuickPaintedSceneItem zoom', zoom, self.width(), self.height())
# self._zoom = zoom
# self.set_transformation(zoom)
# self.update()
# self.zoomChanged.emit()
##############################################
@Property(float)
def zoom(self):
return self._viewport_area.scale_px_by_mm
##############################################
@Slot(QPointF, result=str)
def format_coordinate(self, position):
scene_position = self._viewport_area.viewport_to_scene(position)
return '{:.3f}, {:.3f}'.format(scene_position[0], scene_position[1])
##############################################
@Slot(float)
def zoom_at_center(self, zoom):
self._viewport_area.zoom_at(self._viewport_area.center, zoom)
self.update()
##############################################
@Slot(QPointF, float)
def zoom_at(self, position, zoom):
print('zoom_at', position, zoom)
scene_position = self._viewport_area.viewport_to_scene(position)
self._viewport_area.zoom_at(scene_position, zoom)
self.update()
##############################################
@Slot()
def fit_scene(self):
self._viewport_area.fit_scene()
self.update()
##############################################
@Slot(QPointF)
def pan(self, dxy):
position = self._viewport_area.center + self._viewport_area.pan_delta_to_scene(dxy)
self._viewport_area.zoom_at(position, self._viewport_area.scale_px_by_mm)
self.update()
##############################################
@Slot(QPointF)
def item_at(self, position):
# Fixme: 1 = 1 cm
# as f of zoom ?
radius = 0.3
self._scene.update_rtree()
self._scene.unselect_items()
scene_position = Vector2D(self._viewport_area.viewport_to_scene(position))
items = self._scene.item_at(scene_position, radius)
if items:
distance, nearest_item = items[0]
print('nearest item at {} #{:6.2f} {} {}'.format(scene_position, len(items), distance, nearest_item.user_data))
nearest_item.selected = True
# Fixme: z_value ???
for pair in items[1:]:
distance, item = pair
print(' {:6.2f} {}'.format(distance, item.user_data))
self.update()
####################################################################################################
# qmlRegisterType(QtQuickPaintedSceneItem, 'Patro', 1, 0, 'PaintedSceneItem')
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2019 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
"""This module implements a 2D graphic engine as a scene rendered by a painter class.
A scene contains graphic items like text, image line, circle and Bézier curve. A painter class is
responsible to render the scene on the screen or a graphic file format.
These painters are available for screen rendering:
* Matplotlib : :mod:`Patro.GraphicEngine.Painter.MplPainter`
* Qt : :mod:`Patro.GraphicEngine.Painter.QtPainter`
These painters are available for graphic file format:
* DXF : :mod:`Patro.GraphicEngine.Painter.DxfPainter`
* LaTeX : :mod:`Patro.GraphicEngine.Painter.TexPainter`
* PDF : :mod:`Patro.GraphicEngine.Painter.PdfPainter`
* SVG : :mod:`Patro.GraphicEngine.Painter.SvgPainter`
"""
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2019 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
"""Module to implement a colour database.
"""
####################################################################################################
__all__ = ['Color', 'ColorDataBase']
####################################################################################################
class Color:
"""Class to define a colour
Usage::
color.red
color.blue
color.green
color.name
# to get a '#rrggbb' string
str(color)
color1 == color2
"""
__STR_FORMAT__ = '#' + '{:02x}'*3
##############################################
def __init__(self, *args, **kwargs):
# self._rgb = str(rgb)
number_of_args = len(args)
if number_of_args == 1:
color = args[0]
if isinstance(str, Color):
self._red = color.red
self._green = color.green
self._blue = color.blue
else:
rgb = str(color)
if not rgb.startswith('#'):
raise ValueError('Invalid color {}'.format(rgb))
rgb = rgb[1:]
self._red = int(rgb[:2], 16)
self._green = int(rgb[2:4], 16)
self._blue = int(rgb[-2:], 16)
elif number_of_args == 3:
self._red, self._green, self._blue = [int(arg) for arg in args]
# self._name = kwargs.get('name', None)
if 'name' in kwargs:
self._name = kwargs['name']
else:
self._name = None
##############################################
def clone(self):
return self.__class__(self._red, self._green, self._blue, name=self._name)
##############################################
def __str__(self):
return self.__STR_FORMAT__.format(self._red, self._green, self._blue)
##############################################
def __repr__(self):
return 'Color({})'.format(str(self))
##############################################
def _check_value(self, value):
if isinstance(value, int):
if 0 <= value <= 255:
return value
if isinstance(value, float):
if 0 <= value <= 1:
return int(value * 255)
raise ValueError('Invalid colour {}'.format(value))
##############################################
@property
def red(self):
return self._red
@red.setter
def red(self, value):
self._red = self._check_value(value)
@property
def green(self):
return self._green
@green.setter
def green(self, value):
self._green = self._check_value(value)
@property
def blue(self):
return self._blue
@blue.setter
def blue(self, value):
self._blue = self._check_value(value)
##############################################
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = str(value)
##############################################
def __eq__(self, other):
return str(self) == str(other)
####################################################################################################
class ColorDataBase:
"""Class to implement a colour database.
The class implements a dictionary API::
color_database['black']
'black' in 'color_database
for color in color_database:
pass
We can get a color directly using::
color_database.black
"""
##############################################
def __init__(self):
self._colors = {}
##############################################
def __len__(self):
return len(self._colors)
def __iter__(self):
return iter(self._colors.values())
def __contains__(self, name):
return name in self._colors
def __getitem__(self, name):
return self._colors[str(name)]
def __getattr__(self, name):
return self._colors[name]
##############################################
def iter_names(self):
return iter(self._colors.keys())
##############################################
def add(self, name, color):
"""Register a :class:`Color` instance"""
# Fixme: color.name ???
self._colors[str(name)] = color
##############################################
def ensure_color(self, color):
"""Ensure *color* is a :class:`Color` instance"""
if color is None:
return None
elif isinstance(color, Color):
return color
elif isinstance(color, str):
if color.startswith('#'):
return Color(color)
else:
return self[color]
raise ValueError('Invalid color {}'.format(color))
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2019 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
"""Module to implement a colour database.
"""
####################################################################################################
__all__ = ['Colors']
####################################################################################################
from . import color_data as _color_data
from .ColorDataBase import Color, ColorDataBase
####################################################################################################
#: Colour Database Singleton as an instance of :class:`ColorDataBase`
Colors = ColorDataBase()
def __init__():
# First name set color
for color_set in (
_color_data.BASE_COLORS,
_color_data.TABLEAU_COLORS,
_color_data.XKCD_COLORS,
_color_data.CSS4_COLORS,
_color_data.QML_COLORS,
_color_data.VALENTINA_COLORS ,
):
for name, value in color_set.items():
color = Color(value, name=name)
if name in Colors and color != Colors[name]:
pass
# print('# {:15} {} vs {}'.format(name, color, Colors[name]))
# print('Warning: color name clash: {} {} vs {}'.format(name, color, Colors[name]))
else:
Colors.add(name, color)
__init__()
#################################################################################################### ####################################################################################################
# #
# Patro - A Python library to make patterns for fashion design # Patro - A Python library to make patterns for fashion design
# Copyright (C) 2017 Fabrice Salvaire # Copyright (C) 2018 Fabrice Salvaire
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU General Public License as published by
...@@ -18,19 +18,94 @@ ...@@ -18,19 +18,94 @@
# #
#################################################################################################### ####################################################################################################
"""Module to define colours from several sets.
"""
####################################################################################################
# from Matplotlib, Valentina, Qt # from Matplotlib, Valentina, Qt
__all__ = [
'BASE_COLORS',
'TABLEAU_COLORS',
'XKCD_COLORS',
'CSS4_COLORS',
'QML_COLORS',
'VALENTINA_COLORS',
]
####################################################################################################
# aqua #00ffff vs #13eac9
# aquamarine #7fffd4 vs #04d8b2
# azure #f0ffff vs #069af3
# beige #f5f5dc vs #e6daa6
# blue #0343df vs #0000ff
# blue #1f77b4 vs #0000ff
# brown #653700 vs #8c564b
# brown #a52a2a vs #8c564b
# chartreuse #7fff00 vs #c1f80a
# chocolate #d2691e vs #3d1c02
# coral #ff7f50 vs #fc5a50
# crimson #dc143c vs #8c000f
# cyan #17becf vs #00ffff
# darkblue #00008b vs #030764
# darkgreen #006400 vs #054907
# fuchsia #ff00ff vs #ed0dd9
# goldenrod #daa520 vs #fac205
# gold #ffd700 vs #dbb40c
# gray #808080 vs #7f7f7f
# green #008000 vs #00ff00
# green #15b01a vs #00ff00
# green #2ca02c vs #00ff00
# grey #808080 vs #929591
# indigo #4b0082 vs #380282
# ivory #fffff0 vs #ffffcb
# khaki #f0e68c vs #aaa662
# lavender #e6e6fa vs #c79fef
# lightblue #add8e6 vs #7bc8f6
# lightgreen #90ee90 vs #76ff7b
# lime #00ff00 vs #aaff32
# magenta #c20078 vs #ff00ff
# maroon #800000 vs #650021
# navy #000080 vs #01153e
# olive #6e750e vs #bcbd22
# olive #808000 vs #bcbd22
# orange #f97306 vs #ff7f0e
# orange #ffa500 vs #ff7f0e
# orangered #ff4500 vs #fe420f
# orchid #da70d6 vs #c875c4
# pink #ff81c0 vs #e377c2
# pink #ffc0cb vs #e377c2
# plum #dda0dd vs #580f41
# purple #7e1e9c vs #9467bd
# purple #800080 vs #9467bd
# red #d62728 vs #ff0000
# red #e50000 vs #ff0000
# salmon #fa8072 vs #ff796c
# sienna #a0522d vs #a9561e
# silver #c0c0c0 vs #c5c9c7
# tan #d2b48c vs #d1b26f
# teal #008080 vs #029386
# tomato #ff6347 vs #ef4026
# turquoise #40e0d0 vs #06c2ac
# violet #ee82ee vs #9a0eea
# wheat #f5deb3 vs #fbdd7e
# yellow #ffff14 vs #ffff00
# yellowgreen #9acd32 vs #bbf90f
#################################################################################################### ####################################################################################################
BASE_COLORS = { BASE_COLORS = {
'black': (0, 0, 0), 'black': '#000000',
'blue': (0, 0, 1), 'blue': '#0000ff',
'green': (0, 1, 0), 'green': '#00ff00',
'cyan': (0, 1, 1), 'cyan': '#00ffff',
'red': (1, 0, 0), 'red': '#ff0000',
'magenta': (1, 0, 1), 'magenta': '#ff00ff',
'yellow': (1, 1, 0), 'yellow': '#ffff00',
'white': (1, 1, 1), 'white': '#ffffff',
} }
#################################################################################################### ####################################################################################################
...@@ -783,7 +858,7 @@ XKCD_COLORS = { ...@@ -783,7 +858,7 @@ XKCD_COLORS = {
'purple grey': '#866f85', 'purple grey': '#866f85',
'purple pink': '#e03fd8', 'purple pink': '#e03fd8',
'purple red': '#990147', 'purple red': '#990147',
'purple': '#7e1e9c, 'purple': '#7e1e9c',
'purple/blue': '#5d21d0', 'purple/blue': '#5d21d0',
'purple/pink': '#d725de', 'purple/pink': '#d725de',
'purpleish blue': '#6140ef', 'purpleish blue': '#6140ef',
......
####################################################################################################
#
# Patro - A Python library to make patterns for fashion design
# Copyright (C) 2019 Fabrice Salvaire
#
# 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 <http://www.gnu.org/licenses/>.
#
####################################################################################################
"""Module to define graphic styles like colours and stroke styles.
This module import :class:`Color.Colors`.
"""
####################################################################################################
__all__ = ['Colors', 'StrokeStyle']
####################################################################################################
from enum import Enum, auto
#: Colour Database Singleton as an instance of :class:`ColorDataBase`
from .Color import Colors
####################################################################################################
class StrokeStyle(Enum):
"""Enum class to define stroke styles"""
NoPen = auto()
SolidLine = auto()
DashLine = auto()
DotLine = auto()
DashDotLine = auto()
DashDotDotLine = auto()
...@@ -92,7 +92,7 @@ class Measurement: ...@@ -92,7 +92,7 @@ class Measurement:
#################################################################################################### ####################################################################################################
class Measurements: class Measurements: # Fixme: -> MeasurementSet as well as file
"""Class to store a set of measurements""" """Class to store a set of measurements"""
......
...@@ -79,28 +79,6 @@ class Measurement: ...@@ -79,28 +79,6 @@ class Measurement:
#################################################################################################### ####################################################################################################
class ValentinaMeasurement(Measurement):
##############################################
def __init__(self, code, name, full_name, description, default_value):
super().__init__(name, full_name, description, default_value)
self._code = code
##############################################
@property
def code(self):
return self._code
@code.setter
def code(self, value):
self._code = value
####################################################################################################
class StandardMeasurement: class StandardMeasurement:
############################################## ##############################################
...@@ -150,21 +128,3 @@ class StandardMeasurement: ...@@ -150,21 +128,3 @@ class StandardMeasurement:
for measurement in self: for measurement in self:
print(template.format(measurement)) print(template.format(measurement))
####################################################################################################
class ValentinaStandardMeasurement(StandardMeasurement):
##############################################
def __init__(self):
super().__init__()
yaml_path = Path(__file__).parent.joinpath('data', 'valentina-standard-measurements.yaml')
with open(yaml_path, 'r') as fh:
data = yaml.load(fh.read())
for topic in data.values():
for code, measurement_data in topic['measurements'].items():
measurement = ValentinaMeasurement(code, *measurement_data)
self.add(measurement)