1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303
|
# Copyright (c) 2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
import pytest
from ezdxf.math import Vec2, BoundingBox2d, is_point_in_polygon_2d
from ezdxf.math.clipping import InvertedClippingPolygon2d as ICP
from ezdxf.math.clipping import (
find_closest_vertices,
make_inverted_clipping_polygon,
)
# The InvertedClippingPolygon2d is based on the ConcaveClippingPolygon2d,
# so not all clipping tests are replicated here. See test_664.
@pytest.mark.parametrize(
"extmin,extmax,expected",
[
[(0, 0), (1, 1), (0, 0)],
[(8, 1), (9, 2), (1, 1)],
[(8, 8), (9, 9), (2, 2)],
[(1, 8), (2, 9), (3, 3)],
],
ids=["lower-left", "lower-right", "upper-right", "upper-left"],
)
def test_find_closest_vertices(extmin, extmax, expected):
outer = BoundingBox2d([(0, 0), (10, 10)]).rect_vertices()
inner = BoundingBox2d([extmin, extmax]).rect_vertices()
assert find_closest_vertices(inner, outer) == expected
# 9 7........6
# 8 ......3-2.
# 7 ......|.|.
# 6 ......0-1.
# 5 ..........
# 4 ..........
# 3 ..........
# 2 ..........
# 1 ..........
# 0 4........5
# 0123456789
def test_make_inverted_clipping_polygon():
# 4 6
outer_boundary = BoundingBox2d([(0, 0), (9, 9)])
# 0 1 2 3
inner_polygon = Vec2.list([(6, 6), (8, 6), (8, 8), (6, 8)])
result = make_inverted_clipping_polygon(inner_polygon, outer_boundary)
assert len(result) == 11
# closest vertices are: 2<->6 where the the inner polygon will be connected
# to the outer boundary
for num, point in enumerate(
[
(8, 8), # 2: start inner path at closest vertex pair
(6, 8), # 3: walk in counter clockwise order
(6, 6), # 0
(8, 6), # 1
(8, 8), # 2: close inner path
(9, 9), # 6: connect to outer boundary
(9, 0), # 5: walk in clockwise order
(0, 0), # 4
(0, 9), # 7
(9, 9), # 6: closer outer boundary
(8, 8), # 2: connect to inner polygon
]
):
assert result[num] == point
# The connection between inner polygon and outer bounds, is created
# automatically at setup! This creates a closed concave clipping shape.
#
# 0123456789
# 9 ..........
# 8 .+------5.
# 7 .|......|.
# 6 .|.3--2.|.
# 5 .|.|..|.|.
# 4 .|.|..|.|.
# 3 .|.0--1.|.
# 2 .|/.....|.
# 1 .4------+.
# 0 ..........
# 0123456789
INNER_POLYGON = Vec2.list(
# 0 1 2 3
[(3, 3), (6, 3), (6, 6), (3, 6)]
)
@pytest.fixture(scope="module")
def inverted_polygon():
# 4 5
return ICP(INNER_POLYGON, BoundingBox2d([(1, 1), (8, 8)]))
# 0123456789
# 9 ..........
# 8 .+------5.
# 7 .|d....c|.
# 6 .|.3--2.|.
# 5 .|.|..|.|.
# 4 .|.|..|.|.
# 3 .|.0--1.|.
# 2 .|a....b|.
# 1 .4------+.
# 0 ..........
# 0123456789
# a b c d
POINTS_INSIDE = Vec2.list([(2, 2), (7, 2), (7, 7), (2, 7)])
# 0123456789
# 9 g...f....e
# 8 .+------5.
# 7 .|......|.
# 6 .|.3--2.|.
# 5 .|.|..|.|.
# 4 h|.|i.|.|d
# 3 .|.0--1.|.
# 2 .|......|.
# 1 .4------+.
# 0 a...b....c
# 0123456789
POINTS_OUTSIDE = Vec2.list(
# a b c d e f g h i
[(0, 0), (4, 0), (9, 0), (9, 4), (9, 9), (4, 9), (0, 9), (0, 4), (4, 4)]
)
@pytest.mark.parametrize("point", POINTS_INSIDE)
def test_point_is_inside_polygon(point: Vec2, inverted_polygon: ICP):
assert inverted_polygon.is_inside(point) is True
@pytest.mark.parametrize("point", POINTS_OUTSIDE)
def test_point_is_outside_polygon(point: Vec2, inverted_polygon: ICP):
assert inverted_polygon.is_inside(point) is False
class TestLineClipping:
# just test the basics to see if the implementation runs, more tests in test_664.
def test_basic_clipping(self, inverted_polygon: ICP):
"""Does the inside/outside detection work for inverted polygons?"""
# 5 .|.|..|.|.
# 4 ax=x..x=xb
# 3 .|.0--1.|.
# 2 .|/.....|.
# 1 .4------+.
# 0 ..........
# 0123456789
result = inverted_polygon.clip_line(Vec2(0, 4), Vec2(9, 4))
assert len(result) == 2
l1, l2 = result
assert l1[0].isclose((1, 4))
assert l1[1].isclose((3, 4))
assert l2[0].isclose((6, 4))
assert l2[1].isclose((8, 4))
def test_is_point_inside_inverted_polygon(self, inverted_polygon: ICP):
polygon = inverted_polygon._clipping_polygon
assert is_point_in_polygon_2d(Vec2(1.5, 2), polygon) == 1
assert is_point_in_polygon_2d(Vec2(7.5, 2), polygon) == 1
@pytest.mark.parametrize(
"s,e,xs,xe",
[
[(0, 2), (9, 2), (1, 2), (8, 2)], # outside, outside
[(1, 2), (8, 2), (1, 2), (8, 2)], # border, border
[(1.5, 2), (7.5, 2), (1.5, 2), (7.5, 2)], # inside, inside
],
ids=["outside, outside", "border, border", "inside, inside"],
)
def test_connection_line_create_intersection_point(
self, s, e, xs, xe, inverted_polygon: ICP
):
"""A line should be clipped at the diagonal connection line between 4<->0.
There are two clipping lines 4->0 and 4<-0. The zero-length segment between the
real segments should not be returned.
inside, inside: #1101 <https://github.com/mozman/ezdxf/issues/1101>
"""
# 5 .|.|..|.|.
# 4 .|.|..|.|.
# 3 .|.0--1.|.
# 2 axx=====xb
# 1 .4------+.
# 0 ..........
# 0123456789
result = inverted_polygon.clip_line(Vec2(s), Vec2(e))
assert len(result) == 2
l1, l2 = result
assert l1[0].isclose(xs)
assert l1[1].isclose((2, 2))
# zero-length segment (2, 2) -> (2, 2) is ignored or outside!
assert l2[0].isclose((2, 2))
assert l2[1].isclose(xe)
def test_line_touches_vertex_at_coincident_edge(self, inverted_polygon: ICP):
"""Intersection point at coincident edge."""
# 4 .|a|..|.|.
# 3 .|.x--1.|.
# 2 .|/.b...|.
# 1 .4------+.
# 0 ..........
# 0123456789
result = inverted_polygon.clip_line(Vec2(2, 4), Vec2(4, 2))
assert len(result) == 2
s, e = result[0]
assert s.isclose((2, 4))
assert e.isclose((3, 3))
s, e = result[1]
assert s.isclose((3, 3))
assert e.isclose((4, 2))
def test_line_touches_vertex_inside_inside(self, inverted_polygon: ICP):
"""No intersection point at vertex 1."""
# 4 .|.|..|a|.
# 3 .|.0--1.|.
# 2 .|/..b..|.
# 1 .4------+.
# 0 ..........
# 0123456789
result = inverted_polygon.clip_line(Vec2(7, 4), Vec2(5, 2))
assert len(result) == 1
s, e = result[0]
assert s.isclose((7, 4))
assert e.isclose((5, 2))
def test_colinear_line(self, inverted_polygon: ICP):
"""Colinear line segments are inside per definition."""
# 5 .|.|..|.|.
# 4 .|.|..|.|.
# 3 ax=x==x=xb
# 2 .|/.....|.
# 1 .4------+.
# 0 ..........
# 0123456789
result = inverted_polygon.clip_line(Vec2(0, 3), Vec2(9, 3))
assert len(result) == 3
l1, l2, l3 = result
assert l1[0].isclose((1, 3))
assert l1[1].isclose((3, 3))
# zero-length segment (3, 3) -> (3, 3) is ignored or outside!
assert l2[0].isclose((3, 3))
assert l2[1].isclose((6, 3))
assert l3[0].isclose((6, 3))
assert l3[1].isclose((8, 3))
class TestPolygonClipping:
# just test the basics to see if the implementation runs, more tests in test_664.
def test_polygon_outside(self, inverted_polygon: ICP):
# 9 ..........
# 8 .+------5.
# 7 .|......|.
# 6 .|.3--2.|.
# 5 .|.|dc|.|.
# 4 .|.|ab|.|.
# 3 .|.0--1.|.
# 2 .|/.....|.
# 1 .4------+.
# 0 ..........
# 0123456789
result = inverted_polygon.clip_polygon(
# a b c d
Vec2.list([(4, 4), (5, 4), (5, 5), (4, 5)])
)
assert len(result) == 0
def test_polygon_clipping(self, inverted_polygon: ICP):
# 5 .|.|..|.|.
# 4 .|.|..|.|.
# 3 .|.0--1.|.
# 2 .|/d--c.|.
# 1 .4-x--x-+.
# 0 ...a--b...
# 0123456789
result = inverted_polygon.clip_polygon(
# a b c d
Vec2.list([(3, 0), (6, 0), (6, 2), (3, 2)])
)
assert len(result) == 1
clipped_polygon = result[0]
# The clipped polygon doesn't have to be closed, future implementation may
# change and the results of the Greiner-Horman algorithm for "edge-cases" are
# not consistent!
assert len(clipped_polygon) in (4, 5)
if len(clipped_polygon) == 5:
assert clipped_polygon[0].isclose(clipped_polygon[-1])
bbox = BoundingBox2d(clipped_polygon)
assert bbox.extmin.isclose((3, 1)) # lower-left clipped vertex
assert bbox.extmax.isclose((6, 2)) # vertex c
if __name__ == "__main__":
pytest.main([__file__])
|