From 1495ffc80ab88c7451e64c5673525a8d823c1811 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:47:29 +0000 Subject: [PATCH 1/7] Initial plan From 60e25c6839a003d3327703c94da69bc20ff68709 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:52:30 +0000 Subject: [PATCH 2/7] Add documentation and tests for edge cases in smoothing matrix Co-authored-by: m-reuter <8526484+m-reuter@users.noreply.github.com> --- lapy/polygon.py | 11 ++++++++++ lapy/utils/tests/test_polygon.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/lapy/polygon.py b/lapy/polygon.py index 8c5ee505..699fa122 100644 --- a/lapy/polygon.py +++ b/lapy/polygon.py @@ -277,6 +277,17 @@ def _construct_smoothing_matrix(self) -> sparse.csc_matrix: its neighbors (previous and next point). For open polygons, boundary points (first and last) are kept fixed. + The method handles polygons of any size: + + - For open polygons with 2 points: Both boundary points remain fixed + (identity matrix), so smoothing has no effect. + - For open polygons with 3+ points: Boundary points are fixed, interior + points are averaged with their neighbors. + - For closed polygons with 2 points: Each point is averaged with its + neighbor, causing them to converge to their midpoint. + - For closed polygons with 3+ points: All points are averaged with + their neighbors in a circular manner. + Returns ------- scipy.sparse.csc_matrix diff --git a/lapy/utils/tests/test_polygon.py b/lapy/utils/tests/test_polygon.py index fc29c56b..5181723a 100644 --- a/lapy/utils/tests/test_polygon.py +++ b/lapy/utils/tests/test_polygon.py @@ -221,6 +221,42 @@ def test_smooth_taubin_inplace(self): assert result is poly, "Should return self when inplace=True" + def test_smooth_two_point_open(self): + """Test smoothing on two-point open polygon.""" + points = np.array([[0.0, 0.0], [1.0, 1.0]]) + poly = Polygon(points, closed=False) + smoothed = poly.smooth_laplace(n=5, lambda_=0.5, inplace=False) + + # Both points should remain unchanged (boundary points are fixed) + assert np.allclose(smoothed.get_points(), points), \ + "Two-point open polygon should not change when smoothed" + + def test_smooth_two_point_closed(self): + """Test smoothing on two-point closed polygon.""" + points = np.array([[0.0, 0.0], [2.0, 2.0]]) + poly = Polygon(points, closed=True) + smoothed = poly.smooth_laplace(n=5, lambda_=0.5, inplace=False) + + # Points should converge to their midpoint + midpoint = np.mean(points, axis=0) + assert np.allclose(smoothed.get_points(), midpoint, atol=1e-10), \ + "Two-point closed polygon should converge to midpoint" + + def test_smooth_three_point_open(self): + """Test smoothing on three-point open polygon.""" + points = np.array([[0.0, 0.0], [0.5, 2.0], [1.0, 0.0]]) + poly = Polygon(points, closed=False) + smoothed = poly.smooth_laplace(n=5, lambda_=0.5, inplace=False) + + # First and last points should remain fixed + assert np.allclose(smoothed.get_points()[0], points[0]), \ + "First point should remain fixed in open polygon" + assert np.allclose(smoothed.get_points()[-1], points[-1]), \ + "Last point should remain fixed in open polygon" + # Middle point should be smoothed (moved toward average of neighbors) + assert smoothed.get_points()[1, 1] < points[1, 1], \ + "Middle point should be smoothed downward" + if __name__ == "__main__": pytest.main([__file__, "-v"]) From e1845cd5bcd3d5182a4d13993b97bc4307d2a540 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 19 Dec 2025 07:51:28 +0100 Subject: [PATCH 3/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lapy/__init__.py | 2 +- lapy/polygon.py | 30 ++++++++++++++++++++---------- lapy/utils/tests/test_polygon.py | 4 ++-- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lapy/__init__.py b/lapy/__init__.py index 30fb8830..0fccfc2c 100644 --- a/lapy/__init__.py +++ b/lapy/__init__.py @@ -1,5 +1,5 @@ from ._version import __version__ # noqa: F401 -from .polygon import Polygon +from .polygon import Polygon # noqa: F401 from .solver import Solver # noqa: F401 from .tet_mesh import TetMesh # noqa: F401 from .tria_mesh import TriaMesh # noqa: F401 diff --git a/lapy/polygon.py b/lapy/polygon.py index 699fa122..8ceead88 100644 --- a/lapy/polygon.py +++ b/lapy/polygon.py @@ -4,15 +4,10 @@ various geometric operations including resampling, smoothing, and metric computations. """ -import logging import sys import numpy as np from scipy import sparse - -logger = logging.getLogger(__name__) - - class Polygon: """Class representing a polygon path (open or closed). @@ -177,14 +172,14 @@ def centroid(self) -> np.ndarray: y_closed = np.append(y, y[0]) # Shoelace formula components cross = x_closed[:-1] * y_closed[1:] - x_closed[1:] * y_closed[:-1] - area = 0.5 * np.abs(cross.sum()) + signed_area = 0.5 * cross.sum() - if area < sys.float_info.epsilon: - # Degenerate case: zero area + if abs(signed_area) < sys.float_info.epsilon: + # Degenerate case: zero or near-zero area return np.mean(self.points, axis=0) - cx = np.sum((x_closed[:-1] + x_closed[1:]) * cross) / (6.0 * area) - cy = np.sum((y_closed[:-1] + y_closed[1:]) * cross) / (6.0 * area) + cx = np.sum((x_closed[:-1] + x_closed[1:]) * cross) / (6.0 * signed_area) + cy = np.sum((y_closed[:-1] + y_closed[1:]) * cross) / (6.0 * signed_area) return np.array([cx, cy]) def area(self) -> float: @@ -241,6 +236,10 @@ def resample( Polygon Resampled polygon. Returns self if inplace=True, new instance otherwise. """ + if n_points < 2: + raise ValueError("n_points must be at least 2") + if n_iter < 1: + raise ValueError("n_iter must be at least 1") def _resample_once(p: np.ndarray, n: int, is_closed: bool) -> np.ndarray: """Single resampling pass.""" if is_closed: @@ -355,6 +354,11 @@ def smooth_laplace( Polygon Smoothed polygon. Returns self if inplace=True, new instance otherwise. """ + # Input validation to enforce documented parameter ranges + if not isinstance(n, int) or n <= 0: + raise ValueError(f"n must be a positive integer, got {n!r}") + if not (0.0 <= lambda_ <= 1.0): + raise ValueError(f"lambda_ must be in the range [0, 1], got {lambda_!r}") mat = self._construct_smoothing_matrix() points_smooth = self.points.copy() @@ -397,6 +401,12 @@ def smooth_taubin( Polygon Smoothed polygon. Returns self if inplace=True, new instance otherwise. """ + if n <= 0: + raise ValueError("n must be a positive integer") + if lambda_ <= 0: + raise ValueError("lambda_ must be positive") + if mu >= 0: + raise ValueError("mu must be negative") mat = self._construct_smoothing_matrix() points_smooth = self.points.copy() diff --git a/lapy/utils/tests/test_polygon.py b/lapy/utils/tests/test_polygon.py index 5181723a..4258931c 100644 --- a/lapy/utils/tests/test_polygon.py +++ b/lapy/utils/tests/test_polygon.py @@ -173,9 +173,9 @@ def test_smooth_laplace_open(self): "Should preserve number of points" # First and last points should remain unchanged for open polygon assert np.allclose(smoothed.get_points()[0], points[0]), \ - "First point should not change much" + "First point should remain unchanged (up to numerical precision)" assert np.allclose(smoothed.get_points()[-1], points[-1]), \ - "Last point should not change much" + "Last point should remain unchanged (up to numerical precision)" def test_smooth_laplace_closed(self): """Test Laplace smoothing on closed polygon.""" From 4e453e5078a1f58ec24c6ebc4470d3c9b9789c14 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 19 Dec 2025 08:01:09 +0100 Subject: [PATCH 4/7] fix include order --- lapy/polygon.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lapy/polygon.py b/lapy/polygon.py index 8ceead88..c0989e0c 100644 --- a/lapy/polygon.py +++ b/lapy/polygon.py @@ -8,6 +8,8 @@ import numpy as np from scipy import sparse + + class Polygon: """Class representing a polygon path (open or closed). From 28c2468f0660324c867a57276f3f35b44a5778fc Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 19 Dec 2025 08:04:41 +0100 Subject: [PATCH 5/7] Update lapy/polygon.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lapy/polygon.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/lapy/polygon.py b/lapy/polygon.py index c0989e0c..7724699f 100644 --- a/lapy/polygon.py +++ b/lapy/polygon.py @@ -67,14 +67,27 @@ def __init__(self, points: np.ndarray, closed: bool = False): if self.points.size == 0: raise ValueError("Polygon has no points (empty)") - # Transpose if necessary - if self.points.shape[0] < self.points.shape[1]: + # Ensure points array is 2-dimensional + if self.points.ndim != 2: + raise ValueError("Points array must be 2-dimensional") + + n_rows, n_cols = self.points.shape + + # Support both (n_points, dim) and (dim, n_points) where dim is 2 or 3. + # Only transpose when it is unambiguous that the first dimension is dim. + if n_cols not in (2, 3) and n_rows in (2, 3): + logger.warning( + "Transposing points array from shape %s to %s; expected shape (n_points, dim).", + self.points.shape, + self.points.T.shape, + ) self.points = self.points.T + n_rows, n_cols = self.points.shape # Support both 2D and 3D points - if self.points.shape[1] == 2: + if n_cols == 2: self._is_2d = True - elif self.points.shape[1] == 3: + elif n_cols == 3: self._is_2d = False else: raise ValueError("Points should have 2 or 3 coordinates") From 83dbfb060f3620432b1175e32fad3286bbf7c69d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:47:29 +0000 Subject: [PATCH 6/7] Initial plan From 8a19940ae126584cdf4524793e981ff61ac2bdad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 07:12:14 +0000 Subject: [PATCH 7/7] Fix logger import and test after rebase Co-authored-by: m-reuter <8526484+m-reuter@users.noreply.github.com> --- lapy/polygon.py | 3 +++ lapy/utils/tests/test_polygon.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lapy/polygon.py b/lapy/polygon.py index 7724699f..568b8a86 100644 --- a/lapy/polygon.py +++ b/lapy/polygon.py @@ -4,11 +4,14 @@ various geometric operations including resampling, smoothing, and metric computations. """ +import logging import sys import numpy as np from scipy import sparse +logger = logging.getLogger(__name__) + class Polygon: """Class representing a polygon path (open or closed). diff --git a/lapy/utils/tests/test_polygon.py b/lapy/utils/tests/test_polygon.py index 4258931c..869c7a4b 100644 --- a/lapy/utils/tests/test_polygon.py +++ b/lapy/utils/tests/test_polygon.py @@ -35,7 +35,7 @@ def test_init_empty_raises(self): def test_init_invalid_dimensions_raises(self): """Test that invalid dimensions raise ValueError.""" with pytest.raises(ValueError, match="2 or 3 coordinates"): - Polygon(np.array([[0.0], [1.0]])) + Polygon(np.array([[0.0, 1.0, 2.0, 3.0]])) def test_length_open(self): """Test length computation for open polygon."""