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