1# Copyright (c) 2019-2020 Manfred Moitzi 2# License: MIT License 3from typing import TYPE_CHECKING, Iterable, Sequence, cast 4import array 5import copy 6from itertools import chain 7from ezdxf.audit import AuditError 8from ezdxf.lldxf import validator 9from ezdxf.lldxf.attributes import ( 10 DXFAttr, DXFAttributes, DefSubclass, XType, RETURN_DEFAULT, 11 group_code_mapping, 12) 13from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000, DXFValueError 14from ezdxf.lldxf.packedtags import VertexArray, Tags 15from ezdxf.math import ( 16 Vec3, Matrix44, ConstructionEllipse, Z_AXIS, NULLVEC, 17 uniform_knot_vector, open_uniform_knot_vector, BSpline, 18 required_knot_values, required_fit_points, required_control_points, 19) 20from .dxfentity import base_class, SubclassProcessor 21from .dxfgfx import DXFGraphic, acdb_entity 22from .factory import register_entity 23 24if TYPE_CHECKING: 25 from ezdxf.eztypes import TagWriter, DXFNamespace, Vertex, Auditor 26 27__all__ = ['Spline'] 28 29# From the Autodesk ObjectARX reference: 30# Objects of the AcDbSpline class use an embedded gelib object to maintain the 31# actual spline information. 32# 33# Book recommendations: 34# 35# - "Curves and Surfaces for CAGD" by Gerald Farin 36# - "Mathematical Elements for Computer Graphics" 37# by David Rogers and Alan Adams 38# - "An Introduction To Splines For Use In Computer Graphics & Geometric Modeling" 39# by Richard H. Bartels, John C. Beatty, and Brian A Barsky 40# 41# http://help.autodesk.com/view/OARX/2018/ENU/?guid=OREF-AcDbSpline__setFitData_AcGePoint3dArray__AcGeVector3d__AcGeVector3d__AcGe__KnotParameterization_int_double 42# Construction of a AcDbSpline entity from fit points: 43# degree has no effect. A spline with degree=3 is always constructed when 44# interpolating a series of fit points. 45 46acdb_spline = DefSubclass('AcDbSpline', { 47 # Spline flags: 48 # 1 = Closed spline 49 # 2 = Periodic spline 50 # 4 = Rational spline 51 # 8 = Planar 52 # 16 = Linear (planar bit is also set) 53 'flags': DXFAttr(70, default=0), 54 55 # degree: The degree can't be higher than 11 according to the Autodesk 56 # ObjectARX reference. 57 'degree': DXFAttr(71, default=3, validator=validator.is_positive), 58 'n_knots': DXFAttr( 59 72, xtype=XType.callback, getter='knot_count'), 60 'n_control_points': DXFAttr( 61 73, xtype=XType.callback, getter='control_point_count'), 62 'n_fit_points': DXFAttr( 63 74, xtype=XType.callback, getter='fit_point_count'), 64 'knot_tolerance': DXFAttr(42, default=1e-10, optional=True), 65 'control_point_tolerance': DXFAttr(43, default=1e-10, optional=True), 66 'fit_tolerance': DXFAttr(44, default=1e-10, optional=True), 67 # Start- and end tangents should be normalized, but CAD applications do not 68 # crash if they are not normalized. 69 'start_tangent': DXFAttr( 70 12, xtype=XType.point3d, optional=True, 71 validator=validator.is_not_null_vector, 72 ), 73 'end_tangent': DXFAttr( 74 13, xtype=XType.point3d, optional=True, 75 validator=validator.is_not_null_vector, 76 ), 77 # Extrusion is the normal vector (omitted if the spline is non-planar) 78 'extrusion': DXFAttr( 79 210, xtype=XType.point3d, default=Z_AXIS, optional=True, 80 validator=validator.is_not_null_vector, 81 fixer=RETURN_DEFAULT, 82 ), 83 # 10: Control points (in WCS); one entry per control point 84 # 11: Fit points (in WCS); one entry per fit point 85 # 40: Knot value (one entry per knot) 86 # 41: Weight (if not 1); with multiple group pairs, they are present if all 87 # are not 1 88}) 89acdb_spline_group_codes = group_code_mapping(acdb_spline) 90 91 92class SplineData: 93 def __init__(self, spline: 'Spline'): 94 self.fit_points = spline.fit_points 95 self.control_points = spline.control_points 96 self.knots = spline.knots 97 self.weights = spline.weights 98 99 100REMOVE_CODES = {10, 11, 40, 41, 72, 73, 74} 101 102 103@register_entity 104class Spline(DXFGraphic): 105 """ DXF SPLINE entity """ 106 DXFTYPE = 'SPLINE' 107 DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_spline) 108 MIN_DXF_VERSION_FOR_EXPORT = DXF2000 109 CLOSED = 1 # closed b-spline 110 PERIODIC = 2 # uniform b-spline 111 RATIONAL = 4 # rational b-spline 112 PLANAR = 8 # all spline points in a plane, don't read or set this bit, just ignore like AutoCAD 113 LINEAR = 16 # always set with PLANAR, don't read or set this bit, just ignore like AutoCAD 114 115 def __init__(self): 116 super().__init__() 117 self.fit_points = VertexArray() # data stored as array.array('d') 118 self.control_points = VertexArray() # data stored as array.array('d') 119 self.knots = [] # data stored as array.array('d') 120 self.weights = [] # data stored as array.array('d') 121 122 def _copy_data(self, entity: 'Spline') -> None: 123 """ Copy data: control_points, fit_points, weights, knot_values. """ 124 entity._control_points = copy.deepcopy(self._control_points) 125 entity._fit_points = copy.deepcopy(self._fit_points) 126 entity._knots = copy.deepcopy(self._knots) 127 entity._weights = copy.deepcopy(self._weights) 128 129 def load_dxf_attribs(self, 130 processor: SubclassProcessor = None) -> 'DXFNamespace': 131 dxf = super().load_dxf_attribs(processor) 132 if processor: 133 tags = Tags(self.load_spline_data(processor.subclass_by_index(2))) 134 processor.fast_load_dxfattribs( 135 dxf, acdb_spline_group_codes, subclass=tags, recover=True) 136 return dxf 137 138 def load_spline_data(self, tags) -> Iterable: 139 """ Load and set spline data (fit points, control points, weights, 140 knots) and remove invalid start- and end tangents. 141 Yields the remaining unprocessed tags. 142 """ 143 control_points = [] 144 fit_points = [] 145 knots = [] 146 weights = [] 147 for tag in tags: 148 code, value = tag 149 if code == 10: 150 control_points.append(value) 151 elif code == 11: 152 fit_points.append(value) 153 elif code == 40: 154 knots.append(value) 155 elif code == 41: 156 weights.append(value) 157 elif code in (12, 13) and NULLVEC.isclose(value): 158 # Tangent values equal to (0, 0, 0) are invalid and ignored at 159 # the loading stage! 160 pass 161 else: 162 yield tag 163 self.control_points = control_points 164 self.fit_points = fit_points 165 self.knots = knots 166 self.weights = weights 167 168 def export_entity(self, tagwriter: 'TagWriter') -> None: 169 """ Export entity specific data as DXF tags. """ 170 super().export_entity(tagwriter) 171 tagwriter.write_tag2(SUBCLASS_MARKER, acdb_spline.name) 172 self.dxf.export_dxf_attribs(tagwriter, ['extrusion', 'flags', 'degree']) 173 tagwriter.write_tag2(72, self.knot_count()) 174 tagwriter.write_tag2(73, self.control_point_count()) 175 tagwriter.write_tag2(74, self.fit_point_count()) 176 self.dxf.export_dxf_attribs(tagwriter, [ 177 'knot_tolerance', 'control_point_tolerance', 'fit_tolerance', 178 'start_tangent', 'end_tangent', 179 ]) 180 181 self.export_spline_data(tagwriter) 182 183 def export_spline_data(self, tagwriter: 'TagWriter'): 184 for value in self._knots: 185 tagwriter.write_tag2(40, value) 186 187 if len(self._weights): 188 for value in self._weights: 189 tagwriter.write_tag2(41, value) 190 191 self._control_points.export_dxf(tagwriter, code=10) 192 self._fit_points.export_dxf(tagwriter, code=11) 193 194 @property 195 def closed(self) -> bool: 196 """ ``True`` if spline is closed. A closed spline has a connection from 197 the last control point to the first control point. (read/write) 198 """ 199 return self.get_flag_state(self.CLOSED, name='flags') 200 201 @closed.setter 202 def closed(self, status: bool) -> None: 203 self.set_flag_state(self.CLOSED, state=status, name='flags') 204 205 @property 206 def knots(self) -> 'array.array': 207 """ Knot values as :code:`array.array('d')`. """ 208 return self._knots 209 210 @knots.setter 211 def knots(self, values: Iterable[float]) -> None: 212 self._knots = array.array('d', values) 213 214 # DXF callback attribute Spline.dxf.n_knots 215 def knot_count(self) -> int: 216 """ Count of knot values. """ 217 return len(self._knots) 218 219 @property 220 def weights(self) -> 'array.array': 221 """ Control point weights as :code:`array.array('d')`. """ 222 return self._weights 223 224 @weights.setter 225 def weights(self, values: Iterable[float]) -> None: 226 self._weights = array.array('d', values) 227 228 @property 229 def control_points(self) -> VertexArray: 230 """ :class:`~ezdxf.lldxf.packedtags.VertexArray` of control points in 231 :ref:`WCS`. 232 """ 233 return self._control_points 234 235 @control_points.setter 236 def control_points(self, points: Iterable['Vertex']) -> None: 237 self._control_points = VertexArray( 238 chain.from_iterable(Vec3.generate(points))) 239 240 # DXF callback attribute Spline.dxf.n_control_points 241 def control_point_count(self) -> int: 242 """ Count of control points. """ 243 return len(self.control_points) 244 245 @property 246 def fit_points(self) -> VertexArray: 247 """ :class:`~ezdxf.lldxf.packedtags.VertexArray` of fit points in 248 :ref:`WCS`. 249 """ 250 return self._fit_points 251 252 @fit_points.setter 253 def fit_points(self, points: Iterable['Vertex']) -> None: 254 self._fit_points = VertexArray( 255 chain.from_iterable(Vec3.generate(points))) 256 257 # DXF callback attribute Spline.dxf.n_fit_points 258 def fit_point_count(self) -> int: 259 """ Count of fit points. """ 260 return len(self.fit_points) 261 262 def construction_tool(self) -> BSpline: 263 """ Returns the construction tool :class:`ezdxf.math.BSpline`. 264 """ 265 if self.control_point_count(): 266 weights = self.weights if len(self.weights) else None 267 knots = self.knots if len(self.knots) else None 268 return BSpline(control_points=self.control_points, 269 order=self.dxf.degree + 1, knots=knots, 270 weights=weights) 271 elif self.fit_point_count(): 272 return BSpline.from_fit_points(self.fit_points, 273 degree=self.dxf.degree) 274 else: 275 raise ValueError( 276 'Construction tool requires control- or fit points.') 277 278 def apply_construction_tool(self, s) -> 'Spline': 279 """ Apply SPLINE data from a :class:`~ezdxf.math.BSpline` construction 280 tool or from a :class:`geomdl.BSpline.Curve` object. 281 282 """ 283 try: 284 self.control_points = s.control_points 285 except AttributeError: # maybe a geomdl.BSpline.Curve class 286 s = BSpline.from_nurbs_python_curve(s) 287 self.control_points = s.control_points 288 289 self.dxf.degree = s.degree 290 self.fit_points = [] # remove fit points 291 self.knots = s.knots() 292 self.weights = s.weights() 293 self.set_flag_state(Spline.RATIONAL, state=bool(len(self.weights))) 294 return self # floating interface 295 296 def flattening(self, distance: float, 297 segments: int = 4) -> Iterable[Vec3]: 298 """ Adaptive recursive flattening. The argument `segments` is the 299 minimum count of approximation segments between two knots, if the 300 distance from the center of the approximation segment to the curve is 301 bigger than `distance` the segment will be subdivided. 302 303 Args: 304 distance: maximum distance from the projected curve point onto the 305 segment chord. 306 segments: minimum segment count between two knots 307 308 .. versionadded:: 0.15 309 310 """ 311 return self.construction_tool().flattening(distance, segments) 312 313 @classmethod 314 def from_arc(cls, entity: 'DXFGraphic') -> 'Spline': 315 """ Create a new SPLINE entity from a CIRCLE, ARC or ELLIPSE entity. 316 317 The new SPLINE entity has no owner, no handle, is not stored in 318 the entity database nor assigned to any layout! 319 320 """ 321 dxftype = entity.dxftype() 322 if dxftype == 'ELLIPSE': 323 ellipse = cast('Ellipse', entity).construction_tool() 324 elif dxftype == 'CIRCLE': 325 ellipse = ConstructionEllipse.from_arc( 326 center=entity.dxf.get('center', NULLVEC), 327 radius=abs(entity.dxf.get('radius', 1.0)), 328 extrusion=entity.dxf.get('extrusion', Z_AXIS), 329 ) 330 elif dxftype == 'ARC': 331 ellipse = ConstructionEllipse.from_arc( 332 center=entity.dxf.get('center', NULLVEC), 333 radius=abs(entity.dxf.get('radius', 1.0)), 334 extrusion=entity.dxf.get('extrusion', Z_AXIS), 335 start_angle=entity.dxf.get('start_angle', 0), 336 end_angle=entity.dxf.get('end_angle', 360) 337 ) 338 else: 339 raise TypeError('CIRCLE, ARC or ELLIPSE entity required.') 340 341 spline = Spline.new(dxfattribs=entity.graphic_properties(), 342 doc=entity.doc) 343 s = BSpline.from_ellipse(ellipse) 344 spline.dxf.degree = s.degree 345 spline.dxf.flags = Spline.RATIONAL 346 spline.control_points = s.control_points 347 spline.knots = s.knots() 348 spline.weights = s.weights() 349 return spline 350 351 def set_open_uniform(self, control_points: Sequence['Vertex'], 352 degree: int = 3) -> None: 353 """ Open B-spline with uniform knot vector, start and end at your first 354 and last control points. 355 356 """ 357 self.dxf.flags = 0 358 self.dxf.degree = degree 359 self.control_points = control_points 360 self.knots = open_uniform_knot_vector(len(control_points), degree + 1) 361 362 def set_uniform(self, control_points: Sequence['Vertex'], 363 degree: int = 3) -> None: 364 """ B-spline with uniform knot vector, does NOT start and end at your 365 first and last control points. 366 367 """ 368 self.dxf.flags = 0 369 self.dxf.degree = degree 370 self.control_points = control_points 371 self.knots = uniform_knot_vector(len(control_points), degree + 1) 372 373 def set_closed(self, control_points: Sequence['Vertex'], degree=3) -> None: 374 """ 375 Closed B-spline with uniform knot vector, start and end at your first control point. 376 377 """ 378 self.dxf.flags = self.PERIODIC | self.CLOSED 379 self.dxf.degree = degree 380 self.control_points = control_points 381 self.control_points.extend(control_points[:degree]) 382 # AutoDesk Developer Docs: 383 # If the spline is periodic, the length of knot vector will be greater 384 # than length of the control array by 1, but this does not work with 385 # BricsCAD. 386 self.knots = uniform_knot_vector(len(self.control_points), degree + 1) 387 388 def set_open_rational(self, control_points: Sequence['Vertex'], 389 weights: Sequence[float], degree: int = 3) -> None: 390 """ Open rational B-spline with uniform knot vector, start and end at 391 your first and last control points, and has additional control 392 possibilities by weighting each control point. 393 394 """ 395 self.set_open_uniform(control_points, degree=degree) 396 self.dxf.flags = self.dxf.flags | self.RATIONAL 397 if len(weights) != len(self.control_points): 398 raise DXFValueError( 399 'Control point count must be equal to weights count.') 400 self.weights = weights 401 402 def set_uniform_rational(self, control_points: Sequence['Vertex'], 403 weights: Sequence[float], 404 degree: int = 3) -> None: 405 """ Rational B-spline with uniform knot vector, does NOT start and end 406 at your first and last control points, and has additional control 407 possibilities by weighting each control point. 408 409 """ 410 self.set_uniform(control_points, degree=degree) 411 self.dxf.flags = self.dxf.flags | self.RATIONAL 412 if len(weights) != len(self.control_points): 413 raise DXFValueError( 414 'Control point count must be equal to weights count.') 415 self.weights = weights 416 417 def set_closed_rational(self, control_points: Sequence['Vertex'], 418 weights: Sequence[float], 419 degree: int = 3) -> None: 420 """ Closed rational B-spline with uniform knot vector, start and end at 421 your first control point, and has additional control possibilities by 422 weighting each control point. 423 424 """ 425 self.set_closed(control_points, degree=degree) 426 self.dxf.flags = self.dxf.flags | self.RATIONAL 427 weights = list(weights) 428 weights.extend(weights[:degree]) 429 if len(weights) != len(self.control_points): 430 raise DXFValueError( 431 'Control point count must be equal to weights count.') 432 self.weights = weights 433 434 def transform(self, m: 'Matrix44') -> 'Spline': 435 """ Transform the SPLINE entity by transformation matrix `m` inplace. 436 """ 437 self._control_points.transform(m) 438 self._fit_points.transform(m) 439 # Transform optional attributes if they exist 440 dxf = self.dxf 441 for name in ('start_tangent', 'end_tangent', 'extrusion'): 442 if dxf.hasattr(name): 443 dxf.set(name, m.transform_direction(dxf.get(name))) 444 445 return self 446 447 def audit(self, auditor: 'Auditor') -> None: 448 """ Audit the SPLINE entity. 449 450 .. versionadded:: 0.15.1 451 452 """ 453 super().audit(auditor) 454 degree = self.dxf.degree 455 name = str(self) 456 457 if degree < 1: 458 auditor.fixed_error( 459 code=AuditError.INVALID_SPLINE_DEFINITION, 460 message=f"Removed {name} with invalid degree: {degree} < 1." 461 ) 462 auditor.trash(self) 463 return 464 465 n_control_points = len(self.control_points) 466 n_fit_points = len(self.fit_points) 467 468 if n_control_points == 0 and n_fit_points == 0: 469 auditor.fixed_error( 470 code=AuditError.INVALID_SPLINE_DEFINITION, 471 message=f"Removed {name} without any points (no geometry)." 472 ) 473 auditor.trash(self) 474 return 475 476 if n_control_points > 0: 477 self._audit_control_points(auditor) 478 # Ignore fit points if defined by control points 479 elif n_fit_points > 0: 480 self._audit_fit_points(auditor) 481 482 def _audit_control_points(self, auditor: 'Auditor'): 483 name = str(self) 484 order = self.dxf.degree + 1 485 n_control_points = len(self.control_points) 486 487 # Splines with to few control points can't be processed: 488 n_control_points_required = required_control_points(order) 489 if n_control_points < n_control_points_required: 490 auditor.fixed_error( 491 code=AuditError.INVALID_SPLINE_CONTROL_POINT_COUNT, 492 message=f"Removed {name} with invalid control point count: " 493 f"{n_control_points} < {n_control_points_required}" 494 ) 495 auditor.trash(self) 496 return 497 498 n_weights = len(self.weights) 499 n_knots = len(self.knots) 500 n_knots_required = required_knot_values( 501 n_control_points, order) 502 503 if n_knots < n_knots_required: 504 # Can not fix entity: because the knot values are basic 505 # values which define the geometry of SPLINE. 506 auditor.fixed_error( 507 code=AuditError.INVALID_SPLINE_KNOT_VALUE_COUNT, 508 message=f"Removed {name} with invalid knot value count: " 509 f"{n_knots} < {n_knots_required}" 510 ) 511 auditor.trash(self) 512 return 513 514 if n_weights and n_weights != n_control_points: 515 # Can not fix entity: because the weights are basic 516 # values which define the geometry of SPLINE. 517 auditor.fixed_error( 518 code=AuditError.INVALID_SPLINE_WEIGHT_COUNT, 519 message=f"Removed {name} with invalid weight count: " 520 f"{n_weights} != {n_control_points}" 521 ) 522 auditor.trash(self) 523 return 524 525 def _audit_fit_points(self, auditor: 'Auditor'): 526 name = str(self) 527 order = self.dxf.degree + 1 528 # Assuming end tangents will be estimated if not present, 529 # like by ezdxf: 530 n_fit_points_required = required_fit_points(order, tangents=True) 531 532 # Splines with to few fit points can't be processed: 533 n_fit_points = len(self.fit_points) 534 if n_fit_points < n_fit_points_required: 535 auditor.fixed_error( 536 code=AuditError.INVALID_SPLINE_FIT_POINT_COUNT, 537 message=f"Removed {name} with invalid fit point count: " 538 f"{n_fit_points} < {n_fit_points_required}" 539 ) 540 auditor.trash(self) 541 return 542 543 # Knot values have no meaning for splines defined by fit points: 544 if len(self.knots): 545 auditor.fixed_error( 546 code=AuditError.INVALID_SPLINE_KNOT_VALUE_COUNT, 547 message=f"Removed unused knot values for {name} " 548 f"defined by fit points." 549 ) 550 self.knots = [] 551 552 # Weights have no meaning for splines defined by fit points: 553 if len(self.weights): 554 auditor.fixed_error( 555 code=AuditError.INVALID_SPLINE_WEIGHT_COUNT, 556 message=f"Removed unused weights for {name} " 557 f"defined by fit points." 558 ) 559 self.weights = [] 560