Skip to content

Commit

Permalink
it's working, but one test no longer throws
Browse files Browse the repository at this point in the history
  • Loading branch information
oliverfunk committed Jun 19, 2024
1 parent b11bdb7 commit 753b23e
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 114 deletions.
206 changes: 110 additions & 96 deletions bluemira/geometry/_pyclipr_offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,99 +42,98 @@ def _missing_(cls, value: str | OffsetClipperMethodType) -> OffsetClipperMethodT
) from None


def pyclippath_to_coordinates(path: np.ndarray) -> Coordinates:
def offset_clipper(
coordinates: Coordinates,
delta: float,
method: str | OffsetClipperMethodType = "square",
miter_limit: float = 2.0,
) -> Coordinates:
"""
Transforms a pyclipper path into a bluemira Coordinates object
Carries out an offset operation on the Coordinates using the ClipperLib library.
Only supports closed Coordinates.
Parameters
----------
path:
The vertex polygon path formatting used in pyclipper
coordinates:
The Coordinates upon which to perform the offset operation
delta:
The value of the offset [m]. Positive for increasing size, negative for
decreasing
method:
The type of offset to perform ['square', 'round', 'miter']
miter_limit:
The ratio of delta to use when mitering acute corners. Only used if
method == 'miter'
Returns
-------
The Coordinates from the path object
The offset Coordinates result
Raises
------
GeometryError:
If the Coordinates are not planar
If the Coordinates are not closed
"""
# p2 = scale_from_clipper(np.array(path).T)
return Coordinates({"x": path[0], "y": 0, "z": path[1]})
method = OffsetClipperMethodType(method)
tool = PyCliprOffsetter(method, miter_limit)
return tool.offset(coordinates, delta)


class PyCliprOffsetter:
def __init__(
self,
coordinates: Coordinates,
method: OffsetClipperMethodType,
miter_limit: float = 2.0,
):
if not coordinates.is_planar:
raise GeometryError("Cannot offset non-planar coordinates.")

if not coordinates.closed:
raise GeometryError(
"Open Coordinates are not supported by PyCliprOffsetter."
)

coordinates = deepcopy(coordinates)
com = coordinates.center_of_mass

t_coordinates = transform_coordinates_to_xz(
coordinates, tuple(-np.array(com)), np.array([0.0, 1.0, 0.0])
)
clipr_path = t_coordinates.xz.T

self._coord_scale = self._calculate_scale(clipr_path, coordinates)
self._coordinates = coordinates

# Create an offsetting object
pco = ClipperOffset()

# pco.miterLimit = miter_limit
# Set the scale factor to convert to internal integer representation
pco.scaleFactor = 1000 # ?

self.miter_limit = miter_limit
self.offset_scale = 100000 # ? what to set to
match method:
case OffsetClipperMethodType.SQUARE:
pco.addPaths([clipr_path], JoinType.Square, EndType.Polygon)
self._jt = JoinType.Square
self._et = EndType.Joined
case OffsetClipperMethodType.ROUND:
pco.addPaths([clipr_path], JoinType.Round, EndType.Polygon)
self._jt = JoinType.Round
self._et = EndType.Round
case OffsetClipperMethodType.MITER:
pco.addPaths([clipr_path], JoinType.Miter, EndType.Polygon)
self._pco = pco
self._jt = JoinType.Miter
self._et = EndType.Joined

@staticmethod
def _calculate_scale(path: np.ndarray, coordinates: Coordinates) -> float:
"""
Calculate the pyclipper scaling to integers
"""
# Find the first non-zero dimension (low number of iterations)
for i in range(len(path) - 1):
if path[i][0] != 0:
return path[i][0] / coordinates.x[i]
if path[i][1] != 0:
return path[i][1] / coordinates.z[i]
raise GeometryError(
"Could not calculate scale factor for pyclipper. "
"Path is empty or only (0, 0)'s."
def _transform_coords_to_path(coords: Coordinates) -> np.ndarray:
com = coords.center_of_mass

coords_t = transform_coordinates_to_xz(
coords, tuple(-np.array(com)), np.array([0.0, 1.0, 0.0])
)

def _transform_offset_result(self, result: npt.NDArray[np.float64]) -> Coordinates:
return coordinates_to_pyclippath(coords_t)

@staticmethod
def _transform_offset_result_to_orig(
orig_coords: Coordinates, result: npt.NDArray[np.float64]
) -> Coordinates:
"""
Transforms the offset solution into a Coordinates object
"""
if len(result) == 0 or np.all(result == 0):
raise GeometryError("Offset operation resulted in no geometry.")
orig_com = orig_coords.center_of_mass
orig_norm_v = orig_coords.normal_vector

res_coords_t = pyclippath_to_coordinates(result)

com = self._coordinates.center_of_mass
norm_v = self._coordinates.normal_vector
return transform_coordinates_to_original(res_coords_t, orig_com, orig_norm_v)

x, z = result.T
def _perform_offset(
self, path: npt.NDArray[np.float64], delta: float
) -> npt.NDArray[np.float64]:
# Create an offsetting object
pco = ClipperOffset()
# pco.miterLimit = self.miter_limit
pco.scaleFactor = self.offset_scale

res_coords_t = Coordinates({"x": x, "y": np.zeros(x.shape), "z": z})
return transform_coordinates_to_original(res_coords_t, com, norm_v)
pco.addPath(path, self._jt, self._et)
offset_result = pco.execute(delta)

def perform(self, delta: float) -> Coordinates:
delta = int(round(delta * self._coord_scale))
offset_result = self._pco.execute(delta)
if len(offset_result) == 1:
offset_result = offset_result[0]
elif len(offset_result) > 1:
Expand All @@ -145,45 +144,24 @@ def perform(self, delta: float) -> Coordinates:
offset_result = max(offset_result, key=len)
else:
raise GeometryError("Offset operation failed to produce any geometry.")
return self._transform_offset_result(offset_result)

return offset_result

def offset_clipper(
coordinates: Coordinates,
delta: float,
method: str = "square",
miter_limit: float = 2.0,
) -> Coordinates:
"""
Carries out an offset operation on the Coordinates using the ClipperLib library.
Only supports closed Coordinates.
def offset(self, orig_coords: Coordinates, delta: float) -> Coordinates:
if not orig_coords.is_planar:
raise GeometryError("Cannot offset non-planar coordinates.")

Parameters
----------
coordinates:
The Coordinates upon which to perform the offset operation
delta:
The value of the offset [m]. Positive for increasing size, negative for
decreasing
method:
The type of offset to perform ['square', 'round', 'miter']
miter_limit:
The ratio of delta to use when mitering acute corners. Only used if
method == 'miter'
if not orig_coords.closed:
raise GeometryError(
"Open Coordinates are not supported by PyCliprOffsetter."
)

Returns
-------
The offset Coordinates result
used_coords = deepcopy(orig_coords)

Raises
------
GeometryError:
If the Coordinates are not planar
If the Coordinates are not closed
"""
tool = PyCliprOffsetter(coordinates, OffsetClipperMethodType(method), miter_limit)
result = tool.perform(delta)
return result
path = self._transform_coords_to_path(used_coords)
offset_path = self._perform_offset(path, delta)

return self._transform_offset_result_to_orig(orig_coords, offset_path)


def transform_coordinates_to_xz(
Expand Down Expand Up @@ -215,3 +193,39 @@ def transform_coordinates_to_original(
coordinates = Coordinates({"x": x, "y": y, "z": z})
coordinates.translate(base)
return coordinates


def pyclippath_to_coordinates(path: np.ndarray, *, close=True) -> Coordinates:
"""
Transforms a pyclipper path into a bluemira Coordinates object
Parameters
----------
path:
The vertex polygon path formatting used in pyclipper
Returns
-------
The Coordinates from the path object
"""
x, z = path.T
if close:
x = np.append(x, x[0])
z = np.append(z, z[0])
return Coordinates({"x": x, "y": np.zeros(x.shape), "z": z})


def coordinates_to_pyclippath(coords: Coordinates) -> np.ndarray:
"""
Transforms a pyclipper path into a bluemira Coordinates object
Parameters
----------
path:
The vertex polygon path formatting used in pyclipper
Returns
-------
The Coordinates from the path object
"""
return coords.xz.T
19 changes: 11 additions & 8 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
"""

import os
from contextlib import suppress
from pathlib import Path
from unittest import mock

import matplotlib as mpl
import pytest

from bluemira.base.file import get_bluemira_path, try_get_bluemira_private_data_root
Expand Down Expand Up @@ -54,14 +57,14 @@ def pytest_configure(config):
"""
Configures pytest with the plotting and longrun command line options.
"""
# if not config.getoption("--plotting-on"):
# # We're not displaying plots so use a display-less backend
# mpl.use("Agg")
# # Disable CAD viewer by mocking out FreeCAD API's displayer.
# # Note that if we use a new CAD backend, this must be changed.
# with suppress(ImportError):
# mock.patch("bluemira.codes._polyscope.ps").start()
# mock.patch("bluemira.codes._freecadapi.show_cad").start()
if not config.getoption("--plotting-on"):
# We're not displaying plots so use a display-less backend
mpl.use("Agg")
# Disable CAD viewer by mocking out FreeCAD API's displayer.
# Note that if we use a new CAD backend, this must be changed.
with suppress(ImportError):
mock.patch("bluemira.codes._polyscope.ps").start()
mock.patch("bluemira.codes._freecadapi.show_cad").start()

options = {
"longrun": config.option.longrun,
Expand Down
19 changes: 9 additions & 10 deletions tests/geometry/test_pyclipper_offset.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
import pytest

from bluemira.base.file import get_bluemira_path
from bluemira.geometry._pyclipper_offset import offset_clipper
from bluemira.geometry._pyclipr_offset import offset_clipper as offset_clipper2
from bluemira.geometry._pyclipr_offset import offset_clipper
from bluemira.geometry.coordinates import Coordinates
from bluemira.geometry.error import GeometryError
from bluemira.geometry.tools import distance_to, make_polygon
Expand All @@ -33,9 +32,9 @@ class TestClipperOffset:
("x", "y", "delta"),
[
(x, y, 1.0),
(x[::-1], y[::-1], 1.0),
(x[::-1], y[::-1], 0.9),
(x, y, -1.0),
(x[::-1], y[::-1], -1.0),
(x[::-1], y[::-1], -0.9),
],
)
def test_complex_polygon(self, x, y, delta, method):
Expand All @@ -49,7 +48,7 @@ def test_complex_polygon(self, x, y, delta, method):
ax.set_aspect("equal")

distance = self._calculate_offset(coordinates, c)
np.testing.assert_almost_equal(distance, abs(delta))
np.testing.assert_almost_equal(distance, abs(delta), 5)

@pytest.mark.parametrize("method", options)
def test_complex_polygon_overoffset_raises_error(self, method):
Expand All @@ -63,12 +62,12 @@ def test_blanket_offset(self):
data = json.load(file)
coordinates = Coordinates(data)
offsets = []
for m in ["miter", "square", "round"]: # round very slow...
offset_coordinates = offset_clipper2(coordinates, 1.5, method=m)
for m in ["miter", "square", "round"]:
offset_coordinates = offset_clipper(coordinates, 1.5, method=m)
offsets.append(offset_coordinates)
# Too damn slow!!
# distance = self._calculate_offset(coordinates, offset_coordinates)
# np.testing.assert_almost_equal(distance, 1.5)

distance = self._calculate_offset(coordinates, offset_coordinates)
np.testing.assert_almost_equal(distance, 1.5, 4)

_, ax = plt.subplots()
ax.plot(coordinates.x, coordinates.z, color="k")
Expand Down

0 comments on commit 753b23e

Please sign in to comment.