diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9af03d0869..2e56a5b4528f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `compas.datastructures.Graph.node_index` and `compas.datastructures.Graph.index_node`. * Added `compas.datastructures.Graph.edge_index` and `compas.datastructures.Graph.index_edge`. * Added `compas.datastructures.Halfedge.vertex_index` and `compas.datastructures.Halfedge.index_vertex`. +* xxxxx (to see if the PR passes...) +* Added support for offset operations using a shapely plugin implementation (for polyline and polygon) * Added `compas.geometry.trimesh_descent_numpy`. * Added `compas.geometry.trimesh_gradient_numpy`. diff --git a/src/compas/__init__.py b/src/compas/__init__.py index 1a87032e0ebe..ce16ad3e0d0c 100644 --- a/src/compas/__init__.py +++ b/src/compas/__init__.py @@ -136,6 +136,8 @@ __all_plugins__ = [ "compas.geometry.booleans.booleans_shapely", + "compas.geometry.offset.offset", + "compas.geometry.offset.offset_shapely", ] diff --git a/src/compas/geometry/__init__.py b/src/compas/geometry/__init__.py index 00678e04a34e..c413ffaf1379 100644 --- a/src/compas/geometry/__init__.py +++ b/src/compas/geometry/__init__.py @@ -279,7 +279,7 @@ intersection_sphere_line, intersection_sphere_sphere, ) -from .offset.offset import offset_line, offset_polyline, offset_polygon +from .offset import offset_line, offset_polyline, offset_polygon from .quadmesh.planarization import quadmesh_planarize from .triangulation import conforming_delaunay_triangulation, constrained_delaunay_triangulation, delaunay_triangulation from .triangulation.delaunay import delaunay_from_points diff --git a/src/compas/geometry/curves/line.py b/src/compas/geometry/curves/line.py index f4dfbaeb046e..1853cc058498 100644 --- a/src/compas/geometry/curves/line.py +++ b/src/compas/geometry/curves/line.py @@ -368,3 +368,33 @@ def closest_point(self, point, return_parameter=False): if return_parameter: return c, t return c + + def offset(self, distance, **kwargs): + """Offset a line by a distance. + + Parameters + ---------- + line : :class:`~compas.geometry.Line` + A line defined by two points. + distance : float + The offset distance as float. + + Returns + ------- + list[point] + The two points of the offseted line. + + Notes + ----- + The offset direction is chosen such that if the line were along the positve + X axis and the normal of the offset plane is along the positive Z axis, the + offset line is in the direction of the postive Y axis. + + Depending of the backend used, additional parameters can be added as keyword arguments. (point somewhere in api, or list + accepted arguments) + + """ + from compas.geometry import offset_line + + points = offset_line(self, distance, **kwargs) + return Line(*points) diff --git a/src/compas/geometry/curves/polyline.py b/src/compas/geometry/curves/polyline.py index 2d6aba3b2a67..2615d21a0344 100644 --- a/src/compas/geometry/curves/polyline.py +++ b/src/compas/geometry/curves/polyline.py @@ -640,3 +640,33 @@ def shortened(self, length): crv = self.copy() crv.shorten(length) return crv + + def offset(self, distance, **kwargs): + """Offset a polyline by a distance. + + Parameters + ---------- + polyline : :class:`~compas.geometry.Polyline` + A polyline defined by a sequence of points. + distance : float + The offset distance as float. + + Returns + ------- + list[point] + The points of the offseted polyline. + + Notes + ----- + The offset direction is chosen such that if the line were along the positve + X axis and the normal of the offset plane is along the positive Z axis, the + offset line is in the direction of the postive Y axis. + + Depending of the backend used, additional parameters can be added as keyword arguments. (point somewhere in api, or list + accepted arguments) + + """ + from compas.geometry import offset_polyline + + points = offset_polyline(self, distance, **kwargs) + return Polyline(points) diff --git a/src/compas/geometry/offset/__init__.py b/src/compas/geometry/offset/__init__.py index e69de29bb2d1..2fd3cb377b16 100644 --- a/src/compas/geometry/offset/__init__.py +++ b/src/compas/geometry/offset/__init__.py @@ -0,0 +1,92 @@ +from __future__ import print_function +from __future__ import absolute_import +from __future__ import division + +from compas.plugins import pluggable + + +@pluggable(category="offset") +def offset_line(line, distance, **kwargs): + """Offset a line by a distance. + + Parameters + ---------- + line : :class:`~compas.geometry.Line` + A line defined by two points. + distance : float + The offset distance as float. + + Returns + ------- + list[point] + The two points of the offseted line. + + Notes + ----- + The offset direction is chosen such that if the line were along the positve + X axis and the normal of the offset plane is along the positive Z axis, the + offset line is in the direction of the postive Y axis. + + Depending of the backend used, additional parameters can be added as keyword arguments. (point somewhere in api, or list + accepted arguments) + + """ + raise NotImplementedError + + +@pluggable(category="offset") +def offset_polyline(polyline, distance, **kwargs): + """Offset a polyline by a distance. + + Parameters + ---------- + polyline : :class:`~compas.geometry.Polyline` + A polyline defined by a sequence of points. + distance : float + The offset distance as float. + + Returns + ------- + list[point] + The points of the offseted polyline. + + Notes + ----- + The offset direction is chosen such that if the polyline were along the positve + X axis and the normal of the offset plane is along the positive Z axis, the + offset polyline is in the direction of the postive Y axis. + + Depending of the backend used, additional parameters can be added as keyword arguments. (point somewhere in api, or list + accepted arguments) + + """ + raise NotImplementedError + + +@pluggable(category="offset") +def offset_polygon(polygon, distance, **kwargs): + """Offset a polygon by a distance. + + Parameters + ---------- + polygon : :class:`~compas.geometry.Polygon` + A polygon defined by a sequence of vertices. + distance : float + The offset distance as float. + + Returns + ------- + list[point] + The vertices of the offseted polygon. + + Notes + ----- + The offset direction is determined by the provided normal vector. + If the polyline is in the XY plane and the normal is along the positive Z axis, + positive offset distances will result in counterclockwise offsets, + and negative values in clockwise direction. + Depending of the backend used, additional parameters can be added as keyword arguments. (point somewhere in api, or list + accepted arguments) + + """ + raise NotImplementedError diff --git a/src/compas/geometry/offset/offset.py b/src/compas/geometry/offset/offset.py index 44f20a9b9d61..46b54d680607 100644 --- a/src/compas/geometry/offset/offset.py +++ b/src/compas/geometry/offset/offset.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import division +from compas.plugins import plugin from compas.geometry import scale_vector from compas.geometry import normalize_vector from compas.geometry import add_vectors @@ -51,7 +52,8 @@ def offset_segments(point_list, distances, normal): return segments -def offset_line(line, distance, normal=[0.0, 0.0, 1.0]): +@plugin(category="offset", trylast=True) +def offset_line(line, distance, normal=[0.0, 0.0, 1.0], **kwargs): """Offset a line by a distance. Parameters @@ -97,7 +99,8 @@ def offset_line(line, distance, normal=[0.0, 0.0, 1.0]): return c, d -def offset_polygon(polygon, distance, tol=1e-6): +@plugin(category="offset", trylast=True) +def offset_polygon(polygon, distance, tol=1e-6, **kwargs): """Offset a polygon (closed) by a distance. Parameters @@ -153,7 +156,8 @@ def offset_polygon(polygon, distance, tol=1e-6): return offset -def offset_polyline(polyline, distance, normal=[0.0, 0.0, 1.0], tol=1e-6): +@plugin(category="offset", trylast=True) +def offset_polyline(polyline, distance, normal=[0.0, 0.0, 1.0], tol=1e-6, **kwargs): """Offset a polyline by a distance. Parameters diff --git a/src/compas/geometry/offset/offset_shapely.py b/src/compas/geometry/offset/offset_shapely.py new file mode 100644 index 000000000000..ce8166e43b1e --- /dev/null +++ b/src/compas/geometry/offset/offset_shapely.py @@ -0,0 +1,118 @@ +from compas.plugins import plugin +from shapely.geometry import LineString +from shapely.geometry import Polygon +from shapely import is_ccw + + +# @plugin(category="offset", requires=["shapely"]) +# def offset_line(line, distance, **kwargs): +# """Offset a line by a distance. + +# Parameters +# ---------- +# line : :class:`~compas.geometry.Line` +# A line defined by two points. +# distance : float +# The offset distance as float. + +# Returns +# ------- +# list[point] +# The two points of the offseted line. + +# Notes +# ----- +# The side is determined by the sign of the distance parameter +# (negative for right side offset, positive for left side offset). + +# """ +# linestring = LineString((line.start, line.end)) +# offset = linestring.offset_curve(distance) +# return list(offset.coords) + + +@plugin(category="offset", requires=["shapely"]) +def offset_polyline(polyline, distance, join_style="sharp", sharp_limit=5, **kwargs): + """Offset a polyline by a distance. + + Parameters + ---------- + polyline : :class:`~compas.geometry.Polyline` + A polyline defined by a sequence of points. + distance : float + The offset distance as float. + join_style : {"sharp", "round", "chamfer"} + Specifies the shape of offsetted line midpoints. "round" results in rounded shapes. + "chamfer" results in a chamfered edge that touches the original vertex. "sharp" results in a single vertex + that is chamfered depending on the sharp_limit parameter. Defaults to "sharp". + sharp_limit: float, optional + The sharp limit ratio is used for very sharp corners. The sharp ratio is the ratio of the distance + from the corner to the end of the chamfered offset corner. When two line segments meet at a sharp angle, + a sharp join will extend the original geometry. To prevent unreasonable geometry, + the sharp limit allows controlling the maximum length of the join corner. + Corners with a ratio which exceed the limit will be chamfered. + + Returns + ------- + list[point] + The points of the offseted polyline. + + Notes + ----- + The side is determined by the sign of the distance parameter + (negative for right side offset, positive for left side offset). + + """ + join_styles = ("round", "sharp", "chamfer") + try: + join_style_int = join_styles.index(join_style.lower()) + 1 + except ValueError: + print("Join styles supported are round, sharp and chamfer.") + linestring = LineString(polyline.points) + offset = linestring.offset_curve(distance, join_style=join_style_int, mitre_limit=sharp_limit) + return list(offset.coords) + + +@plugin(category="offset", requires=["shapely"]) +def offset_polygon(polygon, distance, join_style="sharp", sharp_limit=5, **kwargs): + """Offset a polygon by a distance. + + Parameters + ---------- + polygon : :class:`~compas.geometry.Polygon` + A polygon defined by a sequence of vertices. + distance : float + The offset distance as float. + join_style : {"sharp", "round", "chamfer"} + Specifies the shape of offsetted line midpoints. "round" results in rounded shapes. + "chamfer" results in a chamfered edge that touches the original vertex. "sharp" results in a single vertex + that is chamfered depending on the sharp_limit parameter. Defaults to "sharp". + sharp_limit: float, optional + The sharp limit ratio is used for very sharp corners. The sharp ratio is the ratio of the distance + from the corner to the end of the chamfered offset corner. When two line segments meet at a sharp angle, + a sharp join will extend the original geometry. To prevent unreasonable geometry, + the sharp limit allows controlling the maximum length of the join corner. + Corners with a ratio which exceed the limit will be chamfered. + + Returns + ------- + list[point] + The vertices of the offseted polygon. + + Notes + ----- + The offset direction is determined by the provided normal vector. + If the polyline is in the XY plane and the normal is along the positive Z axis, + positive offset distances will result in counterclockwise offsets, + and negative values in clockwise direction. + + """ + join_styles_dict = {"round": "round", "sharp": "mitre", "chamfer": "bevel"} + join_style = join_styles_dict.get(join_style) + if not join_style: + raise ValueError("Join styles supported are round, sharp and chamfer.") + pgon = Polygon(polygon.points) + offset = pgon.buffer(-distance, join_style=join_style, mitre_limit=sharp_limit, single_sided=True) + if is_ccw(pgon.exterior): + return list(offset.reverse().exterior.coords) + return list(offset.exterior.coords) diff --git a/src/compas/geometry/polygon.py b/src/compas/geometry/polygon.py index bc3c9bd3f34e..02ff4cf02b65 100644 --- a/src/compas/geometry/polygon.py +++ b/src/compas/geometry/polygon.py @@ -492,3 +492,29 @@ def boolean_intersection(self, other): coords = boolean_intersection_polygon_polygon(self, other) return Polygon([[x, y, 0] for x, y in coords]) # type: ignore + + def offset(self, distance, **kwargs): + """Offset a polygon by a distance. + + Parameters + ---------- + polygon : :class:`~compas.geometry.Polygon` + A polygon defined by a sequence of vertices. + distance : float + The offset distance as float. + + Returns + ------- + list[point] + The vertices of the offseted polygon. + + Notes + ----- + Depending of the backend used, additional parameters can be added as keyword arguments. (point somewhere in api, or list + accepted arguments) + + """ + from compas.geometry import offset_polygon + + vertices = offset_polygon(self, distance, **kwargs) + return Polygon(vertices) diff --git a/tests/compas/geometry/predicates/test_predicates_2.py b/tests/compas/geometry/predicates/test_predicates_2.py index f6c5212c2402..47c32d9f3e76 100644 --- a/tests/compas/geometry/predicates/test_predicates_2.py +++ b/tests/compas/geometry/predicates/test_predicates_2.py @@ -1,9 +1,9 @@ -# from compas.geometry import Circle -# from compas.geometry import Plane -# from compas.geometry import Point +from compas.geometry import Circle +from compas.geometry import Frame +from compas.geometry import Point from compas.geometry import Polygon -# from compas.geometry import Vector +from compas.geometry import Vector from compas.geometry import is_point_in_circle_xy, is_polygon_in_polygon_xy @@ -17,12 +17,12 @@ def test_is_point_in_circle_xy(): assert not is_point_in_circle_xy(pt_outside, circle) -# def test_is_point_in_circle_xy_class_input(): -# pt_inside = Point(1, 2, 0) -# plane = Plane(Point(2, 2, 10), Vector(0, 0, 1)) -# radius = 4.7 -# circle = Circle(plane, radius) -# assert is_point_in_circle_xy(pt_inside, circle) +def test_is_point_in_circle_xy_class_input(): + pt_inside = Point(1, 2, 0) + frame = Frame(Point(2, 2, 10), Vector(1, 0, 0), Vector(0, 1, 0)) + radius = 4.7 + circle = Circle(frame, radius) + assert is_point_in_circle_xy(pt_inside, circle) # pt_outside = Point(15, 15, 0) # assert not is_point_in_circle_xy(pt_outside, circle) diff --git a/tests/compas/geometry/test_offset.py b/tests/compas/geometry/test_offset.py index 762b5ac7b361..d6af08402307 100644 --- a/tests/compas/geometry/test_offset.py +++ b/tests/compas/geometry/test_offset.py @@ -1,9 +1,10 @@ import pytest +from compas.geometry import Line +from compas.geometry import Polyline +from compas.geometry import Polygon +from compas.geometry.offset.offset import offset_polyline as c_offset_polyline from compas.geometry import allclose -from compas.geometry import offset_line -from compas.geometry import offset_polygon -from compas.geometry import offset_polyline # ============================================================================== # polygon @@ -22,8 +23,9 @@ ], ) def test_offset_polygon(polygon, distance, tol, output_polygon): + input_polygon = Polygon(polygon) output_polygon = [v for v in output_polygon] - assert allclose(offset_polygon(polygon, distance, tol), output_polygon) + assert allclose(input_polygon.offset(distance, tol=tol), output_polygon) @pytest.mark.parametrize( @@ -37,7 +39,7 @@ def test_offset_polygon(polygon, distance, tol, output_polygon): [0.0, 1.0, 0.0], [0.0, 0.5, 0.0], ], - [0.10], + 0.10, 1e-6, [ [0.1, 0.1, 0.0], @@ -50,7 +52,8 @@ def test_offset_polygon(polygon, distance, tol, output_polygon): ], ) def test_offset_colinear_polygon(polygon, distance, tol, output_polygon): - assert allclose(offset_polygon(polygon, distance, tol), output_polygon) + input_polygon = Polygon(polygon) + assert allclose(input_polygon.offset(distance, tol=tol), output_polygon) # ============================================================================== @@ -63,8 +66,9 @@ def test_offset_colinear_polygon(polygon, distance, tol, output_polygon): [([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], 1, [0.0, 0.0, 1.0], 1e-6)], ) def test_offset_polyline_equals_offset_line(polyline, distance, normal, tol): - output_line = [v for v in offset_line(polyline, distance, normal)] - assert allclose(offset_polyline(polyline, distance, normal, tol), output_line) + input_polyline = Polyline(polyline) + output_polyline = [v for v in input_polyline.offset(distance, normal=normal)] + assert allclose(input_polyline.offset(distance, normal=normal, tol=tol), output_polyline) @pytest.mark.parametrize( @@ -80,8 +84,9 @@ def test_offset_polyline_equals_offset_line(polyline, distance, normal, tol): ], ) def test_variable_offset_on_colinear_polyline(polyline, distance, normal, tol, output_polyline): + input_polyline = Polyline(polyline) output_polyline = [v for v in output_polyline] - assert allclose(offset_polyline(polyline, distance, normal, tol), output_polyline) + assert allclose(c_offset_polyline(input_polyline, distance, normal=normal, tol=tol), output_polyline) # ============================================================================== @@ -94,5 +99,6 @@ def test_variable_offset_on_colinear_polyline(polyline, distance, normal, tol, o [([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]], 1, [0.0, 0.0, 1.0])], ) def test_offset_line_zero_length(line, distance, normal): - output_line = offset_line(line, distance, normal) - assert allclose(line, output_line) + input_line = Line(*line) + output_line = input_line.offset(distance, normal=normal) + assert allclose(input_line, output_line)