File: Polygon.py

package info (click to toggle)
uranium 5.0.0-7
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 5,304 kB
  • sloc: python: 31,765; sh: 132; makefile: 12
file content (332 lines) | stat: -rw-r--r-- 14,198 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# Copyright (c) 2022 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.

from typing import Optional, Tuple, List, Union

import numpy
import math
import pyclipper
import scipy.spatial

from UM.Logger import Logger
from UM.Math import NumPyUtil

class Polygon:
    """A class representing an immutable arbitrary 2-dimensional polygon."""

    CLIPPER_PRECISION = 1000
    """
    Number of units per mm to use in clipper operations.
    """

    @staticmethod
    def approximatedCircle(radius, num_segments = 8):
        """Return vertices from an approximate circle.

        An octagon is returned, which comes close enough to a circle.

        :param radius: The radius of the circle.
        :return: A polygon that approximates a circle.
        """

        step = 2 * math.pi / num_segments

        points = []
        for i in range(0, num_segments):
            points.append([radius * -math.cos(i * step), radius * math.sin(i * step)])

        return Polygon(points = numpy.array(points, numpy.float32))

    @staticmethod
    def _fromClipperPoints(points: numpy.ndarray) -> "Polygon":
        """
        Converts the clipper point representation into a normal polygon.
        :param points: The clipper
        :return:
        """
        return Polygon(points = points.astype(numpy.float32) / Polygon.CLIPPER_PRECISION)

    def __init__(self, points: Optional[Union[numpy.ndarray, List]] = None):
        if points is not None:
            self._points = NumPyUtil.immutableNDArray(points)
        else:
            self._points = NumPyUtil.immutableNDArray([])

    def __eq__(self, other):
        if self is other:
            return True
        if type(other) is not Polygon:
            return False

        point_count = len(self._points) if self._points is not None else 0
        point_count2 = len(other.getPoints()) if other.getPoints() is not None else 0
        if point_count != point_count2:
            return False
        return numpy.array_equal(self._points, other.getPoints())

    def __repr__(self):
        """Gives a debugging representation of the polygon.

        This lists the polygon's coordinates, like so::
        [[0,0], [1,3], [3,0]]

        :return: A representation of the polygon that is useful for debugging.
        """

        coordinates = (("[" + str(point[0]) + "," + str(point[1]) + "]") for point in self._points)
        return "[" + ", ".join(coordinates) + "]"

    def isValid(self) -> bool:
        return bool(self._points is not None and len(self._points) >= 3)

    def getPoints(self) -> numpy.ndarray:
        return self._points

    def project(self, normal) -> Tuple[float, float]:
        """Project this polygon on a line described by a normal.

        :param normal: The normal to project on.
        :return: A tuple describing the line segment of this Polygon projected on to the infinite line described by normal.
        The first element is the minimum value, the second the maximum.
        """

        projection_min = numpy.dot(normal, self._points[0])

        projection_max = projection_min
        for point in self._points:
            projection = numpy.dot(normal, point)
            projection_min = min(projection_min, projection)
            projection_max = max(projection_max, projection)

        return projection_min, projection_max

    def translate(self, x: float = 0, y: float = 0) -> "Polygon":
        """Moves the polygon by a fixed offset.

        :param x: The distance to move along the X-axis.
        :param y: The distance to move along the Y-axis.
        """

        if self.isValid():
            return Polygon(numpy.add(self._points, numpy.array([[x, y]])))
        else:
            return self

    def mirror(self, point_on_axis: List[float], axis_direction: List[float]) -> "Polygon":
        """Mirrors this polygon across the specified axis.

        :param point_on_axis: A point on the axis to mirror across.
        :param axis_direction: The direction vector of the axis to mirror across.
        """

        # Input checking.
        if axis_direction == [0, 0]:
            Logger.log("w", "Tried to mirror a polygon over an axis with direction [0, 0].")
            return self  # Axis has no direction. Can't expect us to mirror anything!
        axis_direction /= numpy.linalg.norm(axis_direction)  # Normalise the direction.
        if not self.isValid():  # Not a valid polygon, so don't do anything.
            return self

        # In order to be able to mirror points around an arbitrary axis, we have to normalize the axis and all points
        # such that the axis goes through the origin.
        point_matrix = numpy.matrix(self._points)  # type: ignore
        # Moves all points such that the axis origin is at [0,0].
        point_matrix -= point_on_axis  # type: ignore

        # To mirror a coordinate, we have to add the projection of the point to the axis twice
        # (where v is the vector to reflect):
        #  reflection(v) = 2 * projection(v) - v
        # Writing out the projection, this becomes (where l is the normalised direction of the line):
        #  reflection(v) = 2 * (l . v) l - v
        # With Snell's law this can be simplified to the Householder transformation matrix:
        #  reflection(v) = R v
        #  R = 2 l l^T - I
        # This simplifies the entire reflection to one big matrix transformation.
        axis_matrix = numpy.matrix(axis_direction) # type: ignore
        reflection = 2 * numpy.transpose(axis_matrix) * axis_matrix - numpy.identity(2)
        point_matrix = point_matrix * reflection  # Apply the actual transformation.

        # Shift the points back to the original coordinate space before the axis was normalised to the origin.
        point_matrix += point_on_axis # type: ignore
        return Polygon(point_matrix.getA()[::-1])

    def scale(self, factor: float, origin: Optional[List[float]] = None) -> "Polygon":
        """
        Scales this polygon around a certain origin point.
        :param factor: The scaling factor.
        :param origin: Origin point around which to scale, 2D. As the scale
        factor approaches 0, all coordinates will approach this origin point. As
        the scale factor grows, all coordinates will move away from this origin
        point. If `None`, the 0,0 coordinate will be used.
        :return: A transformed polygon.
        """
        if origin is None:
            origin = [0, 0]

        transformation = numpy.identity(3) * factor  # Just the scaling matrix.
        delta_scale = factor - 1
        transformation[2][0] = delta_scale * -origin[0]
        transformation[2][1] = delta_scale * -origin[1]

        # Apply that affine transformation to the point data.
        point_data = numpy.pad(self._points, ((0, 0), (0, 1)), "constant", constant_values = (1))  # Turn 3D to do an affine transformation.
        point_data = point_data.dot(transformation)

        return Polygon(point_data[:, :-1])  # Leave out the affine component.

    def intersectionConvexHulls(self, other: "Polygon") -> "Polygon":
        """Computes the intersection of the convex hulls of this and another
        polygon.

        :param other: The other polygon to intersect convex hulls with.
        :return: The intersection of the two polygons' convex hulls.
        """
        me = self.getConvexHull()
        him = other.getConvexHull()

        # If either polygon has no surface area, then the intersection is empty.
        if len(me.getPoints()) <= 2 or len(him.getPoints()) <= 2:
            return Polygon()

        clipper = pyclipper.Pyclipper()
        clipper.AddPath(me._clipperPoints(), pyclipper.PT_SUBJECT, closed = True)
        clipper.AddPath(other._clipperPoints(), pyclipper.PT_CLIP, closed = True)

        points = clipper.Execute(pyclipper.CT_INTERSECTION, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
        if len(points) == 0:
            return Polygon()
        points = points[0]  # Intersection between convex hulls should result in a single (convex) simple polygon. Take just the one polygon.
        if points[0] == points[-1]:  # Represent closed polygons without closing vertex.
            points.pop()
        return self._fromClipperPoints(numpy.array(points))

    #  Computes the convex hull of the union of the convex hulls of this and another polygon.
    #
    #   \param other The other polygon to combine convex hulls with.
    #   \return The convex hull of the union of the two polygons' convex hulls.
    def unionConvexHulls(self, other: "Polygon") -> "Polygon":
        if len(self.getPoints()) == 0: # Concatenate doesn't deal well with empty arrays (since they are not the same dimension), so catch that case first.
            return other
        if len(other.getPoints()) == 0:
            return self
        # Combine all points and take the convex hull of that.
        all_points = numpy.concatenate((self.getPoints(), other.getPoints()))
        combined_polys = Polygon(all_points)
        return combined_polys.getConvexHull()

    def intersectsPolygon(self, other: "Polygon") -> Optional[Tuple[float, float]]:
        """Check to see whether this polygon intersects with another polygon.

        :param other: :type{Polygon} The polygon to check for intersection.
        :return: A tuple of the x and y distance of intersection, or None if no intersection occurred.
        """
        if not self.isValid() or not other.isValid():
            return None

        clipper = pyclipper.Pyclipper()
        clipper.AddPath(self._clipperPoints(), pyclipper.PT_SUBJECT, closed = True)
        clipper.AddPath(other._clipperPoints(), pyclipper.PT_CLIP, closed = True)
        intersection_points = clipper.Execute(pyclipper.CT_INTERSECTION)

        if len(intersection_points) == 0:
            return None

        # Find the bounds of the intersection area.
        mini = (math.inf, math.inf)
        maxi = (-math.inf, -math.inf)
        for poly in intersection_points:  # Each simple polygon in the complex intersection multi-polygon.
            for vertex in poly:
                mini = (min(mini[0], vertex[0]), min(mini[1], vertex[1]))
                maxi = (max(maxi[0], vertex[0]), max(maxi[1], vertex[1]))
        return float(maxi[0] - mini[0]) / self.CLIPPER_PRECISION, float(maxi[1] - mini[1]) / self.CLIPPER_PRECISION

    def getConvexHull(self) -> "Polygon":
        """Calculate the convex hull around the set of points of this polygon.

        :return: The convex hull around the points of this polygon.
        """

        points = self._points

        if len(points) < 1:
            return Polygon(numpy.zeros((0, 2), numpy.float64))
        if len(points) <= 2:
            return Polygon(numpy.array(points, numpy.float64))

        try:
            hull = scipy.spatial.ConvexHull(points)
        except scipy.spatial.qhull.QhullError:
            return Polygon(numpy.zeros((0, 2), numpy.float64))
        except OSError:  # For some reason, Spatial sometimes attempts to open a temp file. If this temp file contains non-ASCII characters that fails. See https://github.com/scipy/scipy/issues/8850
            return Polygon(numpy.zeros((0, 2), numpy.float64))

        return Polygon(numpy.flipud(hull.points[hull.vertices]))

    def getMinkowskiSum(self, other: "Polygon") -> "Polygon":
        """Perform a Minkowski sum of this polygon with another polygon.

        :param other: The polygon to perform a Minkowski sum with.
        :return: :type{Polygon} The Minkowski sum of this polygon with other.
        """
        if len(self._points) == 0 or len(other._points) == 0:  # Summing an empty polygon with a certain kernel, or summing a normal polygon with an empty polygon, would crash Numpy down below.
            return Polygon(self._points)
        points = numpy.zeros((len(self._points) * len(other._points), 2))
        for n in range(0, len(self._points)):
            for m in range(0, len(other._points)):
                points[n * len(other._points) + m] = self._points[n] + other._points[m]

        return Polygon(points)

    def getMinkowskiHull(self, other: "Polygon") -> "Polygon":
        """Create a Minkowski hull from this polygon and another polygon.

        The Minkowski hull is the convex hull around the Minkowski sum of this
        polygon with other.

        :param other: :type{Polygon} The Polygon to do a Minkowski addition with.
        :return: The convex hull around the Minkowski sum of this Polygon with other
        """

        sum = self.getMinkowskiSum(other)
        return sum.getConvexHull()

    def isInside(self, point) -> bool:
        """Whether the specified point is inside this polygon.

        If the point is exactly on the border or on a vector, it does not count
        as being inside the polygon.

        :param point: The point to check of whether it is inside.
        :return: True if it is inside, or False otherwise.
        """

        for i in range(0, len(self._points)):
            if self._isRightTurn(self._points[i], self._points[(i + 1) % len(self._points)], point) == -1: #Outside this halfplane!
                return False
        return True

    def _isRightTurn(self, p: numpy.ndarray, q: numpy.ndarray, r: numpy.ndarray) -> float:
        sum1 = q[0] * r[1] + p[0] * q[1] + r[0] * p[1]
        sum2 = q[0] * p[1] + r[0] * q[1] + p[0] * r[1]

        if sum1 - sum2 < 0:
            return 1
        elif sum1 == sum2:
            return 0
        else:
            return -1

    def _clipperPoints(self) -> numpy.ndarray:
        """
        Converts the vertices to a representation useful for PyClipper.

        This is necessary because Clipper uses integer-coordinates, but the coordinates in the rest of the front-end are
        one millimeter per unit. Without this conversion, vertices would be rounded to millimeters. With this conversion
        the units represent micrometers, allowing much greater precision.
        :return: A vertex representation useful for Clipper.
        """
        return (self.getPoints() * self.CLIPPER_PRECISION).astype(numpy.int32)



__all__ = ["Polygon"]