offset-poly 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.1
2
+ Name: offset-poly
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: Shay Hill
6
+ Author-email: shay_public@hotmail.com
7
+ Requires-Python: >=3.9,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # offset_poly
15
+
16
+ Move a polyline or polygon (left) by a given offset.
17
+
18
+ A counterclockwise polygon will be offset to the inside. I clockwise polygon will be offset to the outside.
19
+
20
+ ## offset_polygon
21
+
22
+ ~~~python
23
+ def offset_polygon(polyline: Sequence[_Vec2], offset: float) -> list[_Vec2]:
24
+ """Offset polygon edges (to the left) by a constant amount.
25
+
26
+ :param polyline: polyline
27
+ :param offset: distance to offset from each edge
28
+ :return: polygon offset by offset
29
+ """
30
+ ~~~
31
+
32
+ ## offset_polyline
33
+
34
+ ~~~python
35
+ def offset_polyline(polyline: Sequence[_Vec2], offset: float) -> list[_Vec2]:
36
+ """Offset polygon edges (to the left) by a constant amount.
37
+
38
+ :param polyline: polyline
39
+ :param offset: distance to offset from each edge
40
+ :return: polyline offset by offset
41
+ """
42
+ ~~~
43
+
44
+ The difference between the two is that `offset_polygon` will close the polygon if it is not already closed, whereas `offset_polyline` will leave the polyline open even if the first and last points are identical.
45
+
46
+ This package is the simplest version of polyline offsetting, it does not anticipate or account for self intersections that may come up when offsetting a polyline.
47
+
48
+ This is not nearly as sophisticated as curve offsetting, but you can use this for control polygon offsetting, which will be nearly as good in some instances.
49
+
50
+ * multiple points (knots in your control points) are preserved.
51
+ * if input[0] == input[-1], output[0] will equal output[-1]
52
+
53
+ ## You may see (nan, nan) in the result.
54
+
55
+ If you pass two adjacent, opposite, parallel edges, you will get a (nan, nan) in the result. With points A -> B -> A, for instance, there is no point that would be any given distance (except 0) left of both A B and B A.
@@ -0,0 +1,42 @@
1
+ # offset_poly
2
+
3
+ Move a polyline or polygon (left) by a given offset.
4
+
5
+ A counterclockwise polygon will be offset to the inside. I clockwise polygon will be offset to the outside.
6
+
7
+ ## offset_polygon
8
+
9
+ ~~~python
10
+ def offset_polygon(polyline: Sequence[_Vec2], offset: float) -> list[_Vec2]:
11
+ """Offset polygon edges (to the left) by a constant amount.
12
+
13
+ :param polyline: polyline
14
+ :param offset: distance to offset from each edge
15
+ :return: polygon offset by offset
16
+ """
17
+ ~~~
18
+
19
+ ## offset_polyline
20
+
21
+ ~~~python
22
+ def offset_polyline(polyline: Sequence[_Vec2], offset: float) -> list[_Vec2]:
23
+ """Offset polygon edges (to the left) by a constant amount.
24
+
25
+ :param polyline: polyline
26
+ :param offset: distance to offset from each edge
27
+ :return: polyline offset by offset
28
+ """
29
+ ~~~
30
+
31
+ The difference between the two is that `offset_polygon` will close the polygon if it is not already closed, whereas `offset_polyline` will leave the polyline open even if the first and last points are identical.
32
+
33
+ This package is the simplest version of polyline offsetting, it does not anticipate or account for self intersections that may come up when offsetting a polyline.
34
+
35
+ This is not nearly as sophisticated as curve offsetting, but you can use this for control polygon offsetting, which will be nearly as good in some instances.
36
+
37
+ * multiple points (knots in your control points) are preserved.
38
+ * if input[0] == input[-1], output[0] will equal output[-1]
39
+
40
+ ## You may see (nan, nan) in the result.
41
+
42
+ If you pass two adjacent, opposite, parallel edges, you will get a (nan, nan) in the result. With points A -> B -> A, for instance, there is no point that would be any given distance (except 0) left of both A B and B A.
@@ -0,0 +1,43 @@
1
+ """Import fuctions into the package namespace.
2
+
3
+ :author: Shay Hill
4
+ :created: 2023-08-19
5
+ """
6
+
7
+ from offset_poly.offset import (
8
+ offset_poly_per_edge,
9
+ offset_poly_per_vert,
10
+ offset_polygon,
11
+ offset_polyline,
12
+ )
13
+ from offset_poly.offset_corner import miter_corner
14
+ from offset_poly.vec2_math import (
15
+ det,
16
+ dot,
17
+ get_norm,
18
+ get_signed_angle,
19
+ move_along,
20
+ qrotate,
21
+ set_norm,
22
+ vadd,
23
+ vscale,
24
+ vsub,
25
+ )
26
+
27
+ __all__ = [
28
+ "miter_corner",
29
+ "offset_polyline",
30
+ "offset_polygon",
31
+ "offset_poly_per_vert",
32
+ "offset_poly_per_edge",
33
+ "det",
34
+ "dot",
35
+ "get_signed_angle",
36
+ "get_norm",
37
+ "set_norm",
38
+ "move_along",
39
+ "qrotate",
40
+ "vscale",
41
+ "vadd",
42
+ "vsub",
43
+ ]
@@ -0,0 +1,156 @@
1
+ """Offset a polyline to the right.
2
+
3
+ :author: Shay Hill
4
+ :created: 2023-08-17
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ import itertools as it
11
+ from typing import TYPE_CHECKING
12
+
13
+ from offset_poly.offset_corner import miter_corner
14
+ from offset_poly.prepare_poly import (
15
+ align_closing_points,
16
+ remove_coincident_adjacent_points,
17
+ )
18
+ from offset_poly.vec2_math import vadd, vsub
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Iterable, Sequence
22
+
23
+ _Vec2 = tuple[float, float]
24
+
25
+ _MIN_PTS_FOR_POLYGON = 3
26
+ _MIN_PTS_FOR_POLYLINE = 2
27
+
28
+
29
+ class PolyType(enum.Enum):
30
+ """Polyline types."""
31
+
32
+ POLYLINE = enum.auto()
33
+ POLYGON = enum.auto()
34
+
35
+
36
+ def _wrap_polygon(polyline: Sequence[_Vec2]) -> list[_Vec2]:
37
+ """Wrap a polyline around to the beginning if it is closed.
38
+
39
+ :param polyline: polyline
40
+ :return: polyline with last point repeated if it is closed
41
+
42
+ From A, B, C, A to C, A, B, C, A
43
+ """
44
+ points = list(polyline)
45
+ if points[0] == points[-1]:
46
+ points = points[:-1]
47
+ return points[-1:] + points + points[:1]
48
+
49
+
50
+ def _anchor_polyline(polyline: Sequence[_Vec2]) -> list[_Vec2]:
51
+ """Add edges to endpoints of an open polyline.
52
+
53
+ :param polyline: polyline
54
+ :return: polyline with extended edges at the beginning and end
55
+
56
+ A --- B
57
+ |
58
+ C
59
+
60
+ becomes
61
+
62
+ A` --- A --- B
63
+ |
64
+ C
65
+ |
66
+ C`
67
+
68
+ From A, B, C to (A + (A - B)), A, B, C, (C - (B - C))
69
+ """
70
+ pnt_beg = vadd(polyline[0], vsub(*polyline[:2]))
71
+ pnt_end = vsub(polyline[-1], vsub(*polyline[-2:]))
72
+ return [pnt_beg, *list(polyline), pnt_end]
73
+
74
+
75
+ def offset_poly_per_vert(
76
+ polyline: Sequence[_Vec2],
77
+ vert_offsets: Iterable[tuple[float, float]],
78
+ poly_type: PolyType,
79
+ ) -> list[_Vec2]:
80
+ """Offset each corner of a polyline or polygon.
81
+
82
+ :param polyline: polyline
83
+ :param vert_offsets: iterable of (gap_1, gap_2) tuples. One pair per corner.
84
+ :param poly_type: PolyType.POLYGON or PolyType.POLYLINE
85
+ :return: polyline offset by vert_offsets
86
+ :raise ValueError: if fewer than three points are given for a polygon
87
+ :raise ValueError: if fewer than two points are given for a polyline
88
+
89
+ This is the engine of the offset_polyline and offset_polygon
90
+ functions. You can use it directly, but it's going to be tricky if
91
+ you have zero-length segments or closed points. These points will
92
+ be removed before the gaps are applied, so you'll have to pass
93
+ exactly enough for gap pairs for the segments that are retained.
94
+ """
95
+ points = remove_coincident_adjacent_points(polyline)
96
+ gaps = it.cycle(vert_offsets or [(0, 0)])
97
+
98
+ if poly_type == PolyType.POLYGON:
99
+ if len(points) < _MIN_PTS_FOR_POLYGON:
100
+ msg = "at least three unique points required for a polygon"
101
+ raise ValueError(msg)
102
+ points = _wrap_polygon(points)
103
+ elif poly_type == PolyType.POLYLINE:
104
+ if len(points) < _MIN_PTS_FOR_POLYLINE:
105
+ msg = "at least two unique points required for a polyline"
106
+ raise ValueError(msg)
107
+ points = _anchor_polyline(points)
108
+ else:
109
+ msg = "poly_type must be PolyType.POLYGON or PolyType.POLYLINE"
110
+ raise ValueError(msg)
111
+
112
+ offset_points: list[_Vec2] = []
113
+ for pnt_a, pnt_b, pnt_c in zip(points, points[1:], points[2:]):
114
+ gap_1, gap_2 = next(gaps)
115
+ offset_points.append(miter_corner(pnt_a, pnt_b, pnt_c, gap_1, gap_2))
116
+
117
+ return align_closing_points(polyline, offset_points)
118
+
119
+
120
+ def offset_poly_per_edge(
121
+ polyline: Sequence[_Vec2], edge_offsets: Iterable[float], poly_type: PolyType
122
+ ) -> list[_Vec2]:
123
+ """Offset each edge of a polyline or polygon.
124
+
125
+ :param polyline: polyline
126
+ :param edge_offsets: iterable of offsets. One per edge.
127
+ :param poly_type: PolyType.POLYGON or PolyType.POLYLINE
128
+ :return: polyline offset by edge_offsets
129
+
130
+ This function allows each edge of a polygon or polyline to be offset by
131
+ a different amount. You will end up with a ValueError in miter_corner
132
+ if you try to offset consecutive, parallel edges by different amounts.
133
+ """
134
+ prev_edges, next_edges = it.tee(edge_offsets, 2)
135
+ next_edges = it.islice(it.cycle(next_edges), 1, None)
136
+ return offset_poly_per_vert(polyline, zip(prev_edges, next_edges), poly_type)
137
+
138
+
139
+ def offset_polyline(polyline: Sequence[_Vec2], offset: float) -> list[_Vec2]:
140
+ """Offset polygon edges (to the left) by a constant amount.
141
+
142
+ :param polyline: polyline
143
+ :param offset: distance to offset from each edge
144
+ :return: polyline offset by offset
145
+ """
146
+ return offset_poly_per_edge(polyline, it.cycle([offset]), PolyType.POLYLINE)
147
+
148
+
149
+ def offset_polygon(polyline: Sequence[_Vec2], offset: float) -> list[_Vec2]:
150
+ """Offset polygon edges (to the left) by a constant amount.
151
+
152
+ :param polyline: polyline
153
+ :param offset: distance to offset from each edge
154
+ :return: polygon offset by offset
155
+ """
156
+ return offset_poly_per_edge(polyline, it.cycle([offset]), PolyType.POLYGON)
@@ -0,0 +1,48 @@
1
+ """Simple math helpers and offsetting one corner defined by three points.
2
+
3
+ :author: Shay Hill
4
+ :created: 2023-08-17
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from offset_poly.vec2_math import _Vec2, get_signed_angle, move_along, qrotate, vsub
12
+
13
+
14
+ def miter_corner(
15
+ pnt_a: _Vec2, pnt_b: _Vec2, pnt_c: _Vec2, gap_1: float, gap_2: float | None = None
16
+ ) -> tuple[float, float]:
17
+ """Offset a corner (to the right) defined by three points.
18
+
19
+ :param pnt_a: previous point
20
+ :param pnt_b: corner point
21
+ :param pnt_c: next point
22
+ :param gap_1: distance to offset from vec12
23
+ :param gap_2: optional distance to offset from vec23. If not given, gap_1 is used
24
+ :return: the unique point gap_1 distance from ab and gap_2 distance from bc
25
+ :raise ValueError: if angle abc is zero and gap_1 != gap_2
26
+ """
27
+ if gap_2 is None:
28
+ gap_2 = gap_1
29
+
30
+ if gap_1 is 0 and gap_2 is 0:
31
+ return pnt_b
32
+
33
+ vec12 = vsub(pnt_b, pnt_a)
34
+ vec23 = vsub(pnt_c, pnt_b)
35
+ angle = get_signed_angle(vec12, vec23)
36
+
37
+ if math.isclose(angle, 0, abs_tol=1e-6):
38
+ if gap_1 != gap_2:
39
+ except_msg = "gap_1 and gap_2 must be equal when angle is zero"
40
+ raise ValueError(except_msg)
41
+ return move_along(pnt_b, qrotate(vec12, 1), gap_1)
42
+
43
+ sin_angle = math.sin(angle)
44
+ if math.isclose(sin_angle, 0, abs_tol=1e-6):
45
+ return math.nan, math.nan
46
+
47
+ intermediate = move_along(pnt_b, vec23, gap_1 / sin_angle)
48
+ return move_along(intermediate, vec12, -(gap_2 or 0) / sin_angle)
@@ -0,0 +1,59 @@
1
+ """Remove points that would give indeterminate results.
2
+
3
+ :author: Shay Hill
4
+ :created: 2023-08-17
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools as it
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
15
+ _Vec2 = tuple[float, float]
16
+
17
+
18
+ def remove_coincident_adjacent_points(polyline: Sequence[_Vec2]) -> list[_Vec2]:
19
+ """Remove zero-length segments from a polyline.
20
+
21
+ :param polyline: polyline
22
+ :return: polyline with zero-length segments removed
23
+ """
24
+ new_points: list[_Vec2] = []
25
+ for i, point in enumerate(polyline):
26
+ if i == 0 or point != polyline[i - 1]:
27
+ new_points.append(point)
28
+ return new_points
29
+
30
+
31
+ def align_closing_points(
32
+ polyline_a: Sequence[_Vec2], polyline_b: list[_Vec2]
33
+ ) -> list[_Vec2]:
34
+ """Set b[0] to b[-1] if a[0] == a[-1].
35
+
36
+ :param polyline_a: polyline
37
+ :param polyline_b: polyline
38
+ :return: polyline_b with first point set to last point if polyline_a is closed
39
+
40
+ Match multiples of the same point.
41
+ polyline_a -> [A, B, B, C]
42
+ polyline_b -> [D, E, F]
43
+ polyline_c -> [D, E, E, F]
44
+
45
+ Wrap endpoints if polyline_a is closed.
46
+ polyline_a -> [A, B, C, A]
47
+ polyline_b -> [D, E, F]
48
+ polyline_c -> [D, E, F, D]
49
+ """
50
+ polyline_c: list[_Vec2] = []
51
+ points_b = it.cycle(polyline_b)
52
+ for i, point_a in enumerate(polyline_a):
53
+ if i == 0:
54
+ polyline_c.append(next(points_b))
55
+ elif point_a == polyline_a[i - 1]:
56
+ polyline_c.append(polyline_c[-1])
57
+ else:
58
+ polyline_c.append(next(points_b))
59
+ return polyline_c
File without changes
@@ -0,0 +1,129 @@
1
+ """Simple operations on vectors. All return 2tuples.
2
+
3
+ :author: Shay Hill
4
+ :created: 2023-08-19
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from collections.abc import Sequence
11
+ from typing import Union
12
+
13
+ _Vec2 = Union[tuple[float, float], Sequence[float]]
14
+
15
+
16
+ def det(vec_a: _Vec2, vec_b: _Vec2) -> float:
17
+ """Return determinant of a 2x2 matrix.
18
+
19
+ :param vec_a: 2d vector
20
+ :param vec_b: 2d vector
21
+ :return: determinant of the 2x2 matrix where
22
+ vec_a is the first row and vec_b is the second
23
+ """
24
+ return vec_a[0] * vec_b[1] - vec_a[1] * vec_b[0]
25
+
26
+
27
+ def dot(vec_a: _Vec2, vec_b: _Vec2) -> float:
28
+ """Return dot product of two 2d vectors.
29
+
30
+ :param vec_a: 2d vector
31
+ :param vec_b: 2d vector
32
+ :return: dot product of the vectors
33
+ """
34
+ return vec_a[0] * vec_b[0] + vec_a[1] * vec_b[1]
35
+
36
+
37
+ def get_signed_angle(vec_a: _Vec2, vec_b: _Vec2) -> float:
38
+ """Calculate the signed angle at a corner defined by two vectors.
39
+
40
+ :param vec_a: 2d vector
41
+ :param vec_b: 2d vector
42
+ :return: signed angle between the vectors
43
+
44
+ Counterclockwise angles will be positive
45
+ Clockwise angles will be negative
46
+ """
47
+ return math.atan2(det(vec_a, vec_b), dot(vec_a, vec_b))
48
+
49
+
50
+ def get_norm(vec: _Vec2) -> float:
51
+ """Return Euclidean norm of a 2d vector.
52
+
53
+ :param vec: 2d vector
54
+ :return: Euclidean norm of the vector
55
+ """
56
+ return math.sqrt(vec[0] ** 2 + vec[1] ** 2)
57
+
58
+
59
+ def set_norm(vec: _Vec2, norm: float = 1) -> tuple[float, float]:
60
+ """Scale a 2d vector to a given magnitude.
61
+
62
+ :param vec: 2d vector
63
+ :param norm: desired magnitude of the output vector, default is 1
64
+ :return: normalized (then optionally scaled) 2d vector
65
+ :raise ValueError: if trying to scale a zero-length vector to a nonzero length
66
+ """
67
+ input_norm = get_norm(vec)
68
+ if input_norm == 0 and norm != 0:
69
+ except_msg = "cannot scale a zero-length vector to a nonzero length"
70
+ raise ValueError(except_msg)
71
+ if norm == 0:
72
+ return 0, 0
73
+ scale = norm / input_norm
74
+ return vscale(vec, scale)
75
+
76
+
77
+ def vscale(vec: _Vec2, scale: float) -> tuple[float, float]:
78
+ """Multiply a 2d vector by a scalar.
79
+
80
+ :param vec: 2d vector
81
+ :param scale: scalar for vec
82
+ :return: vec * scale
83
+ """
84
+ return vec[0] * scale, vec[1] * scale
85
+
86
+
87
+ def vadd(vec_a: _Vec2, vec_b: _Vec2) -> tuple[float, float]:
88
+ """Add two 2d vectors.
89
+
90
+ :param vec_a: 2d vector
91
+ :param vec_b: 2d vector
92
+ :return: sum of the vectors
93
+ """
94
+ return vec_a[0] + vec_b[0], vec_a[1] + vec_b[1]
95
+
96
+
97
+ def vsub(vec_a: _Vec2, vec_b: _Vec2) -> tuple[float, float]:
98
+ """Subtract two 2d vectors.
99
+
100
+ :param vec_a: 2d vector
101
+ :param vec_b: 2d vector
102
+ :return: difference of the vectors
103
+ """
104
+ return vadd(vec_a, vscale(vec_b, -1))
105
+
106
+
107
+ def move_along(pnt: _Vec2, vec: _Vec2, distance: float) -> tuple[float, float]:
108
+ """Move a point along a vector.
109
+
110
+ :param pnt: 2d vector
111
+ :param vec: 2d vector
112
+ :param distance: distance to move v1 along v2
113
+ :return: new point
114
+ """
115
+ vec12 = set_norm(vec, distance)
116
+ return vadd(pnt, vec12)
117
+
118
+
119
+ def qrotate(vec: _Vec2, quadrants: int) -> tuple[float, float]:
120
+ """Rotate a 2d vector 90 degrees counterclockwise.
121
+
122
+ :param vec: 2d vector
123
+ :param quadrants: number of 90 degree clockwise rotations
124
+ :return: rotated vector
125
+ """
126
+ quadrants = quadrants % 4
127
+ if quadrants == 0:
128
+ return vec[0], vec[1]
129
+ return qrotate((-vec[1], vec[0]), quadrants - 1)
@@ -0,0 +1,19 @@
1
+ [tool.poetry]
2
+ name = "offset-poly"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = ["Shay Hill <shay_public@hotmail.com>"]
6
+ readme = "README.md"
7
+ packages = [{include = "offset_poly"}]
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.9"
11
+
12
+
13
+ [tool.poetry.group.dev.dependencies]
14
+ pytest = "^7.4.0"
15
+ pre-commit = "^3.3.3"
16
+
17
+ [build-system]
18
+ requires = ["poetry-core"]
19
+ build-backend = "poetry.core.masonry.api"