simgeometry 0.1.0__py3-none-any.whl

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.
Files changed (62) hide show
  1. simgeometry/__init__.py +38 -0
  2. simgeometry/_bounds.py +16 -0
  3. simgeometry/_normalize.py +23 -0
  4. simgeometry/_particle_boundaries.py +37 -0
  5. simgeometry/_types.py +22 -0
  6. simgeometry/api.py +247 -0
  7. simgeometry/boundingbox.py +231 -0
  8. simgeometry/collection.py +65 -0
  9. simgeometry/crossings.py +319 -0
  10. simgeometry/cut_edges.py +309 -0
  11. simgeometry/cylindrical.py +43 -0
  12. simgeometry/domain_decomposition.py +496 -0
  13. simgeometry/evaluation.py +105 -0
  14. simgeometry/fractions.py +68 -0
  15. simgeometry/grid.py +238 -0
  16. simgeometry/kernels/__init__.py +1 -0
  17. simgeometry/kernels/_build.py +53 -0
  18. simgeometry/kernels/_metal.py +41 -0
  19. simgeometry/kernels/compile.py +24 -0
  20. simgeometry/kernels/contains_ball/__init__.py +12 -0
  21. simgeometry/kernels/contains_ball/contains_ball_cuda.cu +27 -0
  22. simgeometry/kernels/contains_ball/contains_ball_metal.py +53 -0
  23. simgeometry/kernels/contains_ball/contains_ball_numpy.py +23 -0
  24. simgeometry/kernels/contains_ball/contains_ball_pyccel.py +31 -0
  25. simgeometry/kernels/contains_box/__init__.py +12 -0
  26. simgeometry/kernels/contains_box/contains_box_cuda.cu +26 -0
  27. simgeometry/kernels/contains_box/contains_box_metal.py +50 -0
  28. simgeometry/kernels/contains_box/contains_box_numpy.py +24 -0
  29. simgeometry/kernels/contains_box/contains_box_pyccel.py +32 -0
  30. simgeometry/kernels/contains_polygon_2d/__init__.py +12 -0
  31. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_cuda.cu +35 -0
  32. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py +58 -0
  33. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_numpy.py +28 -0
  34. simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_pyccel.py +40 -0
  35. simgeometry/mesh.py +147 -0
  36. simgeometry/objects/__init__.py +35 -0
  37. simgeometry/objects/aperture2d.py +183 -0
  38. simgeometry/objects/aperture3d.py +361 -0
  39. simgeometry/objects/circle.py +138 -0
  40. simgeometry/objects/cuboid.py +173 -0
  41. simgeometry/objects/function_object.py +214 -0
  42. simgeometry/objects/line.py +127 -0
  43. simgeometry/objects/polygon.py +182 -0
  44. simgeometry/objects/polyline.py +67 -0
  45. simgeometry/objects/rectangle.py +130 -0
  46. simgeometry/objects/sphere.py +181 -0
  47. simgeometry/objects/triangle.py +148 -0
  48. simgeometry/operations.py +386 -0
  49. simgeometry/panels.py +80 -0
  50. simgeometry/plotting.py +179 -0
  51. simgeometry/py.typed +0 -0
  52. simgeometry/queries.py +445 -0
  53. simgeometry/sampling.py +75 -0
  54. simgeometry/shapes.py +402 -0
  55. simgeometry/simulation_domain.py +277 -0
  56. simgeometry/utils.py +24 -0
  57. simgeometry/visualization.py +381 -0
  58. simgeometry-0.1.0.dist-info/METADATA +128 -0
  59. simgeometry-0.1.0.dist-info/RECORD +62 -0
  60. simgeometry-0.1.0.dist-info/WHEEL +5 -0
  61. simgeometry-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
  62. simgeometry-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,214 @@
1
+ """Function-based geometric object definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ import cunumpy as xp
8
+
9
+ from simgeometry._types import Array, BoundsLike, ConfigValue
10
+ from simgeometry.shapes import Shape2D, Shape3D
11
+
12
+
13
+ class FunctionObject2D(Shape2D):
14
+ """Represent an arbitrary 2D geometry object defined by a Boolean function.
15
+
16
+ The object interior is the set of all points for which ``func(coords)``
17
+ returns ``True``. A bounding box must be supplied by the caller because
18
+ it cannot in general be derived from an arbitrary function.
19
+
20
+ Example
21
+ -------
22
+ Create a ring (annulus) with inner radius 1 and outer radius 2::
23
+
24
+ def ring(coords):
25
+ x, y = coords
26
+ r2 = x**2 + y**2
27
+ return 1.0**2 <= r2 <= 2.0**2
28
+
29
+ obj = FunctionObject2D(
30
+ func=ring,
31
+ lower_bound=(-2.0, -2.0),
32
+ upper_bound=(2.0, 2.0),
33
+ )
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ func: Callable[[Array], bool],
39
+ lower_bound: BoundsLike,
40
+ upper_bound: BoundsLike,
41
+ bc: ConfigValue = None,
42
+ inverted: bool = False,
43
+ ) -> None:
44
+ """
45
+ Initialize a function-based 2D object.
46
+
47
+ Parameters
48
+ ----------
49
+ func:
50
+ A callable that accepts a coordinate array ``(x, y)`` and returns
51
+ ``True`` if the point lies inside the object.
52
+ lower_bound:
53
+ Lower corner ``(x_min, y_min)`` of the bounding box.
54
+ upper_bound:
55
+ Upper corner ``(x_max, y_max)`` of the bounding box.
56
+ bc:
57
+ Optional boundary condition information.
58
+ inverted:
59
+ If ``True``, the object covers every point where ``func`` is falsy.
60
+ """
61
+ assert callable(func), "func must be callable."
62
+ assert len(lower_bound) == 2, "lower_bound must have two coordinates."
63
+ assert len(upper_bound) == 2, "upper_bound must have two coordinates."
64
+
65
+ self._func = func
66
+ self._bc = bc
67
+ self._inverted = bool(inverted)
68
+
69
+ super().__init__(
70
+ lower_bound=tuple(lower_bound),
71
+ upper_bound=tuple(upper_bound),
72
+ )
73
+
74
+ @property
75
+ def func(self) -> Callable[[Array], bool]:
76
+ """Return the Boolean membership function."""
77
+ return self._func
78
+
79
+ def contains(self, coords: Array) -> bool:
80
+ """
81
+ Check if the given coordinates are inside the object.
82
+
83
+ Parameters
84
+ ----------
85
+ coords:
86
+ ``(x, y)`` coordinates to test.
87
+
88
+ Returns
89
+ -------
90
+ bool
91
+ ``True`` if ``func(coords)`` is truthy (falsy when inverted).
92
+ """
93
+ return self._apply_inversion(bool(self._func(xp.to_numpy(coords))))
94
+
95
+ def plot(self, ax: Any = None, **kwargs: Any) -> Any:
96
+ """Draw the object as a sampled mask into ``ax`` and return the axes.
97
+
98
+ ``resolution`` (samples per axis) and ``bounds`` set the sampling of the
99
+ mask, other keyword arguments style the image. With an inspection option
100
+ (``x``, ``y``, ``z``, ``grid``, ``nodes``, ``normals``) this is
101
+ :meth:`Shape2D.plot`.
102
+ """
103
+ if any(key in kwargs for key in ("x", "y", "z", "grid", "nodes", "normals")):
104
+ return super().plot(ax=ax, **kwargs)
105
+ from simgeometry.plotting import draw_shape
106
+
107
+ return draw_shape(self, ax=ax, **kwargs)
108
+
109
+
110
+ class FunctionObject3D(Shape3D):
111
+ """Represent an arbitrary 3D geometry object defined by a Boolean function.
112
+
113
+ The object interior is the set of all points for which ``func(coords)``
114
+ returns ``True``. A bounding box must be supplied by the caller because
115
+ it cannot in general be derived from an arbitrary function.
116
+
117
+ Example
118
+ -------
119
+ Create a solid torus with major radius 3 and tube radius 1::
120
+
121
+ def torus(coords):
122
+ x, y, z = coords
123
+ r = (x**2 + y**2)**0.5
124
+ return (r - 3.0)**2 + z**2 <= 1.0**2
125
+
126
+ obj = FunctionObject3D(
127
+ func=torus,
128
+ lower_bound=(-4.0, -4.0, -1.0),
129
+ upper_bound=(4.0, 4.0, 1.0),
130
+ )
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ func: Callable[[Array], bool],
136
+ lower_bound: BoundsLike,
137
+ upper_bound: BoundsLike,
138
+ bc: ConfigValue = None,
139
+ inverted: bool = False,
140
+ ) -> None:
141
+ """
142
+ Initialize a function-based 3D object.
143
+
144
+ Parameters
145
+ ----------
146
+ func:
147
+ A callable that accepts a coordinate array ``(x, y, z)`` and
148
+ returns ``True`` if the point lies inside the object.
149
+ lower_bound:
150
+ Lower corner ``(x_min, y_min, z_min)`` of the bounding box.
151
+ upper_bound:
152
+ Upper corner ``(x_max, y_max, z_max)`` of the bounding box.
153
+ bc:
154
+ Optional boundary condition information.
155
+ inverted:
156
+ If ``True``, the object covers every point where ``func`` is falsy.
157
+ """
158
+ assert callable(func), "func must be callable."
159
+ assert len(lower_bound) == 3, "lower_bound must have three coordinates."
160
+ assert len(upper_bound) == 3, "upper_bound must have three coordinates."
161
+
162
+ self._func = func
163
+ self._bc = bc
164
+ self._inverted = bool(inverted)
165
+
166
+ super().__init__(
167
+ lower_bound=tuple(lower_bound),
168
+ upper_bound=tuple(upper_bound),
169
+ )
170
+
171
+ @property
172
+ def func(self) -> Callable[[Array], bool]:
173
+ """Return the Boolean membership function."""
174
+ return self._func
175
+
176
+ def contains(self, coords: Array) -> bool:
177
+ """
178
+ Check if the given coordinates are inside the object.
179
+
180
+ Parameters
181
+ ----------
182
+ coords:
183
+ ``(x, y, z)`` coordinates to test.
184
+
185
+ Returns
186
+ -------
187
+ bool
188
+ ``True`` if ``func(coords)`` is truthy (falsy when inverted).
189
+ """
190
+ return self._apply_inversion(bool(self._func(xp.to_numpy(coords))))
191
+
192
+ def get_vertices(
193
+ self,
194
+ slice_axis: int = 0,
195
+ slice_position: float | None = None,
196
+ ) -> tuple:
197
+ """Return an empty array; vertices cannot be derived from an arbitrary function."""
198
+ import cunumpy as xp
199
+
200
+ return xp.array([]), self.inverted
201
+
202
+ def volume(self) -> float:
203
+ """Volume cannot be analytically derived from an arbitrary function."""
204
+ raise NotImplementedError(
205
+ "volume() is not available for FunctionObject3D. "
206
+ "Use numerical integration if needed.",
207
+ )
208
+
209
+ def surface_area(self) -> float:
210
+ """Surface area cannot be analytically derived from an arbitrary function."""
211
+ raise NotImplementedError(
212
+ "surface_area() is not available for FunctionObject3D. "
213
+ "Use numerical methods if needed.",
214
+ )
@@ -0,0 +1,127 @@
1
+ """Line helpers for geometric object definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cunumpy as xp
6
+
7
+ from simgeometry._types import Array, BoundsLike, ConfigValue
8
+ from simgeometry.shapes import Shape1D, _component_major
9
+
10
+
11
+ class Line(Shape1D):
12
+ """Represent a line geometry object."""
13
+
14
+ def __init__(
15
+ self,
16
+ midpoint: BoundsLike,
17
+ side_lengths: BoundsLike,
18
+ bc: ConfigValue = None,
19
+ inverted: bool = False,
20
+ ) -> None:
21
+ """
22
+ Initialize a line in 1D space.
23
+
24
+ Parameters:
25
+ midpoint (list or tuple): Coordinates of the midpoint of the rectangle.
26
+ side_lengths (list or tuple): Lengths of the sides of the rectangle in each dimension.
27
+ bc (optional): Boundary condition information.
28
+ inverted (bool, optional): Cover everything except the line.
29
+ """
30
+ assert len(midpoint) == 1, (
31
+ f"Length of midpoint must match ndim (2). {len(midpoint) =}"
32
+ )
33
+ assert len(side_lengths) == 1, "Length of side_lengths must match ndim."
34
+
35
+ self.midpoint = xp.array(midpoint)
36
+ self.side_lengths = xp.array(side_lengths)
37
+ self._bc = bc
38
+ self._inverted = bool(inverted)
39
+
40
+ # Calculate bounds based on the midpoint and side lengths
41
+ half_lengths = self.side_lengths / 2
42
+ lower_bound = tuple(self.midpoint - half_lengths)
43
+ upper_bound = tuple(self.midpoint + half_lengths)
44
+
45
+ super().__init__(
46
+ lower_bound=lower_bound,
47
+ upper_bound=upper_bound,
48
+ )
49
+
50
+ def mirror(self, axis: int, position: float) -> None:
51
+ """
52
+ Mirror the line across a specified axis at a given position.
53
+
54
+ Parameters:
55
+ axis (int): Axis index (0 for x-axis).
56
+ position (float): Position along the axis to mirror.
57
+ """
58
+ if axis != 0:
59
+ raise ValueError("Axis must be 0 (x-axis).")
60
+
61
+ # Mirror the midpoint coordinate along the specified axis
62
+ self.midpoint[axis] = 2 * position - self.midpoint[axis]
63
+
64
+ half_lengths = self.side_lengths / 2
65
+ lower_bound = tuple(self.midpoint - half_lengths)
66
+ upper_bound = tuple(self.midpoint + half_lengths)
67
+
68
+ self.set_bounding_box(lower_bound, upper_bound)
69
+
70
+ def copy(self) -> Line:
71
+ """
72
+ Create a copy of the line.
73
+
74
+ Returns:
75
+ Line: A new instance of Line with the same midpoint, side lengths, and boundary conditions.
76
+ """
77
+ return Line(
78
+ midpoint=self.midpoint.copy(),
79
+ side_lengths=self.side_lengths.copy(),
80
+ bc=self._bc,
81
+ inverted=self._inverted,
82
+ )
83
+
84
+ def translate(self, offset: tuple[float]) -> None:
85
+ """
86
+ Translate the line by the given offset.
87
+
88
+ Parameters:
89
+ offset (tuple): (dx,) translation vector.
90
+ """
91
+ offset_vec = xp.asarray(offset)
92
+
93
+ # Apply translation
94
+ self.midpoint = self.midpoint + offset_vec
95
+
96
+ half_lengths = self.side_lengths / 2
97
+ lower_bound = tuple(self.midpoint - half_lengths)
98
+ upper_bound = tuple(self.midpoint + half_lengths)
99
+
100
+ self.set_bounding_box(lower_bound, upper_bound)
101
+
102
+ def get_vertices(self) -> ConfigValue:
103
+ """Return the vertices that define the object."""
104
+ return [(self.lower_bound[0], self.upper_bound[0])]
105
+
106
+ def contains(self, coords: Array) -> bool:
107
+ """
108
+ Check if the given coordinates are inside the line.
109
+
110
+ Parameters:
111
+ coords (list or tuple): Coordinates for each dimension.
112
+
113
+ Returns:
114
+ bool: True if the coordinates are inside the line, False otherwise.
115
+ """
116
+ assert len(coords) == 1, f"Expected 1 dimension, got {len(coords)}."
117
+
118
+ coords = xp.to_numpy(coords)
119
+ return self._apply_inversion(
120
+ bool(self.lower_bound[0] <= coords[0] <= self.upper_bound[0])
121
+ )
122
+
123
+ def contains_points(self, points: Array) -> Array:
124
+ """Vectorised :meth:`contains`: a mask of the ``(1, n)`` points inside."""
125
+ points = _component_major(points, 1)
126
+ inside = (self.lower_bound[0] <= points[0]) & (points[0] <= self.upper_bound[0])
127
+ return ~inside if self.inverted else inside
@@ -0,0 +1,182 @@
1
+ """Polygon helpers for geometric object definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cunumpy as xp
6
+ import numpy as np
7
+ from numpy import ndarray
8
+
9
+ from simgeometry.kernels.contains_polygon_2d import kernel as contains_polygon_2d
10
+ from simgeometry.shapes import Shape2D, _component_major
11
+ from simgeometry.utils import vertices_to_bounds
12
+
13
+
14
+ class Polygon(Shape2D):
15
+ """Represent a polygon geometry object."""
16
+
17
+ exact_outline = True
18
+
19
+ def __init__(
20
+ self,
21
+ vertices: ndarray,
22
+ bc: float | None = None,
23
+ inverted: bool = False,
24
+ ) -> None:
25
+ """
26
+ Initialize a polygon in 2D space.
27
+
28
+ Parameters:
29
+ vertices (list or tuple): List of tuples, each representing a vertex (x, y).
30
+ bc (optional): Boundary condition information.
31
+ inverted (bool, optional): Cover everything except the polygon.
32
+ """
33
+ assert len(vertices) >= 3, "Polygon must have at least three vertices."
34
+ assert all(len(vertex) == 2 for vertex in vertices), (
35
+ "Each vertex must have two coordinates."
36
+ )
37
+
38
+ self.vertices = xp.array(vertices) # Use setter
39
+ self._bc = bc
40
+ self._inverted = bool(inverted)
41
+
42
+ # Calculate bounds tuple
43
+ lower_bound, upper_bound = vertices_to_bounds(self.vertices)
44
+
45
+ super().__init__(
46
+ lower_bound=lower_bound,
47
+ upper_bound=upper_bound,
48
+ )
49
+
50
+ @property
51
+ def vertices(self) -> ndarray:
52
+ """Return object vertices."""
53
+ return self._vertices
54
+
55
+ @vertices.setter
56
+ def vertices(self, vertices: ndarray) -> None:
57
+ """Set the vertices."""
58
+ self._vertices = vertices
59
+
60
+ def copy(self) -> Polygon:
61
+ """
62
+ Create a copy of the polygon.
63
+
64
+ Returns:
65
+ Polygon: A new instance of Polygon with the same vertices and boundary conditions.
66
+ """
67
+ return Polygon(
68
+ vertices=self.vertices.copy(),
69
+ bc=self._bc,
70
+ inverted=self._inverted,
71
+ )
72
+
73
+ def translate(self, offset: tuple[float, float]) -> None:
74
+ """
75
+ Translate the polygon by the given offset.
76
+
77
+ Parameters:
78
+ offset (tuple): (dx, dy) translation vector.
79
+ """
80
+ offset_vec = xp.asarray(offset) # , dtype=self._vertices.dtype)
81
+
82
+ # Apply translation
83
+ self.vertices = self.vertices + offset_vec
84
+
85
+ lower_bound, upper_bound = vertices_to_bounds(self.vertices)
86
+
87
+ self.set_bounding_box(lower_bound, upper_bound)
88
+
89
+ # TODO: Enable mirroring along arbitrary axes
90
+ def mirror(self, axis: int, position: float) -> None:
91
+ """
92
+ Mirror the polygon across a specified axis at a given position.
93
+
94
+ Parameters:
95
+ axis (int): Axis to mirror across (0 for x-axis, 1 for y-axis).
96
+ position (float): Position along the axis to mirror.
97
+ """
98
+ assert axis in (0, 1), "Axis must be 0 (x-axis) or 1 (y-axis)."
99
+
100
+ # Create a copy of the vertices to avoid modifying during iteration
101
+ new_vertices = self.vertices.copy()
102
+
103
+ # Apply mirroring
104
+ for i in range(len(new_vertices)):
105
+ if axis == 0: # Mirror across x-axis
106
+ new_vertices[i][1] = 2 * position - new_vertices[i][1]
107
+ else: # Mirror across y-axis
108
+ new_vertices[i][0] = 2 * position - new_vertices[i][0]
109
+
110
+ self.vertices = new_vertices
111
+
112
+ lower_bound, upper_bound = vertices_to_bounds(self.vertices)
113
+
114
+ self.set_bounding_box(lower_bound, upper_bound)
115
+
116
+ def _corner_loops(self) -> list[np.ndarray]:
117
+ """The polygon's vertices as one ``(2, n)`` loop."""
118
+ return [np.asarray(xp.to_numpy(self._vertices), dtype=float).T.copy()]
119
+
120
+ def _contains_polygon(self, coords: ndarray) -> bool:
121
+ """
122
+ Check if the given coordinates are inside the polygon.
123
+
124
+ Parameters:
125
+ coords (list or tuple): Coordinates (x, y).
126
+
127
+ Returns:
128
+ bool: True if the point is inside the polygon, False otherwise.
129
+ """
130
+ coords = xp.to_numpy(coords)
131
+ vertices = xp.to_numpy(self._vertices)
132
+ x, y = coords
133
+
134
+ # First check if the point is inside the bounding box
135
+ if not self.bounding_box.contains(coords):
136
+ return False
137
+
138
+ # Ray casting algorithm for point-in-polygon
139
+ num_vertices = len(vertices)
140
+ inside = False
141
+ j = num_vertices - 1
142
+ for i in range(num_vertices):
143
+ xi, yi = vertices[i]
144
+ xj, yj = vertices[j]
145
+ if ((yi > y) != (yj > y)) and (
146
+ x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-16) + xi
147
+ ):
148
+ inside = not inside
149
+ j = i
150
+ return inside
151
+ # return [self._vertices]
152
+
153
+ def contains(self, coords: ndarray) -> bool:
154
+ """
155
+ Check if the given coordinates are inside the (possibly inverted) polygon.
156
+
157
+ Parameters:
158
+ coords (list or tuple): Coordinates (x, y).
159
+
160
+ Returns:
161
+ bool: True if the point is inside the object, False otherwise.
162
+ """
163
+ return self._apply_inversion(self._contains_polygon(coords))
164
+
165
+ def contains_points(self, points: ndarray) -> ndarray:
166
+ """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside."""
167
+ points = xp.asarray(_component_major(points, 2), dtype=float)
168
+ if points.shape[1] == 0: # the compiled kernels reject empty arrays
169
+ return xp.zeros(0, dtype=bool)
170
+ vertices = xp.kernels.as_kernel_array(self._vertices, like=points, dtype=float)
171
+ lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None]
172
+ upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None]
173
+ inside = xp.empty(points.shape[1], dtype=bool)
174
+ contains_polygon_2d(
175
+ xp.kernels.as_kernel_array(points, like=points, dtype=float),
176
+ inside,
177
+ vertices,
178
+ 0,
179
+ )
180
+ inside &= (points[0] >= lower[0]) & (points[0] < upper[0])
181
+ inside &= (points[1] >= lower[1]) & (points[1] < upper[1])
182
+ return ~inside if self.inverted else inside
@@ -0,0 +1,67 @@
1
+ """Open polylines: curves of zero thickness, such as thin plates and wires."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cunumpy as xp
6
+ import numpy as np
7
+ from numpy import ndarray
8
+
9
+ from simgeometry._types import ConfigValue
10
+ from simgeometry.shapes import Shape2D, _component_major
11
+ from simgeometry.utils import vertices_to_bounds
12
+
13
+
14
+ class Polyline(Shape2D):
15
+ """An open curve through ``vertices``: a thin plate, wire or grid of zero thickness.
16
+
17
+ It encloses no area, so :meth:`contains` is always False and it cannot be
18
+ inverted or combined in Boolean shapes. Its geometry is its outline, an open
19
+ polyline from the first vertex to the last (``closed`` is False), which
20
+ :meth:`~simgeometry.shapes.Shape2D.boundary_panels` and ``crossings()`` use
21
+ directly.
22
+ """
23
+
24
+ exact_outline = True
25
+ closed = False
26
+
27
+ def __init__(self, vertices: ndarray, bc: ConfigValue = None) -> None:
28
+ """
29
+ Initialize a polyline in 2D space.
30
+
31
+ Parameters:
32
+ vertices (list or tuple): At least two ``(x, y)`` vertices, in order along the
33
+ curve; consecutive vertices must differ.
34
+ bc (optional): Boundary condition information.
35
+ """
36
+ points = np.asarray(xp.to_numpy(xp.asarray(vertices, dtype=float)), dtype=float)
37
+ if points.ndim != 2 or points.shape[1] != 2 or points.shape[0] < 2:
38
+ raise ValueError("A polyline needs at least two (x, y) vertices")
39
+ if np.any(np.hypot(*np.diff(points, axis=0).T) == 0):
40
+ raise ValueError("Consecutive polyline vertices must differ")
41
+ self._vertices = points
42
+ self._bc = bc
43
+ lower_bound, upper_bound = vertices_to_bounds(points)
44
+ super().__init__(lower_bound=lower_bound, upper_bound=upper_bound)
45
+
46
+ @property
47
+ def vertices(self) -> ndarray:
48
+ """The ``(n, 2)`` vertices, one row per point, as given."""
49
+ return self._vertices
50
+
51
+ @property
52
+ def length(self) -> float:
53
+ """The length of the curve."""
54
+ return float(np.hypot(*np.diff(self._vertices, axis=0).T).sum())
55
+
56
+ def _corner_loops(self) -> list[np.ndarray]:
57
+ """The vertices as one open ``(2, n)`` polyline."""
58
+ return [self._vertices.T.copy()]
59
+
60
+ def contains(self, coords: tuple[float, float]) -> bool:
61
+ """Always False: a curve encloses no area."""
62
+ return False
63
+
64
+ def contains_points(self, points: ndarray) -> ndarray:
65
+ """An all-False mask of the ``(2, n)`` points: a curve encloses no area."""
66
+ points = _component_major(points, 2)
67
+ return xp.zeros(points.shape[1], dtype=bool)