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