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__ = [
import collections
import numpy as np
from .BoundingBox import bounding_box_from_points
####################################################################################################
......@@ -143,6 +145,18 @@ class Primitive:
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:
......@@ -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):
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._point_array = None
##############################################
......@@ -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):
self._points = points
self._point_array = None
##############################################
def __getitem__(self, _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):
bounding_box = self.bounding_box
return (point.x in bounding_box.x and
point.y in bounding_box.y)
##############################################
def distance_to_point(self, point):
raise NotImplementedError
......@@ -50,12 +50,12 @@ class Segment2D(Primitive2DMixin, Primitive2P):
@property
def length(self):
return self.vector.magnitude()
return self.vector.magnitude
@property
def center(self):
# midpoint, barycenter
return (self._p0 * self._p1) / 2
return (self._p0 + self._p1) / 2
##############################################
......@@ -67,6 +67,7 @@ class Segment2D(Primitive2DMixin, Primitive2P):
##############################################
def to_line(self):
# Fixme: cache
return Line2D.from_two_points(self._p1, self._p0)
##############################################
......@@ -160,3 +161,22 @@ class Segment2D(Primitive2DMixin, Primitive2P):
def is_collinear(self, point):
"""Tests if a point is on line"""
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
This diff is collapsed.
......@@ -238,6 +238,8 @@ class Vector2DFloatBase(Vector2DBase):
@property
def magnitude(self):
"""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)
##############################################
......@@ -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
def middle(p0, p1):
"""Return the middle point."""
......@@ -499,6 +514,7 @@ class Vector2D(Vector2DFloatBase):
def normalise(self):
"""Normalise the vector"""
self._v /= self.magnitude
return self
##############################################
......
......@@ -18,8 +18,35 @@
#
####################################################################################################
"""This module implements a 2D geometry engine suitable for a low number of graphic entities. It
implements standard primitives like line, segment and Bezier curve.
"""This module implements a 2D geometry engine which implement standard primitives like line, conic
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,12 +18,25 @@
#
####################################################################################################
"""Module to implement a graphic scene.
"""
####################################################################################################
__class__ = ['GraphicScene']
####################################################################################################
import logging
import rtree
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__)
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):
......@@ -40,7 +68,15 @@ class GraphicSceneScope:
if transformation is None:
transformation = AffineTransformation2D.Identity()
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:
def transformation(self):
return self._transformation
# @transformation.setter
# def transformation(self, value):
# self._transformation = value
##############################################
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)
self._items.append(item)
@property
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
##############################################
def add_scope(self, *args, **kwargs):
return self._add_item(GraphicSceneScope, self, *args, **kwargs)
def remove_coordinate(self, name):
del self._coordinates[name]
def add_coordinate(self, *args, **kwargs):
return self._add_item(CoordinateItem, *args, **kwargs)
##############################################
def add_text(self, *args, **kwargs):
return self._add_item(TextItem, *args, **kwargs)
def coordinate(self, name):
return self._coordinates[name]
def add_segment(self, *args, **kwargs):
return self._add_item(SegmentItem, *args, **kwargs)
##############################################
def add_circle(self, *args, **kwargs):
return self._add_item(CircleItem, *args, **kwargs)
def cast_position(self, position):
def add_cubic_bezier(self, *args, **kwargs):
return self._add_item(CubicBezierItem, *args, **kwargs)
"""Cast coordinate and apply scope transformation, *position* can be a coordinate name string of a
: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 ???
self._root_scope = GraphicSceneScope()
# Fixme: don't want to reimplement bounding box for graphic item
# - solution 1 : pass geometric object
# but we want to use named coordinate -> Coordinate union of Vector2D or name
# - solution 2 : item -> geometric object -> bounding box
# need to convert coordinate to vector2d
self._bounding_box = None
item_id = id(item) # Fixme: hash ???
self._items[item_id] = item
user_data = item.user_data
if user_data is not None:
user_data_id = id(user_data) # Fixme: hash ???
items = self._user_data_map.setdefault(user_data_id, [])
items.append(item)
return item
##############################################
@property
def root_scope(self):
return self._root_scope
def remove_item(self, item):
@property
def bounding_box(self):
return self._bounding_box
self.update_rtree(item, insert=False)
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 bounding_box(self, value):
self._bounding_box = value
def item_in_bounding_box(self, bounding_box):
# 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):
return self._root_scope.add_scope(*args, **kwargs)
# Fixme: !!!
# 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):
return self._root_scope.add_coordinate(*args, **kwargs)
# Fixme: generic code
def add_text(self, *args, **kwargs):
return self._root_scope.add_text(*args, **kwargs)
number_of_points = len(points)
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):
return self._root_scope.add_circle(*args, **kwargs)
items = []
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 self._root_scope.add_segment(*args, **kwargs)
return items
##############################################
# 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):
return self._root_scope.add_cubic_bezier(*args, **kwargs)
class GraphicScene(GraphicSceneScope):
"""Class to implement a graphic scene."""
pass
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
####################################################################################################
#
# 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`
"""
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.