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.
- simgeometry/__init__.py +38 -0
- simgeometry/_bounds.py +16 -0
- simgeometry/_normalize.py +23 -0
- simgeometry/_particle_boundaries.py +37 -0
- simgeometry/_types.py +22 -0
- simgeometry/api.py +247 -0
- simgeometry/boundingbox.py +231 -0
- simgeometry/collection.py +65 -0
- simgeometry/crossings.py +319 -0
- simgeometry/cut_edges.py +309 -0
- simgeometry/cylindrical.py +43 -0
- simgeometry/domain_decomposition.py +496 -0
- simgeometry/evaluation.py +105 -0
- simgeometry/fractions.py +68 -0
- simgeometry/grid.py +238 -0
- simgeometry/kernels/__init__.py +1 -0
- simgeometry/kernels/_build.py +53 -0
- simgeometry/kernels/_metal.py +41 -0
- simgeometry/kernels/compile.py +24 -0
- simgeometry/kernels/contains_ball/__init__.py +12 -0
- simgeometry/kernels/contains_ball/contains_ball_cuda.cu +27 -0
- simgeometry/kernels/contains_ball/contains_ball_metal.py +53 -0
- simgeometry/kernels/contains_ball/contains_ball_numpy.py +23 -0
- simgeometry/kernels/contains_ball/contains_ball_pyccel.py +31 -0
- simgeometry/kernels/contains_box/__init__.py +12 -0
- simgeometry/kernels/contains_box/contains_box_cuda.cu +26 -0
- simgeometry/kernels/contains_box/contains_box_metal.py +50 -0
- simgeometry/kernels/contains_box/contains_box_numpy.py +24 -0
- simgeometry/kernels/contains_box/contains_box_pyccel.py +32 -0
- simgeometry/kernels/contains_polygon_2d/__init__.py +12 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_cuda.cu +35 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_metal.py +58 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_numpy.py +28 -0
- simgeometry/kernels/contains_polygon_2d/contains_polygon_2d_pyccel.py +40 -0
- simgeometry/mesh.py +147 -0
- simgeometry/objects/__init__.py +35 -0
- simgeometry/objects/aperture2d.py +183 -0
- simgeometry/objects/aperture3d.py +361 -0
- simgeometry/objects/circle.py +138 -0
- simgeometry/objects/cuboid.py +173 -0
- simgeometry/objects/function_object.py +214 -0
- simgeometry/objects/line.py +127 -0
- simgeometry/objects/polygon.py +182 -0
- simgeometry/objects/polyline.py +67 -0
- simgeometry/objects/rectangle.py +130 -0
- simgeometry/objects/sphere.py +181 -0
- simgeometry/objects/triangle.py +148 -0
- simgeometry/operations.py +386 -0
- simgeometry/panels.py +80 -0
- simgeometry/plotting.py +179 -0
- simgeometry/py.typed +0 -0
- simgeometry/queries.py +445 -0
- simgeometry/sampling.py +75 -0
- simgeometry/shapes.py +402 -0
- simgeometry/simulation_domain.py +277 -0
- simgeometry/utils.py +24 -0
- simgeometry/visualization.py +381 -0
- simgeometry-0.1.0.dist-info/METADATA +128 -0
- simgeometry-0.1.0.dist-info/RECORD +62 -0
- simgeometry-0.1.0.dist-info/WHEEL +5 -0
- simgeometry-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
- 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
|