1#  Copyright (c) 2020, Manfred Moitzi
2#  License: MIT License
3import pytest
4import math
5from ezdxf.layouts import VirtualLayout
6from ezdxf.math import Matrix44, OCS, Vec3, close_vectors
7from ezdxf.path import (
8    Path, bbox, fit_paths_into_box, transform_paths, transform_paths_to_ocs,
9    to_polylines3d, to_lines, to_lwpolylines, to_polylines2d,
10    to_hatches, to_bsplines_and_vertices, to_splines_and_polylines,
11    from_vertices
12)
13from ezdxf.path import make_path, Command
14
15
16class TestTransformPaths():
17    def test_empty_paths(self):
18        result = transform_paths([], Matrix44())
19        assert len(result) == 0
20
21    def test_start_point_only_paths(self):
22        result = transform_paths([Path((1, 2, 3))], Matrix44())
23        assert len(result) == 1
24        assert len(result[0]) == 0
25        assert result[0].start == (1, 2, 3)
26
27    def test_transformation_is_executed(self):
28        # Real transformation is just tested once, because Matrix44
29        # transformation is tested in 605:
30        result = transform_paths([Path((1, 2, 3))], Matrix44.translate(1, 1, 1))
31        assert result[0].start == (2, 3, 4)
32
33    def test_one_path_line_to(self):
34        path = Path()
35        path.line_to((1, 0))
36        result = transform_paths([path], Matrix44())
37        path0 = result[0]
38        assert path0[0].type == Command.LINE_TO
39        assert path0.start == (0, 0)
40        assert path0.end == (1, 0)
41
42    def test_one_path_curve3_to(self):
43        path = Path()
44        path.curve3_to((2, 0), (1, 1))
45        result = transform_paths([path], Matrix44())
46        path0 = result[0]
47        assert path0[0].type == Command.CURVE3_TO
48        assert len(path0[0]) == 2
49        assert path0.start == (0, 0)
50        assert path0.end == (2, 0)
51
52    def test_one_path_curve4_to(self):
53        path = Path()
54        path.curve4_to((2, 0), (0, 1), (2, 1))
55        result = transform_paths([path], Matrix44())
56        path0 = result[0]
57        assert path0[0].type == Command.CURVE4_TO
58        assert len(path0[0]) == 3
59        assert path0.start == (0, 0)
60        assert path0.end == (2, 0)
61
62    def test_one_path_multiple_command(self):
63        path = Path()
64        path.line_to((1, 0))
65        path.curve3_to((2, 0), (2.5, 1))
66        path.curve4_to((3, 0), (2, 1), (3, 1))
67        result = transform_paths([path], Matrix44())
68
69        path0 = result[0]
70        assert path0[0].type == Command.LINE_TO
71        assert path0[1].type == Command.CURVE3_TO
72        assert path0[2].type == Command.CURVE4_TO
73        assert path0.start == (0, 0)
74        assert path0.end == (3, 0)
75
76    def test_two_paths_one_command(self):
77        path_a = Path()
78        path_a.line_to((1, 0))
79        path_b = Path((2, 0))
80        path_b.line_to((3, 0))
81        result = transform_paths([path_a, path_b], Matrix44())
82
83        path0 = result[0]
84        assert path0[0].type == Command.LINE_TO
85        assert path0.start == (0, 0)
86        assert path0.end == (1, 0)
87
88        path1 = result[1]
89        assert path1[0].type == Command.LINE_TO
90        assert path1.start == (2, 0)
91        assert path1.end == (3, 0)
92
93    def test_two_paths_multiple_commands(self):
94        path_a = Path()
95        path_a.line_to((1, 0))
96        path_a.curve3_to((2, 0), (2.5, 1))
97        path_a.curve4_to((3, 0), (2, 1), (3, 1))
98
99        path_b = path_a.transform(Matrix44.translate(4, 0, 0))
100        result = transform_paths([path_a, path_b], Matrix44())
101
102        path0 = result[0]
103        assert path0[0].type == Command.LINE_TO
104        assert path0[1].type == Command.CURVE3_TO
105        assert path0[2].type == Command.CURVE4_TO
106        assert path0.start == (0, 0)
107        assert path0.end == (3, 0)
108
109        path1 = result[1]
110        assert path1[0].type == Command.LINE_TO
111        assert path1[1].type == Command.CURVE3_TO
112        assert path1[2].type == Command.CURVE4_TO
113        assert path1.start == (4, 0)
114        assert path1.end == (7, 0)
115
116    def test_to_ocs(self):
117        p = Path((0, 1, 1))
118        p.line_to((0, 1, 3))
119        ocs = OCS((1, 0, 0))  # x-Axis
120        result = list(transform_paths_to_ocs([p], ocs))
121        p0 = result[0]
122        assert ocs.from_wcs((0, 1, 1)) == p0.start
123        assert ocs.from_wcs((0, 1, 3)) == p0[0].end
124
125
126class TestBoundingBox:
127    def test_empty_paths(self):
128        result = bbox([])
129        assert result.has_data is False
130
131    def test_one_path(self):
132        p = Path()
133        p.line_to((1, 2, 3))
134        assert bbox([p]).size == (1, 2, 3)
135
136    def test_two_path(self):
137        p1 = Path()
138        p1.line_to((1, 2, 3))
139        p2 = Path()
140        p2.line_to((-3, -2, -1))
141        assert bbox([p1, p2]).size == (4, 4, 4)
142
143    @pytest.fixture(scope='class')
144    def quadratic(self):
145        p = Path()
146        p.curve3_to((2, 0), (1, 1))
147        return p
148
149    def test_not_precise_box(self, quadratic):
150        result = bbox([quadratic], flatten=0)
151        assert result.extmax.y == pytest.approx(1)  # control point
152
153    def test_precise_box(self, quadratic):
154        result = bbox([quadratic], flatten=0.01)
155        assert result.extmax.y == pytest.approx(0.5)  # parabola
156
157
158class TestFitPathsIntoBoxUniformScaling:
159    @pytest.fixture(scope='class')
160    def spath(self):
161        p = Path()
162        p.line_to((1, 2, 3))
163        return p
164
165    def test_empty_paths(self):
166        assert fit_paths_into_box([], (0, 0, 0)) == []
167
168    def test_uniform_stretch_paths_limited_by_z(self, spath):
169        result = fit_paths_into_box([spath], (6, 6, 6))
170        box = bbox(result)
171        assert box.size == (2, 4, 6)
172
173    def test_uniform_stretch_paths_limited_by_y(self, spath):
174        result = fit_paths_into_box([spath], (6, 3, 6))
175        box = bbox(result)
176        # stretch factor: 1.5
177        assert box.size == (1.5, 3, 4.5)
178
179    def test_uniform_stretch_paths_limited_by_x(self, spath):
180        result = fit_paths_into_box([spath], (1.2, 6, 6))
181        box = bbox(result)
182        # stretch factor: 1.2
183        assert box.size.isclose((1.2, 2.4, 3.6))
184
185    def test_uniform_shrink_paths(self, spath):
186        result = fit_paths_into_box([spath], (1.5, 1.5, 1.5))
187        box = bbox(result)
188        assert box.size.isclose((0.5, 1, 1.5))
189
190    def test_project_into_xy(self, spath):
191        result = fit_paths_into_box([spath], (6, 6, 0))
192        box = bbox(result)
193        # Note: z-axis is also ignored by extent detection:
194        # scaling factor = 3x
195        assert box.size.isclose((3, 6, 0)), "z-axis should be ignored"
196
197    def test_project_into_xz(self, spath):
198        result = fit_paths_into_box([spath], (6, 0, 6))
199        box = bbox(result)
200        assert box.size.isclose((2, 0, 6)), "y-axis should be ignored"
201
202    def test_project_into_yz(self, spath):
203        result = fit_paths_into_box([spath], (0, 6, 6))
204        box = bbox(result)
205        assert box.size.isclose((0, 4, 6)), "x-axis should be ignored"
206
207    def test_invalid_target_size(self, spath):
208        with pytest.raises(ValueError):
209            fit_paths_into_box([spath], (0, 0, 0))
210
211
212class TestFitPathsIntoBoxNonUniformScaling:
213    @pytest.fixture(scope='class')
214    def spath(self):
215        p = Path()
216        p.line_to((1, 2, 3))
217        return p
218
219    def test_non_uniform_stretch_paths(self, spath):
220        result = fit_paths_into_box([spath], (8, 7, 6), uniform=False)
221        box = bbox(result)
222        assert box.size == (8, 7, 6)
223
224    def test_non_uniform_shrink_paths(self, spath):
225        result = fit_paths_into_box([spath], (1.5, 1.5, 1.5),
226                                    uniform=False)
227        box = bbox(result)
228        assert box.size == (1.5, 1.5, 1.5)
229
230    def test_project_into_xy(self, spath):
231        result = fit_paths_into_box([spath], (6, 6, 0), uniform=False)
232        box = bbox(result)
233        assert box.size == (6, 6, 0), "z-axis should be ignored"
234
235    def test_project_into_xz(self, spath):
236        result = fit_paths_into_box([spath], (6, 0, 6), uniform=False)
237        box = bbox(result)
238        assert box.size == (6, 0, 6), "y-axis should be ignored"
239
240    def test_project_into_yz(self, spath):
241        result = fit_paths_into_box([spath], (0, 6, 6), uniform=False)
242        box = bbox(result)
243        assert box.size == (0, 6, 6), "x-axis should be ignored"
244
245
246class TestPathToBsplineAndVertices:
247    def test_empty_path(self):
248        result = list(to_bsplines_and_vertices(Path()))
249        assert result == []
250
251    def test_only_vertices(self):
252        p = from_vertices([(1, 0), (2, 0), (3, 1)])
253        result = list(to_bsplines_and_vertices(p))
254        assert len(result) == 1, "expected one list of vertices"
255        assert len(result[0]) == 3, "expected 3 vertices"
256
257    def test_one_quadratic_bezier(self):
258        p = Path()
259        p.curve3_to((4, 0), (2, 2))
260        result = list(to_bsplines_and_vertices(p))
261        assert len(result) == 1, "expected one B-spline"
262        cpnts = result[0].control_points
263        # A quadratic bezier should be converted to cubic bezier curve, which
264        # has a precise cubic B-spline representation.
265        assert len(cpnts) == 4, "expected 4 control vertices"
266        assert cpnts[0] == (0, 0)
267        assert cpnts[3] == (4, 0)
268
269    def test_one_cubic_bezier(self):
270        p = Path()
271        p.curve4_to((4, 0), (1, 2), (3, 2))
272        result = list(to_bsplines_and_vertices(p))
273        assert len(result) == 1, "expected one B-spline"
274        # cubic bezier curve maps 1:1 to cubic B-spline curve
275        # see tests: 630b for the bezier_to_bspline() function
276
277    def test_adjacent_cubic_beziers_with_G1_continuity(self):
278        p = Path()
279        p.curve4_to((4, 0), (1, 2), (3, 2))
280        p.curve4_to((8, 0), (5, -2), (7, -2))
281        result = list(to_bsplines_and_vertices(p))
282        assert len(result) == 1, "expected one B-spline"
283        # cubic bezier curve maps 1:1 to cubic B-spline curve
284        # see tests: 630b for the bezier_to_bspline() function
285
286    def test_adjacent_cubic_beziers_without_G1_continuity(self):
287        p = Path()
288        p.curve4_to((4, 0), (1, 2), (3, 2))
289        p.curve4_to((8, 0), (5, 2), (7, 2))
290        result = list(to_bsplines_and_vertices(p))
291        assert len(result) == 2, "expected two B-splines"
292
293    def test_multiple_segments(self):
294        p = Path()
295        p.curve4_to((4, 0), (1, 2), (3, 2))
296        p.line_to((6, 0))
297        p.curve3_to((8, 0), (7, 1))
298        result = list(to_bsplines_and_vertices(p))
299        assert len(result) == 3, "expected three segments"
300
301
302class TestToEntityConverter:
303    @pytest.fixture
304    def path(self):
305        p = Path()
306        p.line_to((4, 0, 0))
307        p.curve4_to((0, 0, 0), (3, 1, 1), (1, 1, 1))
308        return p
309
310    @pytest.fixture
311    def path1(self):
312        p = Path((0, 0, 1))
313        p.curve4_to((4, 0, 1), (1, 1, 1), (3, 1, 1))
314        return p
315
316    def test_empty_to_polylines3d(self):
317        assert list(to_polylines3d([])) == []
318
319    def test_to_polylines3d(self, path):
320        polylines = list(to_polylines3d(path))
321        assert len(polylines) == 1
322        p0 = polylines[0]
323        assert p0.dxftype() == 'POLYLINE'
324        assert p0.is_3d_polyline is True
325        assert len(p0) == 18
326        assert p0.vertices[0].dxf.location == (0, 0, 0)
327        assert p0.vertices[-1].dxf.location == (0, 0, 0)
328
329    def test_empty_to_lines(self):
330        assert list(to_lines([])) == []
331
332    def test_to_lines(self, path):
333        lines = list(to_lines(path))
334        assert len(lines) == 17
335        l0 = lines[0]
336        assert l0.dxftype() == 'LINE'
337        assert l0.dxf.start == (0, 0, 0)
338        assert l0.dxf.end == (4, 0, 0)
339
340    def test_empty_to_lwpolyline(self):
341        assert list(to_lwpolylines([])) == []
342
343    def test_to_lwpolylines(self, path):
344        polylines = list(to_lwpolylines(path))
345        assert len(polylines) == 1
346        p0 = polylines[0]
347        assert p0.dxftype() == 'LWPOLYLINE'
348        assert p0[0] == (0, 0, 0, 0, 0)  # x, y, swidth, ewidth, bulge
349        assert p0[-1] == (0, 0, 0, 0, 0)
350
351    def test_to_lwpolylines_with_wcs_elevation(self, path1):
352        polylines = list(to_lwpolylines(path1))
353        p0 = polylines[0]
354        assert p0.dxf.elevation == 1
355
356    def test_to_lwpolylines_with_ocs(self, path1):
357        m = Matrix44.x_rotate(math.pi / 4)
358        path = path1.transform(m)
359        extrusion = m.transform((0, 0, 1))
360        polylines = list(to_lwpolylines(path, extrusion=extrusion))
361        p0 = polylines[0]
362        assert p0.dxf.elevation == pytest.approx(1)
363        assert p0.dxf.extrusion.isclose(extrusion)
364        assert p0[0] == (0, 0, 0, 0, 0)
365        assert p0[-1] == (4, 0, 0, 0, 0)
366
367    def test_empty_to_polylines2d(self):
368        assert list(to_polylines2d([])) == []
369
370    def test_to_polylines2d(self, path):
371        polylines = list(to_polylines2d(path))
372        assert len(polylines) == 1
373        p0 = polylines[0]
374        assert p0.dxftype() == 'POLYLINE'
375        assert p0.is_2d_polyline is True
376        assert p0[0].dxf.location == (0, 0, 0)
377        assert p0[-1].dxf.location == (0, 0, 0)
378
379    def test_to_polylines2d_with_wcs_elevation(self, path1):
380        polylines = list(to_polylines2d(path1))
381        p0 = polylines[0]
382        assert p0.dxf.elevation == (0, 0, 1)
383
384    def test_to_polylines2d_with_ocs(self, path1):
385        m = Matrix44.x_rotate(math.pi / 4)
386        path = path1.transform(m)
387        extrusion = m.transform((0, 0, 1))
388        polylines = list(to_polylines2d(path, extrusion=extrusion))
389        p0 = polylines[0]
390        assert p0.dxf.elevation.isclose((0, 0, 1))
391        assert p0.dxf.extrusion.isclose(extrusion)
392        assert p0[0].dxf.location.isclose((0, 0, 1))
393        assert p0[-1].dxf.location.isclose((4, 0, 1))
394
395    def test_empty_to_hatches(self):
396        assert list(to_hatches([])) == []
397
398    def test_to_poly_path_hatches(self, path):
399        hatches = list(to_hatches(path, edge_path=False))
400        assert len(hatches) == 1
401        h0 = hatches[0]
402        assert h0.dxftype() == 'HATCH'
403        assert len(h0.paths) == 1
404
405    def test_to_poly_path_hatches_with_wcs_elevation(self, path1):
406        hatches = list(to_hatches(path1, edge_path=False))
407        ho = hatches[0]
408        assert ho.dxf.elevation.isclose((0, 0, 1))
409
410    def test_to_poly_path_hatches_with_ocs(self, path1):
411        m = Matrix44.x_rotate(math.pi / 4)
412        path = path1.transform(m)
413        extrusion = m.transform((0, 0, 1))
414        hatches = list(to_hatches(path, edge_path=False, extrusion=extrusion))
415        h0 = hatches[0]
416        assert h0.dxf.elevation.isclose((0, 0, 1))
417        assert h0.dxf.extrusion.isclose(extrusion)
418        polypath0 = h0.paths[0]
419        assert polypath0.vertices[0] == (0, 0, 0)  # x, y, bulge
420        assert polypath0.vertices[-1] == (
421            0, 0, 0), "should be closed automatically"
422
423    def test_to_edge_path_hatches(self, path):
424        hatches = list(to_hatches(path, edge_path=True))
425        assert len(hatches) == 1
426        h0 = hatches[0]
427        assert h0.dxftype() == 'HATCH'
428        assert len(h0.paths) == 1
429        edge_path = h0.paths[0]
430        assert edge_path.PATH_TYPE == 'EdgePath'
431        line, spline = edge_path.edges
432        assert line.EDGE_TYPE == 'LineEdge'
433        assert line.start == (0, 0)
434        assert line.end == (4, 0)
435        assert spline.EDGE_TYPE == 'SplineEdge'
436        assert close_vectors(Vec3.generate(spline.control_points), [
437            (4, 0), (3, 1), (1, 1), (0, 0)
438        ])
439
440    def test_to_splines_and_polylines(self, path):
441        entities = list(to_splines_and_polylines([path]))
442        assert len(entities) == 2
443        polyline = entities[0]
444        spline = entities[1]
445        assert polyline.dxftype() == 'POLYLINE'
446        assert spline.dxftype() == 'SPLINE'
447        assert polyline.vertices[0].dxf.location.isclose((0, 0))
448        assert polyline.vertices[1].dxf.location.isclose((4, 0))
449        assert close_vectors(Vec3.generate(spline.control_points), [
450            (4, 0, 0), (3, 1, 1), (1, 1, 1), (0, 0, 0)
451        ])
452
453
454# Issue #224 regression test
455@pytest.fixture
456def ellipse():
457    layout = VirtualLayout()
458    return layout.add_ellipse(
459        center=(1999.488177113287, -1598.02265357955, 0.0),
460        major_axis=(629.968069297, 0.0, 0.0),
461        ratio=0.495263197,
462        start_param=-1.261396328799999,
463        end_param=-0.2505454928,
464        dxfattribs={
465            'layer': "0",
466            'linetype': "Continuous",
467            'color': 3,
468            'extrusion': (0.0, 0.0, -1.0),
469        },
470    )
471
472
473def test_issue_224_end_points(ellipse):
474    p = make_path(ellipse)
475
476    assert ellipse.start_point.isclose(p.start)
477    assert ellipse.end_point.isclose(p.end)
478
479    # end point locations measured in BricsCAD:
480    assert ellipse.start_point.isclose((2191.3054, -1300.8375), abs_tol=1e-4)
481    assert ellipse.end_point.isclose((2609.7870, -1520.6677), abs_tol=1e-4)
482