1# Copyright (c) 2020-2021, Manfred Moitzi
2# License: MIT License
3from typing import TYPE_CHECKING, List, Iterable
4from collections import abc
5import warnings
6
7from ezdxf.math import (
8    Vec3, NULLVEC, OCS, Bezier3P, Bezier4P, Matrix44,
9    ConstructionEllipse, BSpline, has_clockwise_orientation,
10)
11from ezdxf.entities import LWPolyline, Polyline, Spline
12from .commands import Command, LineTo, Curve3To, Curve4To, AnyCurve, PathElement
13
14if TYPE_CHECKING:
15    from ezdxf.eztypes import Vertex, Ellipse, Arc, Circle
16
17__all__ = ['Path']
18
19MAX_DISTANCE = 0.01
20MIN_SEGMENTS = 4
21G1_TOL = 1e-4
22
23
24class Path(abc.Sequence):
25    __slots__ = ('_start', '_commands')
26
27    def __init__(self, start: 'Vertex' = NULLVEC):
28        self._start = Vec3(start)
29        self._commands: List[PathElement] = []
30
31    def __len__(self) -> int:
32        return len(self._commands)
33
34    def __getitem__(self, item) -> PathElement:
35        return self._commands[item]
36
37    def __iter__(self) -> Iterable[PathElement]:
38        return iter(self._commands)
39
40    def __copy__(self) -> 'Path':
41        """ Returns a new copy of :class:`Path` with shared immutable data. """
42        copy = Path(self._start)
43        # immutable data
44        copy._commands = list(self._commands)
45        return copy
46
47    clone = __copy__
48
49    @property
50    def start(self) -> Vec3:
51        """ :class:`Path` start point, resetting the start point of an empty
52        path is possible.
53        """
54        return self._start
55
56    @start.setter
57    def start(self, location: 'Vertex') -> None:
58        if len(self._commands):
59            raise ValueError('Requires an empty path.')
60        else:
61            self._start = Vec3(location)
62
63    @property
64    def end(self) -> Vec3:
65        """ :class:`Path` end point. """
66        if self._commands:
67            return self._commands[-1].end
68        else:
69            return self._start
70
71    @property
72    def is_closed(self) -> bool:
73        """ Returns ``True`` if the start point is close to the end point. """
74        return self._start.isclose(self.end)
75
76    @property
77    def has_lines(self) -> bool:
78        """ Returns ``True`` if the path has any line segments. """
79        return any(cmd.type == Command.LINE_TO for cmd in self._commands)
80
81    @property
82    def has_curves(self) -> bool:
83        """ Returns ``True`` if the path has any curve segments. """
84        return any(cmd.type in AnyCurve for cmd in self._commands)
85
86    def has_clockwise_orientation(self) -> bool:
87        """ Returns ``True`` if 2D path has clockwise orientation, ignores
88        z-axis of all control vertices.
89        """
90        return has_clockwise_orientation(self.control_vertices())
91
92    def line_to(self, location: 'Vertex') -> None:
93        """ Add a line from actual path end point to `location`.
94        """
95        self._commands.append(LineTo(end=Vec3(location)))
96
97    def curve3_to(self, location: 'Vertex', ctrl: 'Vertex') -> None:
98        """ Add a quadratic Bèzier-curve from actual path end point to
99        `location`, `ctrl` is the control point for the quadratic Bèzier-curve.
100        """
101        self._commands.append(Curve3To(end=Vec3(location), ctrl=Vec3(ctrl)))
102
103    def curve4_to(self, location: 'Vertex', ctrl1: 'Vertex',
104                  ctrl2: 'Vertex') -> None:
105        """ Add a cubic Bèzier-curve from actual path end point to `location`,
106        `ctrl1` and `ctrl2` are the control points for the cubic Bèzier-curve.
107        """
108        self._commands.append(Curve4To(
109            end=Vec3(location), ctrl1=Vec3(ctrl1), ctrl2=Vec3(ctrl2))
110        )
111
112    curve_to = curve4_to  # TODO: 2021-01-30, remove compatibility alias
113
114    def close(self) -> None:
115        """ Close path by adding a line segment from the end point to the start
116        point.
117        """
118        if not self.is_closed:
119            self.line_to(self.start)
120
121    def reversed(self) -> 'Path':
122        """ Returns a new :class:`Path` with reversed segments and control
123        vertices.
124        """
125        if len(self) == 0:
126            return Path(self.start)
127
128        path = Path(start=self.end)
129        for index in range(len(self) - 1, -1, -1):
130            cmd = self[index]
131            if index > 0:
132                prev_end = self[index - 1].end
133            else:
134                prev_end = self.start
135
136            if cmd.type == Command.LINE_TO:
137                path.line_to(prev_end)
138            elif cmd.type == Command.CURVE3_TO:
139                path.curve3_to(prev_end, cmd.ctrl)
140            elif cmd.type == Command.CURVE4_TO:
141                path.curve4_to(prev_end, cmd.ctrl2, cmd.ctrl1)
142        return path
143
144    def clockwise(self) -> 'Path':
145        """ Returns new :class:`Path` in clockwise orientation. """
146        if self.has_clockwise_orientation():
147            return self.clone()
148        else:
149            return self.reversed()
150
151    def counter_clockwise(self) -> 'Path':
152        """ Returns new :class:`Path` in counter-clockwise orientation. """
153        if self.has_clockwise_orientation():
154            return self.reversed()
155        else:
156            return self.clone()
157
158    def approximate(self, segments: int = 20) -> Iterable[Vec3]:
159        """ Approximate path by vertices, `segments` is the count of
160        approximation segments for each Bézier curve.
161
162        Does not yield any vertices for empty paths, where only a start point
163        is present!
164
165        """
166
167        def approx_curve3(s, c, e) -> Iterable[Vec3]:
168            return Bezier3P((s, c, e)).approximate(segments)
169
170        def approx_curve4(s, c1, c2, e) -> Iterable[Vec3]:
171            return Bezier4P((s, c1, c2, e)).approximate(segments)
172
173        yield from self._approximate(approx_curve3, approx_curve4)
174
175    def flattening(self, distance: float,
176                   segments: int = 16) -> Iterable[Vec3]:
177        """ Approximate path by vertices and use adaptive recursive flattening
178        to approximate Bèzier curves. The argument `segments` is the
179        minimum count of approximation segments for each curve, if the distance
180        from the center of the approximation segment to the curve is bigger than
181        `distance` the segment will be subdivided.
182
183        Does not yield any vertices for empty paths, where only a start point
184        is present!
185
186        Args:
187            distance: maximum distance from the center of the curve to the
188                center of the line segment between two approximation points to
189                determine if a segment should be subdivided.
190            segments: minimum segment count per Bézier curve
191
192        """
193
194        def approx_curve3(s, c, e) -> Iterable[Vec3]:
195            return Bezier3P((s, c, e)).flattening(distance, segments)
196
197        def approx_curve4(s, c1, c2, e) -> Iterable[Vec3]:
198            return Bezier4P((s, c1, c2, e)).flattening(distance, segments)
199
200        yield from self._approximate(approx_curve3, approx_curve4)
201
202    def _approximate(self, approx_curve3, approx_curve4) -> Iterable[Vec3]:
203        if not self._commands:
204            return
205
206        start = self._start
207        yield start
208
209        for cmd in self._commands:
210            end_location = cmd.end
211            if cmd.type == Command.LINE_TO:
212                yield end_location
213            elif cmd.type == Command.CURVE3_TO:
214                pts = iter(
215                    approx_curve3(start, cmd.ctrl, end_location)
216                )
217                next(pts)  # skip first vertex
218                yield from pts
219            elif cmd.type == Command.CURVE4_TO:
220                pts = iter(
221                    approx_curve4(start, cmd.ctrl1, cmd.ctrl2, end_location)
222                )
223                next(pts)  # skip first vertex
224                yield from pts
225            else:
226                raise ValueError(f'Invalid command: {cmd.type}')
227            start = end_location
228
229    def transform(self, m: 'Matrix44') -> 'Path':
230        """ Returns a new transformed path.
231
232        Args:
233             m: transformation matrix of type :class:`~ezdxf.math.Matrix44`
234
235        """
236        new_path = self.__class__(m.transform(self.start))
237        for cmd in self._commands:
238
239            if cmd.type == Command.LINE_TO:
240                new_path.line_to(m.transform(cmd.end))
241            elif cmd.type == Command.CURVE3_TO:
242                loc, ctrl = m.transform_vertices(
243                    (cmd.end, cmd.ctrl)
244                )
245                new_path.curve3_to(loc, ctrl)
246            elif cmd.type == Command.CURVE4_TO:
247                loc, ctrl1, ctrl2 = m.transform_vertices(
248                    (cmd.end, cmd.ctrl1, cmd.ctrl2)
249                )
250                new_path.curve4_to(loc, ctrl1, ctrl2)
251            else:
252                raise ValueError(f'Invalid command: {cmd.type}')
253
254        return new_path
255
256    def to_wcs(self, ocs: OCS, elevation: float):
257        """ Transform path from given `ocs` to WCS coordinates inplace. """
258        self._start = ocs.to_wcs(self._start.replace(z=elevation))
259        for i, cmd in enumerate(self._commands):
260            self._commands[i] = cmd.to_wcs(ocs, elevation)
261
262    def add_curves(self, curves: Iterable[Bezier4P]) -> None:
263        """ Add multiple cubic Bèzier-curves to the path.
264
265        .. deprecated:: 0.15.3
266            replaced by factory function :func:`add_bezier4p`
267
268        """
269        warnings.warn(
270            'use tool function add_bezier4p(),'
271            'will be removed in v0.17.', DeprecationWarning)
272        from .tools import add_bezier4p
273        add_bezier4p(self, curves)
274
275    def add_bezier3p(self, curves: Iterable[Bezier3P]) -> None:
276        """ Add multiple quadratic Bèzier-curves to the path.
277
278        """
279        warnings.warn(
280            'use tool function add_bezier3p(),'
281            'will be removed in v0.17.', DeprecationWarning)
282        from .tools import add_bezier3p
283        add_bezier3p(self, curves)
284
285    def add_ellipse(self, ellipse: ConstructionEllipse, segments=1,
286                    reset=True) -> None:
287        """ Add an elliptical arc as multiple cubic Bèzier-curves
288
289        .. deprecated:: 0.15.3
290            replaced by factory function :func:`add_ellipse`
291
292        """
293        warnings.warn(
294            'use tool function add_ellipse(),'
295            'will be removed in v0.17.', DeprecationWarning)
296        from .tools import add_ellipse
297        add_ellipse(self, ellipse, segments, reset)
298
299    def add_spline(self, spline: BSpline, level=4, reset=True) -> None:
300        """ Add a B-spline as multiple cubic Bèzier-curves.
301
302        .. deprecated:: 0.15.3
303            replaced by factory function :func:`add_spline`
304
305        """
306        warnings.warn(
307            'use tool function add_spline(),'
308            'will be removed in v0.17.', DeprecationWarning)
309        from .tools import add_spline
310        add_spline(self, spline, level, reset)
311
312    @classmethod
313    def from_vertices(cls, vertices: Iterable['Vertex'], close=False) -> 'Path':
314        """ Returns a :class:`Path` from given `vertices`.
315
316        .. deprecated:: 0.15.3
317            replaced by factory function :func:`from_vertices()`
318
319        """
320        warnings.warn(
321            'use factory function from_vertices(),'
322            'will be removed in v0.17.', DeprecationWarning)
323        from .converter import from_vertices
324        return from_vertices(vertices, close)
325
326    @classmethod
327    def from_lwpolyline(cls, lwpolyline: 'LWPolyline') -> 'Path':
328        """ Returns a :class:`Path` from a :class:`~ezdxf.entities.LWPolyline`
329        entity, all vertices transformed to WCS.
330
331        .. deprecated:: 0.15.2
332            replaced by factory function :func:`make_path()`
333
334        """
335        warnings.warn(
336            'use factory function make_path(lwpolyline),'
337            'will be removed in v0.17.', DeprecationWarning)
338        from .converter import make_path
339        return make_path(lwpolyline)
340
341    @classmethod
342    def from_polyline(cls, polyline: 'Polyline') -> 'Path':
343        """ Returns a :class:`Path` from a :class:`~ezdxf.entities.Polyline`
344        entity, all vertices transformed to WCS.
345
346        .. deprecated:: 0.15.2
347            replaced by factory function :func:`make_path()`
348
349        """
350        warnings.warn(
351            'use factory function make_path(polyline),'
352            'will be removed in v0.17.', DeprecationWarning)
353        from .converter import make_path
354        return make_path(polyline)
355
356    @classmethod
357    def from_spline(cls, spline: 'Spline', level: int = 4) -> 'Path':
358        """ Returns a :class:`Path` from a :class:`~ezdxf.entities.Spline`.
359
360        .. deprecated:: 0.15.2
361            replaced by factory function :func:`make_path()`
362
363        """
364        warnings.warn(
365            'use factory function make_path(polyline),'
366            'will be removed in v0.17.', DeprecationWarning)
367        from .converter import make_path
368        return make_path(spline, level=level)
369
370    @classmethod
371    def from_ellipse(cls, ellipse: 'Ellipse', segments: int = 1) -> 'Path':
372        """ Returns a :class:`Path` from a :class:`~ezdxf.entities.Ellipse`.
373
374        .. deprecated:: 0.15.2
375            replaced by factory function :func:`make_path()`
376
377        """
378        warnings.warn(
379            'use factory function make_path(ellipse),'
380            'will be removed in v0.17.', DeprecationWarning)
381        from .converter import make_path
382        return make_path(ellipse, segments=segments)
383
384    @classmethod
385    def from_arc(cls, arc: 'Arc', segments: int = 1) -> 'Path':
386        """ Returns a :class:`Path` from an :class:`~ezdxf.entities.Arc`.
387
388        .. deprecated:: 0.15.2
389            replaced by factory function :func:`make_path()`
390
391        """
392        warnings.warn(
393            'use factory function make_path(arc),'
394            'will be removed in v0.17.', DeprecationWarning)
395        from .converter import make_path
396        return make_path(arc, segments=segments)
397
398    @classmethod
399    def from_circle(cls, circle: 'Circle', segments: int = 1) -> 'Path':
400        """ Returns a :class:`Path` from a :class:`~ezdxf.entities.Circle`.
401
402        .. deprecated:: 0.15.2
403            replaced by factory function :func:`make_path()`
404
405        """
406        warnings.warn(
407            'use factory function make_path(circle),'
408            'will be removed in v0.17.', DeprecationWarning)
409        from .converter import make_path
410        return make_path(circle, segments=segments)
411
412    def control_vertices(self):
413        """ Yields all path control vertices in consecutive order. """
414        if len(self):
415            yield self.start
416            for cmd in self._commands:
417                if cmd.type == Command.LINE_TO:
418                    yield cmd.end
419                elif cmd.type == Command.CURVE3_TO:
420                    yield cmd.ctrl
421                    yield cmd.end
422                elif cmd.type == Command.CURVE4_TO:
423                    yield cmd.ctrl1
424                    yield cmd.ctrl2
425                    yield cmd.end
426