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,35 @@
1
+ // The CUDA version (GPU) of the contains_polygon_2d kernel;
2
+ // contains_polygon_2d_pyccel.py next to this file is the Pyccel version (CPU),
3
+ // with the same arguments. Loaded by picnic.kernels.catalog: one thread per
4
+ // marker (positions are (2, N)), even-odd ray casting over the (n, 2) vertices.
5
+
6
+ #include "cunumpy/array_view.cuh"
7
+
8
+ extern "C" __global__
9
+ void contains_polygon_2d(
10
+ Array2D<double> positions,
11
+ Array1D<char> mask,
12
+ Array2D<double> vertices,
13
+ int inverted
14
+ ) {
15
+ const long long marker = blockDim.x * (long long)blockIdx.x + threadIdx.x;
16
+ if (marker >= positions.shape[1]) return;
17
+ const double x = positions(0, marker);
18
+ const double y = positions(1, marker);
19
+ const long long n = vertices.shape[0];
20
+ bool inside = false;
21
+ double xj = vertices(n - 1, 0);
22
+ double yj = vertices(n - 1, 1);
23
+ for (long long i = 0; i < n; ++i) {
24
+ const double xi = vertices(i, 0);
25
+ const double yi = vertices(i, 1);
26
+ if ((yi > y) != (yj > y)
27
+ && x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-16) + xi) {
28
+ inside = !inside;
29
+ }
30
+ xj = xi;
31
+ yj = yi;
32
+ }
33
+ if (inverted == 1) inside = !inside;
34
+ mask(marker) = inside ? 1 : 0;
35
+ }
@@ -0,0 +1,58 @@
1
+ """The Metal version (Apple-silicon GPU, float32) of the ``contains_polygon_2d`` kernel.
2
+
3
+ ``contains_polygon_2d_pyccel.py`` next to this file is the CPU version with the same
4
+ arguments. One thread per marker (``positions`` is ``(2, N)``); see :mod:`simgeometry.kernels._metal`. The test is
5
+ done in float32, so a marker within about 1e-7 (relative) of the boundary may be
6
+ classified differently from the CPU version.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from simgeometry._types import Array
14
+ from simgeometry.kernels._metal import make_kernel
15
+
16
+ _SOURCE = """
17
+ uint marker = thread_position_in_grid.x;
18
+ uint n_markers = positions_shape[1];
19
+ if (marker >= n_markers) return;
20
+ float x = positions[marker];
21
+ float y = positions[n_markers + marker];
22
+ int n = (int)vertices_shape[0];
23
+ bool inside = false;
24
+ float xj = vertices[2 * (n - 1)];
25
+ float yj = vertices[2 * (n - 1) + 1];
26
+ for (int i = 0; i < n; ++i) {
27
+ float xi = vertices[2 * i];
28
+ float yi = vertices[2 * i + 1];
29
+ if ((yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-16f) + xi) {
30
+ inside = !inside;
31
+ }
32
+ xj = xi;
33
+ yj = yi;
34
+ }
35
+ if (flags[0] == 1) inside = !inside;
36
+ inside_flag[marker] = inside ? 1 : 0;
37
+ """
38
+
39
+ _KERNEL = make_kernel(
40
+ "simgeometry_contains_polygon_2d",
41
+ _SOURCE,
42
+ inputs=["positions", "vertices", "flags"],
43
+ outputs=["inside_flag"],
44
+ )
45
+
46
+
47
+ def contains_polygon_2d(
48
+ positions: Array,
49
+ mask: Array,
50
+ vertices: Array,
51
+ inverted: int,
52
+ ) -> None:
53
+ """Set ``mask[marker]`` to whether the marker lies inside the polygon."""
54
+ if not positions.shape[1]:
55
+ return
56
+ flags = np.zeros(positions.shape[1], dtype=np.uint8)
57
+ _KERNEL(positions, vertices, np.array([inverted], dtype=np.int32), out=flags)
58
+ mask[:] = flags.astype(bool)
@@ -0,0 +1,28 @@
1
+ """The NumPy version of the ``contains_polygon_2d`` kernel, run when Pyccel is unavailable.
2
+
3
+ Same arguments and result as ``contains_polygon_2d_pyccel.py``, vectorized over
4
+ the markers (a loop over the vertices).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+
12
+ def contains_polygon_2d(
13
+ positions: np.ndarray,
14
+ mask: np.ndarray,
15
+ vertices: np.ndarray,
16
+ inverted: int,
17
+ ) -> None:
18
+ """Set ``mask`` to whether each marker lies inside the polygon."""
19
+ x = positions[0]
20
+ y = positions[1]
21
+ inside = np.zeros(positions.shape[1], dtype=bool)
22
+ xj, yj = vertices[-1]
23
+ for xi, yi in vertices:
24
+ inside ^= ((yi > y) != (yj > y)) & (
25
+ x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-16) + xi
26
+ )
27
+ xj, yj = xi, yi
28
+ mask[...] = ~inside if inverted == 1 else inside
@@ -0,0 +1,40 @@
1
+ """Mask of the markers inside a 2D polygon, by even-odd ray casting.
2
+
3
+ The Pyccel version (CPU) of the ``contains_polygon_2d`` kernel;
4
+ ``contains_polygon_2d_cuda.cu`` next to this file is the CUDA version (GPU), with
5
+ the same arguments. ``positions`` is ``(2, N)`` (component-major), ``vertices`` is (n, 2), the closing edge implied. The same
6
+ crossing rule as ``Markers._polygon_contains_points``. With ``inverted == 1``
7
+ the mask is negated.
8
+ """
9
+
10
+
11
+ def contains_polygon_2d(
12
+ positions: "float[:,:]",
13
+ mask: "bool[:]",
14
+ vertices: "float[:,:]",
15
+ inverted: int,
16
+ ) -> None:
17
+ """Set ``mask[marker]`` to whether the marker lies inside the polygon."""
18
+ num_markers = positions.shape[1]
19
+ num_vertices = vertices.shape[0]
20
+ # fmt: off
21
+ #$ omp parallel for private(x, y, inside, xj, yj, i, xi, yi) schedule(dynamic, 2048)
22
+ # fmt: on
23
+ for marker in range(num_markers):
24
+ x = positions[0, marker]
25
+ y = positions[1, marker]
26
+ inside = False
27
+ xj = vertices[num_vertices - 1, 0]
28
+ yj = vertices[num_vertices - 1, 1]
29
+ for i in range(num_vertices):
30
+ xi = vertices[i, 0]
31
+ yi = vertices[i, 1]
32
+ if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (
33
+ (yj - yi) + 1e-16
34
+ ) + xi:
35
+ inside = not inside
36
+ xj = xi
37
+ yj = yi
38
+ if inverted == 1:
39
+ inside = not inside
40
+ mask[marker] = inside
simgeometry/mesh.py ADDED
@@ -0,0 +1,147 @@
1
+ """Sampled surface meshes and portable export formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from simgeometry.evaluation import sampling_axes
11
+ from simgeometry.operations import mask
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SurfaceMesh:
16
+ """Vertices (n, ndim), segments/triangles, and sampling spacing.
17
+
18
+ Empty cells mean no boundary was resolved in the requested region.
19
+ ``spacing`` describes the sampling, not a guaranteed error bound. Surfaces
20
+ touching the sampling bounds can be open. Coordinates are physical.
21
+ """
22
+
23
+ vertices: np.ndarray
24
+ cells: np.ndarray
25
+ spacing: tuple[float, ...]
26
+
27
+ @property
28
+ def centres(self):
29
+ """Segment/triangle centres, shape (ncells, ndim)."""
30
+ return self.vertices[self.cells].mean(axis=1)
31
+
32
+ @property
33
+ def normals(self):
34
+ """Outward unit normals of mesh segments/triangles (faceted approximation)."""
35
+ cells = self.vertices[self.cells]
36
+ edge = cells[:, 1] - cells[:, 0]
37
+ normal = (
38
+ np.column_stack((edge[:, 1], -edge[:, 0]))
39
+ if self.vertices.shape[1] == 2
40
+ else np.cross(edge, cells[:, 2] - cells[:, 0])
41
+ )
42
+ length = np.linalg.norm(normal, axis=1)
43
+ return normal / np.where(length > 0, length, np.nan)[:, None]
44
+
45
+ def save(self, path):
46
+ """Write .npz, .obj (segments or triangles), or ASCII .stl (3D)."""
47
+ path = Path(path)
48
+ suffix = path.suffix.lower()
49
+ if suffix == ".npz":
50
+ np.savez(
51
+ path, vertices=self.vertices, cells=self.cells, spacing=self.spacing
52
+ )
53
+ elif suffix == ".obj":
54
+ vertices = np.pad(self.vertices, ((0, 0), (0, 3 - self.vertices.shape[1])))
55
+ lines = ["# simgeometry sampled surface\n"]
56
+ lines += ["v " + " ".join(f"{v:.17g}" for v in p) + "\n" for p in vertices]
57
+ prefix = "f" if self.cells.shape[1] == 3 else "l"
58
+ lines += [
59
+ prefix + " " + " ".join(str(int(i) + 1) for i in cell) + "\n"
60
+ for cell in self.cells
61
+ ]
62
+ path.write_text("".join(lines))
63
+ elif suffix == ".stl" and self.vertices.shape[1] == 3:
64
+ lines = ["solid simgeometry\n"]
65
+ for triangle in self.vertices[self.cells]:
66
+ normal = np.cross(triangle[1] - triangle[0], triangle[2] - triangle[0])
67
+ length = np.linalg.norm(normal)
68
+ normal = normal / length if length else normal
69
+ lines.append(
70
+ "facet normal "
71
+ + " ".join(f"{v:.17g}" for v in normal)
72
+ + "\nouter loop\n"
73
+ )
74
+ lines += [
75
+ "vertex " + " ".join(f"{v:.17g}" for v in p) + "\n"
76
+ for p in triangle
77
+ ]
78
+ lines.append("endloop\nendfacet\n")
79
+ lines.append("endsolid simgeometry\n")
80
+ path.write_text("".join(lines))
81
+ else:
82
+ raise ValueError("Use .npz, .obj, or .stl for 3D triangles")
83
+ return path
84
+
85
+
86
+ def to_mesh(shape, *, resolution=64, bounds=None):
87
+ if shape.ndim not in (2, 3):
88
+ raise ValueError("Surface meshing requires 2D or 3D geometry")
89
+ try:
90
+ from skimage.measure import find_contours, marching_cubes
91
+ except ImportError as exc:
92
+ raise ImportError("Surface meshing requires simgeometry[mesh]") from exc
93
+ from simgeometry.queries import BALLS, BOXES
94
+
95
+ if bounds is None:
96
+ lower, upper = np.asarray(shape.lower_bound), np.asarray(shape.upper_bound)
97
+ pad = (upper - lower) * 0.05
98
+ bounds = (lower - pad, upper + pad)
99
+ axes = sampling_axes(shape, resolution, bounds)
100
+ dimensions = tuple(len(a) for a in axes)
101
+ spacing = tuple(float(a[1] - a[0]) for a in axes)
102
+ points = np.stack([a.ravel() for a in np.meshgrid(*axes, indexing="ij")])
103
+ # Continuous implicit functions improve primitive surface placement; generic
104
+ # membership masks have an uncertainty of roughly a sampling cell.
105
+ if isinstance(shape, BALLS):
106
+ values = shape.radius - np.linalg.norm(
107
+ points - np.asarray(shape.midpoint)[:, None], axis=0
108
+ )
109
+ elif isinstance(shape, BOXES):
110
+ centre = (np.asarray(shape.lower_bound) + shape.upper_bound) / 2
111
+ half = (np.asarray(shape.upper_bound) - shape.lower_bound) / 2
112
+ q = abs(points - centre[:, None]) - half[:, None]
113
+ values = -(
114
+ np.linalg.norm(np.maximum(q, 0), axis=0) + np.minimum(np.max(q, axis=0), 0)
115
+ )
116
+ else:
117
+ values = mask(shape, points).astype(float) - 0.5
118
+ if isinstance(shape, (*BALLS, *BOXES)) and shape.inverted:
119
+ values = -values
120
+ values = values.reshape(dimensions)
121
+ if not (values.min() < 0 < values.max()):
122
+ return SurfaceMesh(
123
+ np.empty((0, shape.ndim)), np.empty((0, shape.ndim), dtype=int), spacing
124
+ )
125
+ if shape.ndim == 2:
126
+ vertices, cells = [], []
127
+ offset = 0
128
+ for contour in find_contours(values, 0, positive_orientation="high"):
129
+ vertices.extend(
130
+ contour * np.asarray(spacing) + np.array([a[0] for a in axes])
131
+ )
132
+ cells.extend((offset + i, offset + i + 1) for i in range(len(contour) - 1))
133
+ offset += len(contour)
134
+ return SurfaceMesh(
135
+ np.asarray(vertices).reshape(-1, 2),
136
+ np.asarray(cells, dtype=int).reshape(-1, 2),
137
+ spacing,
138
+ )
139
+ vertices, faces, _, _ = marching_cubes(
140
+ values,
141
+ level=0,
142
+ spacing=spacing,
143
+ allow_degenerate=False,
144
+ gradient_direction="ascent",
145
+ )
146
+ vertices += np.array([a[0] for a in axes])
147
+ return SurfaceMesh(vertices, faces, spacing)
@@ -0,0 +1,35 @@
1
+ """Package exports for geometric object definitions."""
2
+
3
+ # 1D objects
4
+ # 2D objects
5
+ from simgeometry.objects.aperture2d import Aperture2D
6
+ from simgeometry.objects.aperture3d import Aperture3D
7
+ from simgeometry.objects.circle import Circle
8
+ from simgeometry.objects.cuboid import Cuboid
9
+ from simgeometry.objects.function_object import FunctionObject2D, FunctionObject3D
10
+ from simgeometry.objects.line import Line
11
+ from simgeometry.objects.polygon import Polygon
12
+ from simgeometry.objects.polyline import Polyline
13
+ from simgeometry.objects.rectangle import Rectangle
14
+
15
+ # 3D objects
16
+ from simgeometry.objects.sphere import Sphere
17
+ from simgeometry.objects.triangle import Triangle
18
+
19
+ __all__ = [
20
+ # 2D objects
21
+ "Aperture2D",
22
+ "Aperture3D",
23
+ "Circle",
24
+ "Cuboid",
25
+ "FunctionObject2D",
26
+ "FunctionObject3D",
27
+ # 1D objects
28
+ "Line",
29
+ "Polygon",
30
+ "Polyline",
31
+ "Rectangle",
32
+ # 3D objects
33
+ "Sphere",
34
+ "Triangle",
35
+ ]
@@ -0,0 +1,183 @@
1
+ """Two-dimensional aperture helpers for geometric object definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from numpy import ndarray
7
+
8
+ from simgeometry._types import ConfigValue
9
+ from simgeometry.objects.polygon import Polygon
10
+ from simgeometry.shapes import Shape2D
11
+
12
+
13
+ class Aperture2D(Shape2D):
14
+ """Two plates mirrored about a beam axis: a slit, or the 2D cut of a round aperture.
15
+
16
+ The beam runs along coordinate ``axis`` (0 for x, 1 for y), on the centre line
17
+ where the other coordinate equals ``position``. ``vertices`` are the plate profile
18
+ as ``(axial, radial)`` pairs: the coordinate along the beam and the distance from
19
+ the centre line. One plate is the polygon at ``position + radial``, the other its
20
+ mirror image at ``position - radial``. :meth:`slit` builds the common rectangular
21
+ plates.
22
+ """
23
+
24
+ exact_outline = True
25
+
26
+ def __init__(
27
+ self,
28
+ axis: int,
29
+ position: float,
30
+ vertices: tuple[tuple[float, float], ...],
31
+ bc: ConfigValue = None,
32
+ inverted: bool = False,
33
+ ) -> None:
34
+ """
35
+ Initialize a 2D aperture object.
36
+
37
+ Parameters:
38
+ axis (int): The beam axis, 0 (along x) or 1 (along y).
39
+ position (float): The centre line: the coordinate across the beam,
40
+ ``y`` for ``axis=0`` and ``x`` for ``axis=1``.
41
+ vertices (list or tuple): The plate profile as ``(axial, radial)``
42
+ vertices.
43
+ bc (optional): Boundary condition information.
44
+ inverted (bool, optional): Cover everything except the two plates.
45
+ """
46
+
47
+ self.axis = axis
48
+ self._position = position
49
+ self.vertices = vertices
50
+ self._bc = bc
51
+ self._inverted = bool(inverted)
52
+
53
+ lower, upper = self._plate_vertices()
54
+ self._lower_polygon = Polygon(vertices=lower, bc=bc)
55
+ self._upper_polygon = Polygon(vertices=upper, bc=bc)
56
+
57
+ super().__init__(
58
+ lower_bound=tuple(
59
+ min(lower, upper)
60
+ for lower, upper in zip(
61
+ self._lower_polygon.lower_bound,
62
+ self._upper_polygon.lower_bound,
63
+ )
64
+ ),
65
+ upper_bound=tuple(
66
+ max(lower, upper)
67
+ for lower, upper in zip(
68
+ self._lower_polygon.upper_bound,
69
+ self._upper_polygon.upper_bound,
70
+ )
71
+ ),
72
+ )
73
+
74
+ @classmethod
75
+ def slit(
76
+ cls,
77
+ axial_position: float,
78
+ half_width: float,
79
+ thickness: float,
80
+ length: float,
81
+ *,
82
+ axis: int = 0,
83
+ position: float = 0.0,
84
+ bc: ConfigValue = None,
85
+ inverted: bool = False,
86
+ ) -> Aperture2D:
87
+ """A slit: two rectangular plates with a gap of ``2 * half_width`` between them.
88
+
89
+ The plates are ``thickness`` thick along the beam, centred on
90
+ ``axial_position``, and extend ``length`` outward from the gap.
91
+ """
92
+ if not (half_width >= 0 and thickness > 0 and length > 0):
93
+ raise ValueError(
94
+ "A slit needs half_width >= 0 and positive thickness and length"
95
+ )
96
+ front, back = axial_position - thickness / 2, axial_position + thickness / 2
97
+ tip, outer = half_width, half_width + length
98
+ return cls(
99
+ axis=axis,
100
+ position=position,
101
+ vertices=((front, tip), (back, tip), (back, outer), (front, outer)),
102
+ bc=bc,
103
+ inverted=inverted,
104
+ )
105
+
106
+ @property
107
+ def axis(self) -> int:
108
+ """Return the beam axis."""
109
+ return self._axis
110
+
111
+ @axis.setter
112
+ def axis(self, value: int) -> None:
113
+ """Set the beam axis."""
114
+ assert value in [0, 1], "Axis must be one of 0 or 1."
115
+ self._axis = value
116
+
117
+ def _plate_vertices(self) -> tuple[ndarray, ndarray]:
118
+ """The ``(n, 2)`` vertices of the plate below and the plate above the centre line."""
119
+ profile = np.asarray(self.vertices, dtype=float)
120
+ axial, radial = profile[:, 0], profile[:, 1]
121
+ plates = []
122
+ for across in (self.position - radial, self.position + radial):
123
+ points = (axial, across) if self.axis == 0 else (across, axial)
124
+ plates.append(np.stack(points, axis=1))
125
+ return plates[0], plates[1]
126
+
127
+ def _corner_loops(self) -> list[np.ndarray]:
128
+ """One loop per plate: the lower plate, then the upper one."""
129
+ return [
130
+ loop
131
+ for plate in (self._lower_polygon, self._upper_polygon)
132
+ for loop in plate._corner_loops()
133
+ ]
134
+
135
+ def contains(self, coords: ndarray) -> bool:
136
+ """
137
+ Check if the given coordinates are inside one of the plates.
138
+
139
+ Parameters:
140
+ coords (list or tuple): Coordinates (x, y).
141
+
142
+ Returns:
143
+ bool: True if the point is inside a plate, False otherwise.
144
+ """
145
+
146
+ inside = self._lower_polygon.contains(coords) or self._upper_polygon.contains(
147
+ coords
148
+ )
149
+ return self._apply_inversion(inside)
150
+
151
+ def contains_points(self, points: ndarray) -> ndarray:
152
+ """Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside."""
153
+ inside = self._lower_polygon.contains_points(
154
+ points
155
+ ) | self._upper_polygon.contains_points(points)
156
+ return ~inside if self.inverted else inside
157
+
158
+ @property
159
+ def vertices(self) -> tuple[tuple[float, float], ...]:
160
+ """Return the plate profile as ``(axial, radial)`` vertices."""
161
+ return self._vertices
162
+
163
+ @vertices.setter
164
+ def vertices(self, value: tuple) -> None:
165
+ """Set the vertices."""
166
+ for vertex in value:
167
+ assert len(vertex) == 2, "vertices must be a tuple of length 2."
168
+ self._vertices = value
169
+
170
+ @property
171
+ def rvec(self) -> list:
172
+ """Return the radial aperture coordinate vector."""
173
+ return [v[1] for v in self.vertices]
174
+
175
+ @property
176
+ def zvec(self) -> list:
177
+ """Return the axial aperture coordinate vector."""
178
+ return [v[0] for v in self.vertices]
179
+
180
+ @property
181
+ def position(self) -> float:
182
+ """Return the centre line's coordinate across the beam."""
183
+ return self._position