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,361 @@
|
|
|
1
|
+
"""Three-dimensional aperture helpers for geometric object definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
|
|
7
|
+
from simgeometry._types import Array, ConfigValue
|
|
8
|
+
from simgeometry.shapes import Shape3D, _component_major
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Aperture3D(Shape3D):
|
|
12
|
+
"""Represent a three-dimensional aperture embedded in a simulation domain."""
|
|
13
|
+
|
|
14
|
+
#: Even when inverted, the aperture is solid only inside its bounding box
|
|
15
|
+
#: (``contains`` is False outside it), so markers far from the box can be
|
|
16
|
+
#: skipped before the shape test.
|
|
17
|
+
solid_within_bounding_box = True
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
axis: str,
|
|
22
|
+
center_coords: tuple,
|
|
23
|
+
vertices: tuple[tuple[float, float], ...],
|
|
24
|
+
lower_bound: tuple,
|
|
25
|
+
upper_bound: tuple,
|
|
26
|
+
bc: ConfigValue = None,
|
|
27
|
+
inverted: bool = True,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Initialize a 3D aperture object.
|
|
31
|
+
|
|
32
|
+
Parameters:
|
|
33
|
+
axis (str): Axis along which the aperture extends ('x', 'y', or 'z').
|
|
34
|
+
center_coords (list or tuple): Center coordinate pair for the aperture's center axis.
|
|
35
|
+
vertices (list of lists): List of lists where each element is [coordinate_along_axis, radial_distance].
|
|
36
|
+
bounds (list of numpy arrays): Bounds of the aperture in 3D space, defined as [b0, b1].
|
|
37
|
+
bc (optional): Boundary condition information.
|
|
38
|
+
inverted (bool, optional): Defaults to True so apertures model solid
|
|
39
|
+
material around a central opening unless explicitly disabled.
|
|
40
|
+
"""
|
|
41
|
+
# print(f"{axis = }")
|
|
42
|
+
assert axis in ["x", "y", "z"], "Axis must be one of 'x', 'y', or 'z'."
|
|
43
|
+
assert len(center_coords) == 2, (
|
|
44
|
+
"Center coordinates must be a pair (2D coordinates)."
|
|
45
|
+
)
|
|
46
|
+
assert len(lower_bound) == 3
|
|
47
|
+
assert len(upper_bound) == 3
|
|
48
|
+
assert all(len(vertex) == 2 for vertex in vertices), (
|
|
49
|
+
"Each vertex must be a list [coordinate_along_axis, radial_distance]."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self.axis = axis
|
|
53
|
+
self.center_coords = xp.array(center_coords, dtype=float)
|
|
54
|
+
self.vertices = xp.array(vertices)
|
|
55
|
+
self._bc = bc
|
|
56
|
+
self._inverted = bool(inverted)
|
|
57
|
+
|
|
58
|
+
# Store the minimum and maximum radial distance for fast checks
|
|
59
|
+
self._rmin_squared = float(xp.min(self.vertices[:, 1])) ** 2
|
|
60
|
+
|
|
61
|
+
# Determine the axis index
|
|
62
|
+
self.axis_index = {"x": 0, "y": 1, "z": 2}[axis]
|
|
63
|
+
self._perpendicular_indices = [i for i in range(3) if i != self.axis_index]
|
|
64
|
+
|
|
65
|
+
# Set the bounds directly
|
|
66
|
+
super().__init__(
|
|
67
|
+
lower_bound=lower_bound,
|
|
68
|
+
upper_bound=upper_bound,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def mirror(self, axis: int, position: float) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Mirror the aperture across a specified axis at a given position.
|
|
74
|
+
|
|
75
|
+
Parameters:
|
|
76
|
+
axis (int): Axis index (0 for x-axis, 1 for y-axis, 2 for z-axis).
|
|
77
|
+
position (float): Position along the axis to mirror.
|
|
78
|
+
"""
|
|
79
|
+
if axis < 0 or axis > 2:
|
|
80
|
+
raise ValueError("Axis must be 0 (x-axis), 1 (y-axis), or 2 (z-axis).")
|
|
81
|
+
|
|
82
|
+
# Mirror the center_coords along the specified axis if applicable
|
|
83
|
+
if axis in self._perpendicular_indices:
|
|
84
|
+
index_in_center_coords = 0 if self._perpendicular_indices[0] == axis else 1
|
|
85
|
+
self.center_coords[index_in_center_coords] = (
|
|
86
|
+
2 * position - self.center_coords[index_in_center_coords]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Mirror the vertices along the aperture's axis if applicable
|
|
90
|
+
if axis == self.axis_index:
|
|
91
|
+
self.vertices[:, 0] = 2 * position - self.vertices[:, 0]
|
|
92
|
+
|
|
93
|
+
lower_bound = self.lower_bound
|
|
94
|
+
upper_bound = self.upper_bound
|
|
95
|
+
|
|
96
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
97
|
+
|
|
98
|
+
def copy(self) -> Aperture3D:
|
|
99
|
+
"""
|
|
100
|
+
Create a copy of the aperture.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Aperture3D: A new instance of Aperture3D with the same properties.
|
|
104
|
+
"""
|
|
105
|
+
return Aperture3D(
|
|
106
|
+
axis=self.axis,
|
|
107
|
+
center_coords=self.center_coords.copy(),
|
|
108
|
+
vertices=self.vertices.copy(),
|
|
109
|
+
lower_bound=self.lower_bound,
|
|
110
|
+
upper_bound=self.upper_bound,
|
|
111
|
+
bc=self._bc,
|
|
112
|
+
inverted=self._inverted,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def translate(self, offset: tuple[float, float, float]) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Translate the aperture by the given offset.
|
|
118
|
+
|
|
119
|
+
Parameters:
|
|
120
|
+
offset (tuple): (dx, dy, dz) translation vector.
|
|
121
|
+
"""
|
|
122
|
+
offset_vec = xp.asarray(offset)
|
|
123
|
+
|
|
124
|
+
# Apply translation to center_coords
|
|
125
|
+
for i, axis_idx in enumerate(self._perpendicular_indices):
|
|
126
|
+
self.center_coords[i] += offset_vec[axis_idx]
|
|
127
|
+
|
|
128
|
+
# Apply translation to vertices along the aperture's axis
|
|
129
|
+
self.vertices[:, 0] += offset_vec[self.axis_index]
|
|
130
|
+
|
|
131
|
+
lower_bound = self.lower_bound
|
|
132
|
+
upper_bound = self.upper_bound
|
|
133
|
+
|
|
134
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
135
|
+
|
|
136
|
+
def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue:
|
|
137
|
+
"""
|
|
138
|
+
Get the vertices of a slice of the aperture along a given axis.
|
|
139
|
+
|
|
140
|
+
Parameters:
|
|
141
|
+
slice_axis (int): Axis along which to slice (0 for x, 1 for y, 2 for z).
|
|
142
|
+
slice_position (float): Position along the slice_axis where the slice is taken.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
vertices (ndarray): Array of vertices defining the slice shape.
|
|
146
|
+
If the slice does not intersect the aperture, returns an empty array.
|
|
147
|
+
"""
|
|
148
|
+
assert 0 <= slice_axis < 3, "slice_axis must be 0, 1, or 2."
|
|
149
|
+
inverted = self.inverted
|
|
150
|
+
# Check if the slice_position is within the bounds along the slice_axis
|
|
151
|
+
if not (
|
|
152
|
+
self.lower_bound[slice_axis]
|
|
153
|
+
<= slice_position
|
|
154
|
+
<= self.upper_bound[slice_axis]
|
|
155
|
+
):
|
|
156
|
+
# The slice is outside the aperture's bounds
|
|
157
|
+
return xp.array([]), inverted
|
|
158
|
+
|
|
159
|
+
# Determine the axes indices
|
|
160
|
+
axis_aperture = self.axis_index
|
|
161
|
+
axes_perp_to_aperture = self._perpendicular_indices # list of two axes
|
|
162
|
+
|
|
163
|
+
# Check if slice_axis is equal to aperture's axis
|
|
164
|
+
if slice_axis == axis_aperture:
|
|
165
|
+
# Slice is perpendicular to the aperture's axis
|
|
166
|
+
# Interpolate the radial distance at the given position along the aperture's axis
|
|
167
|
+
axis_positions = self.vertices[:, 0]
|
|
168
|
+
radial_distances = self.vertices[:, 1]
|
|
169
|
+
|
|
170
|
+
# Check if the slice_position is within the vertices' range
|
|
171
|
+
if not (axis_positions[0] <= slice_position <= axis_positions[-1]):
|
|
172
|
+
# The slice_position is outside the defined vertices
|
|
173
|
+
return xp.array([]), inverted
|
|
174
|
+
|
|
175
|
+
# Interpolate radial distance
|
|
176
|
+
radial_distance = xp.interp(
|
|
177
|
+
xp.asarray(slice_position, dtype=float),
|
|
178
|
+
axis_positions,
|
|
179
|
+
radial_distances,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# If radial distance is zero or negative, return empty array
|
|
183
|
+
if radial_distance <= 0:
|
|
184
|
+
return xp.array([]), inverted
|
|
185
|
+
|
|
186
|
+
# Generate points along the circle
|
|
187
|
+
N = 100 # Number of points to approximate the circle
|
|
188
|
+
theta = xp.linspace(0, 2 * xp.pi, N)
|
|
189
|
+
|
|
190
|
+
# Get the center coordinates in the slicing plane
|
|
191
|
+
center1 = self.center_coords[0]
|
|
192
|
+
center2 = self.center_coords[1]
|
|
193
|
+
|
|
194
|
+
x = center1 + radial_distance * xp.cos(theta)
|
|
195
|
+
y = center2 + radial_distance * xp.sin(theta)
|
|
196
|
+
|
|
197
|
+
# Identify the perpendicular axes
|
|
198
|
+
_axis1, _axis2 = axes_perp_to_aperture
|
|
199
|
+
|
|
200
|
+
# Combine x and y into an array of vertices
|
|
201
|
+
vertices = xp.zeros((N, 2))
|
|
202
|
+
vertices[:, 0] = x
|
|
203
|
+
vertices[:, 1] = y
|
|
204
|
+
|
|
205
|
+
return xp.array(vertices), inverted
|
|
206
|
+
|
|
207
|
+
elif slice_axis in axes_perp_to_aperture:
|
|
208
|
+
# The slice axis is one of the axes perpendicular to the aperture's axis
|
|
209
|
+
# Find the index in center_coords corresponding to slice_axis
|
|
210
|
+
index_in_center_coords = axes_perp_to_aperture.index(slice_axis)
|
|
211
|
+
|
|
212
|
+
D = abs(self.center_coords[index_in_center_coords] - slice_position)
|
|
213
|
+
|
|
214
|
+
# The maximum radius along the aperture
|
|
215
|
+
r_max = xp.max(self.vertices[:, 1])
|
|
216
|
+
|
|
217
|
+
# If D > r_max, the plane does not intersect the aperture
|
|
218
|
+
if D > r_max:
|
|
219
|
+
return xp.array([]), inverted
|
|
220
|
+
|
|
221
|
+
# Generate positions along axis_aperture
|
|
222
|
+
s_values = xp.linspace(self.vertices[0, 0], self.vertices[-1, 0], num=100)
|
|
223
|
+
|
|
224
|
+
# Interpolate radial distances at s_values
|
|
225
|
+
r_values = xp.interp(s_values, self.vertices[:, 0], self.vertices[:, 1])
|
|
226
|
+
|
|
227
|
+
# For each s, check if D <= r(s)
|
|
228
|
+
valid_indices = xp.where(r_values >= D)[0]
|
|
229
|
+
|
|
230
|
+
if len(valid_indices) == 0:
|
|
231
|
+
# No intersection
|
|
232
|
+
return xp.array([]), inverted
|
|
233
|
+
|
|
234
|
+
# For valid s_values, compute r_perp = sqrt(r(s)^2 - D^2)
|
|
235
|
+
s_valid = s_values[valid_indices]
|
|
236
|
+
r_valid = r_values[valid_indices]
|
|
237
|
+
r_perp = xp.sqrt(r_valid**2 - D**2)
|
|
238
|
+
|
|
239
|
+
# The coordinate along the other axis (not slice_axis) in axes_perp_to_aperture
|
|
240
|
+
# axis_other = axes_perp_to_aperture[1 - index_in_center_coords]
|
|
241
|
+
index_other_in_center_coords = 1 - index_in_center_coords
|
|
242
|
+
coord_other_center = self.center_coords[index_other_in_center_coords]
|
|
243
|
+
|
|
244
|
+
# Create vertices along the perimeter of the shape
|
|
245
|
+
# First half of vertices (coord_other increasing)
|
|
246
|
+
vertices1 = xp.column_stack((s_valid, coord_other_center + r_perp))
|
|
247
|
+
|
|
248
|
+
# Second half of vertices (coord_other decreasing)
|
|
249
|
+
vertices2 = xp.column_stack(
|
|
250
|
+
(s_valid[::-1], coord_other_center - r_perp[::-1]),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Combine vertices
|
|
254
|
+
vertices = xp.vstack((vertices1, vertices2))
|
|
255
|
+
|
|
256
|
+
return vertices, inverted
|
|
257
|
+
|
|
258
|
+
else:
|
|
259
|
+
# The slice_axis is neither the aperture's axis nor one of the axes perpendicular to it
|
|
260
|
+
# In 3D, this should not happen, so return an empty array
|
|
261
|
+
return xp.array([]), inverted
|
|
262
|
+
|
|
263
|
+
def contains(self, coords: Array) -> bool:
|
|
264
|
+
"""
|
|
265
|
+
Check if the given coordinates are inside the aperture.
|
|
266
|
+
|
|
267
|
+
Parameters:
|
|
268
|
+
coords (list or tuple): 3D coordinates.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
bool: True if the coordinates are inside the aperture, False otherwise.
|
|
272
|
+
"""
|
|
273
|
+
coords = xp.array(coords)
|
|
274
|
+
|
|
275
|
+
# Early exit if the point is outside the bounding box
|
|
276
|
+
if not self.bounding_box.contains(coords):
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
# Calculate radial position squared to avoid taking square root
|
|
280
|
+
perp_coords = coords[self._perpendicular_indices] - self.center_coords
|
|
281
|
+
radial_position_squared = xp.dot(perp_coords, perp_coords)
|
|
282
|
+
|
|
283
|
+
# Early exit if the point is guaranteed to be inside the aperture profile.
|
|
284
|
+
if radial_position_squared < self._rmin_squared:
|
|
285
|
+
return not self.inverted
|
|
286
|
+
|
|
287
|
+
# Get the position along the primary axis
|
|
288
|
+
axis_position = coords[self.axis_index]
|
|
289
|
+
|
|
290
|
+
# Binary search to find the appropriate radial distance in vertices
|
|
291
|
+
index = xp.searchsorted(self.vertices[:, 0], axis_position, side="left")
|
|
292
|
+
# print(f"{index = }")
|
|
293
|
+
# If out of the vertices range, the point is outside
|
|
294
|
+
if index == 0:
|
|
295
|
+
radial_distance_squared = self.vertices[0, 1] ** 2
|
|
296
|
+
elif index >= len(self.vertices):
|
|
297
|
+
radial_distance_squared = self.vertices[-1, 1] ** 2
|
|
298
|
+
else:
|
|
299
|
+
# Linear interpolation for radial distance
|
|
300
|
+
t = (axis_position - self.vertices[index - 1, 0]) / (
|
|
301
|
+
self.vertices[index, 0] - self.vertices[index - 1, 0]
|
|
302
|
+
)
|
|
303
|
+
radial_distance_squared = (
|
|
304
|
+
(1 - t) * self.vertices[index - 1, 1] + t * self.vertices[index, 1]
|
|
305
|
+
) ** 2
|
|
306
|
+
|
|
307
|
+
# Check if the point is within the interpolated radial distance
|
|
308
|
+
inside = bool(radial_position_squared <= radial_distance_squared)
|
|
309
|
+
return (not inside) if self.inverted else inside
|
|
310
|
+
|
|
311
|
+
def contains_points(self, points: Array) -> Array:
|
|
312
|
+
"""Vectorized :meth:`contains`: a mask of the ``(3, n)`` points inside."""
|
|
313
|
+
points = _component_major(points, 3)
|
|
314
|
+
if points.shape[1] == 0:
|
|
315
|
+
return xp.zeros(0, dtype=bool)
|
|
316
|
+
lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None]
|
|
317
|
+
upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None]
|
|
318
|
+
# half-open, as BoundingBox.contains
|
|
319
|
+
in_box = xp.all((points >= lower) & (points < upper), axis=0)
|
|
320
|
+
perp = points[self._perpendicular_indices] - self.center_coords[:, None]
|
|
321
|
+
radial_squared = xp.sum(perp * perp, axis=0)
|
|
322
|
+
axis_position = points[self.axis_index]
|
|
323
|
+
vertices = self.vertices
|
|
324
|
+
if len(vertices) == 1:
|
|
325
|
+
radius = xp.full(points.shape[1], float(vertices[0, 1]))
|
|
326
|
+
inside = radial_squared <= radius**2
|
|
327
|
+
return in_box & (~inside if self.inverted else inside)
|
|
328
|
+
index = xp.searchsorted(vertices[:, 0], axis_position, side="left")
|
|
329
|
+
# beyond the vertices the first or last radius holds, as in contains
|
|
330
|
+
inner = xp.clip(index, 1, len(vertices) - 1)
|
|
331
|
+
x0, r0 = vertices[inner - 1, 0], vertices[inner - 1, 1]
|
|
332
|
+
x1, r1 = vertices[inner, 0], vertices[inner, 1]
|
|
333
|
+
t = (axis_position - x0) / (x1 - x0)
|
|
334
|
+
radius = xp.where(
|
|
335
|
+
index == 0,
|
|
336
|
+
vertices[0, 1],
|
|
337
|
+
xp.where(index >= len(vertices), vertices[-1, 1], (1 - t) * r0 + t * r1),
|
|
338
|
+
)
|
|
339
|
+
inside = (radial_squared < self._rmin_squared) | (radial_squared <= radius**2)
|
|
340
|
+
solid = ~inside if self.inverted else inside
|
|
341
|
+
return in_box & solid
|
|
342
|
+
|
|
343
|
+
def volume(self) -> None:
|
|
344
|
+
"""Declare the aperture volume interface."""
|
|
345
|
+
raise NotImplementedError("Volume calculation not implemented for Aperture3D.")
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def min_radius(self) -> float:
|
|
349
|
+
"""Return the smallest aperture radius."""
|
|
350
|
+
return xp.min(self.vertices[:, 1])
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def max_radius(self) -> float:
|
|
354
|
+
"""Return the largest aperture radius."""
|
|
355
|
+
return xp.max(self.vertices[:, 1])
|
|
356
|
+
|
|
357
|
+
def surface_area(self) -> None:
|
|
358
|
+
"""Declare the aperture surface-area interface."""
|
|
359
|
+
raise NotImplementedError(
|
|
360
|
+
"Surface area calculation not implemented for Aperture3D.",
|
|
361
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Circle helpers for geometric object definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from simgeometry._types import Array, BoundsLike, ConfigValue
|
|
9
|
+
from simgeometry.shapes import Shape2D, Spacing, _component_major, _distribute
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Circle(Shape2D):
|
|
13
|
+
"""Represent a circular geometry object."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
midpoint: BoundsLike,
|
|
18
|
+
radius: float,
|
|
19
|
+
bc: ConfigValue = None,
|
|
20
|
+
inverted: bool = False,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Initialize a circle in 2D space.
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
midpoint (list or tuple): Coordinates of the midpoint of the circle.
|
|
27
|
+
radius (float): Radius of the circle.
|
|
28
|
+
bc (optional): Boundary condition information.
|
|
29
|
+
inverted (bool, optional): Cover everything except the circle.
|
|
30
|
+
"""
|
|
31
|
+
assert len(midpoint) == 2, "Midpoint must have two coordinates."
|
|
32
|
+
assert isinstance(radius, (int, float)) and radius > 0, (
|
|
33
|
+
"Radius must be a positive number."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self.midpoint = list(midpoint)
|
|
37
|
+
self.radius = radius
|
|
38
|
+
self._bc = bc
|
|
39
|
+
self._inverted = bool(inverted)
|
|
40
|
+
|
|
41
|
+
# Calculate bounds based on the midpoint and radius
|
|
42
|
+
lower_bound = tuple(x - self.radius for x in self.midpoint)
|
|
43
|
+
upper_bound = tuple(x + self.radius for x in self.midpoint)
|
|
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 circle across a specified axis at a given position.
|
|
53
|
+
|
|
54
|
+
Parameters:
|
|
55
|
+
axis (int): Axis index (0 for x-axis, 1 for y-axis).
|
|
56
|
+
position (float): Position along the axis to mirror.
|
|
57
|
+
"""
|
|
58
|
+
if axis < 0 or axis > 1:
|
|
59
|
+
raise ValueError("Axis must be 0 (x-axis) or 1 (y-axis).")
|
|
60
|
+
|
|
61
|
+
# Mirror the midpoint coordinate along the specified axis
|
|
62
|
+
self.midpoint[axis] = 2 * position - self.midpoint[axis]
|
|
63
|
+
|
|
64
|
+
lower_bound = tuple(x - self.radius for x in self.midpoint)
|
|
65
|
+
upper_bound = tuple(x + self.radius for x in self.midpoint)
|
|
66
|
+
|
|
67
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
68
|
+
|
|
69
|
+
def copy(self) -> Circle:
|
|
70
|
+
"""
|
|
71
|
+
Create a copy of the circle.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Circle: A new instance of Circle with the same midpoint, radius, and boundary conditions.
|
|
75
|
+
"""
|
|
76
|
+
return Circle(
|
|
77
|
+
midpoint=self.midpoint.copy(),
|
|
78
|
+
radius=self.radius,
|
|
79
|
+
bc=self._bc,
|
|
80
|
+
inverted=self._inverted,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def translate(self, offset: tuple[float, float]) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Translate the xyz by the given offset.
|
|
86
|
+
|
|
87
|
+
Parameters:
|
|
88
|
+
offset (tuple): (dx, dy) translation vector.
|
|
89
|
+
"""
|
|
90
|
+
offset_vec = xp.asarray(offset)
|
|
91
|
+
|
|
92
|
+
# Apply translation
|
|
93
|
+
self.midpoint = list(xp.asarray(self.midpoint) + offset_vec)
|
|
94
|
+
|
|
95
|
+
lower_bound = tuple(x - self.radius for x in self.midpoint)
|
|
96
|
+
upper_bound = tuple(x + self.radius for x in self.midpoint)
|
|
97
|
+
|
|
98
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
99
|
+
|
|
100
|
+
def _outline_loops(self, max_spacing: Spacing | None) -> list[np.ndarray]:
|
|
101
|
+
"""One loop of vertices on the circle, at most ``max_spacing`` apart (100 by default)."""
|
|
102
|
+
x0, y0 = (float(c) for c in self.midpoint)
|
|
103
|
+
|
|
104
|
+
def curve(t: np.ndarray) -> np.ndarray:
|
|
105
|
+
theta = 2 * np.pi * t
|
|
106
|
+
return np.stack(
|
|
107
|
+
(x0 + self.radius * np.cos(theta), y0 + self.radius * np.sin(theta))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if max_spacing is None:
|
|
111
|
+
return [curve(np.arange(100) / 100)]
|
|
112
|
+
perimeter = 2 * np.pi * self.radius
|
|
113
|
+
return [curve(_distribute(curve, perimeter, max_spacing, min_points=3))]
|
|
114
|
+
|
|
115
|
+
def contains(self, coords: Array) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Check if the given coordinates are inside the circle.
|
|
118
|
+
|
|
119
|
+
Parameters:
|
|
120
|
+
coords (list or tuple): Coordinates for each dimension.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
bool: True if the coordinates are inside the circle, False otherwise.
|
|
124
|
+
"""
|
|
125
|
+
distance_squared = xp.sum(
|
|
126
|
+
(xp.asarray(coords, dtype=float) - xp.asarray(self.midpoint, dtype=float))
|
|
127
|
+
** 2,
|
|
128
|
+
)
|
|
129
|
+
return self._apply_inversion(bool(distance_squared <= self.radius**2))
|
|
130
|
+
|
|
131
|
+
def contains_points(self, points: Array) -> Array:
|
|
132
|
+
"""Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside."""
|
|
133
|
+
points = _component_major(points, 2)
|
|
134
|
+
midpoint = xp.asarray(self.midpoint, dtype=float)[:, None]
|
|
135
|
+
# summed over the axes in order, as xp.sum does for one point
|
|
136
|
+
distance_squared = xp.sum((points - midpoint) ** 2, axis=0)
|
|
137
|
+
inside = distance_squared <= self.radius**2
|
|
138
|
+
return ~inside if self.inverted else inside
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Cuboid helpers for geometric object definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from operator import add, sub
|
|
6
|
+
|
|
7
|
+
import cunumpy as xp
|
|
8
|
+
|
|
9
|
+
from simgeometry._types import Array, BoundsLike, ConfigValue
|
|
10
|
+
from simgeometry.shapes import Shape3D, _component_major
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Cuboid(Shape3D):
|
|
14
|
+
"""Represent a cuboid geometry object."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
midpoint: BoundsLike,
|
|
19
|
+
side_lengths: BoundsLike,
|
|
20
|
+
bc: ConfigValue = None,
|
|
21
|
+
inverted: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initialize a cuboid (rectangular prism) in 3D.
|
|
25
|
+
|
|
26
|
+
Parameters:
|
|
27
|
+
midpoint (list or tuple): Coordinates of the midpoint of the cuboid.
|
|
28
|
+
side_lengths (list or tuple): Lengths of the sides of the cuboid in each dimension.
|
|
29
|
+
bc (optional): Boundary condition information.
|
|
30
|
+
"""
|
|
31
|
+
assert len(midpoint) == 3, "Length of midpoint must be 3 for 3D Cuboid."
|
|
32
|
+
assert len(side_lengths) == 3, "Length of side_lengths must be 3 for 3D Cuboid."
|
|
33
|
+
|
|
34
|
+
self.midpoint = xp.array(midpoint)
|
|
35
|
+
self.side_lengths = xp.array(side_lengths)
|
|
36
|
+
self._bc = bc
|
|
37
|
+
self._inverted = bool(inverted)
|
|
38
|
+
|
|
39
|
+
# Calculate bounds based on the midpoint and side lengths
|
|
40
|
+
half_lengths = tuple(len / 2 for len in self.side_lengths)
|
|
41
|
+
lower_bound = tuple(map(sub, self.midpoint, half_lengths))
|
|
42
|
+
upper_bound = tuple(map(add, self.midpoint, half_lengths))
|
|
43
|
+
|
|
44
|
+
super().__init__(
|
|
45
|
+
lower_bound=lower_bound,
|
|
46
|
+
upper_bound=upper_bound,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def mirror(self, axis: int, position: float) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Mirror the cuboid across a specified axis at a given position.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
axis (int): Axis index (0 for x-axis, 1 for y-axis, 2 for z-axis).
|
|
55
|
+
position (float): Position along the axis to mirror.
|
|
56
|
+
"""
|
|
57
|
+
if axis < 0 or axis > 2:
|
|
58
|
+
raise ValueError("Axis must be 0 (x-axis), 1 (y-axis), or 2 (z-axis).")
|
|
59
|
+
|
|
60
|
+
# Mirror the midpoint coordinate along the specified axis
|
|
61
|
+
self.midpoint[axis] = 2 * position - self.midpoint[axis]
|
|
62
|
+
|
|
63
|
+
half_lengths = tuple(len / 2 for len in self.side_lengths)
|
|
64
|
+
lower_bound = tuple(map(sub, self.midpoint, half_lengths))
|
|
65
|
+
upper_bound = tuple(map(add, self.midpoint, half_lengths))
|
|
66
|
+
|
|
67
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
68
|
+
|
|
69
|
+
def copy(self) -> Cuboid:
|
|
70
|
+
"""
|
|
71
|
+
Create a copy of the cuboid.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Cuboid: A new instance of Cuboid with the same midpoint, side lengths, and boundary conditions.
|
|
75
|
+
"""
|
|
76
|
+
return Cuboid(
|
|
77
|
+
midpoint=self.midpoint.copy(),
|
|
78
|
+
side_lengths=self.side_lengths.copy(),
|
|
79
|
+
bc=self._bc,
|
|
80
|
+
inverted=self._inverted,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def translate(self, offset: tuple[float, float, float]) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Translate the cuboid by the given offset.
|
|
86
|
+
|
|
87
|
+
Parameters:
|
|
88
|
+
offset (tuple): (dx, dy, dz) translation vector.
|
|
89
|
+
"""
|
|
90
|
+
offset_vec = xp.asarray(offset)
|
|
91
|
+
|
|
92
|
+
# Apply translation
|
|
93
|
+
self.midpoint = self.midpoint + offset_vec
|
|
94
|
+
|
|
95
|
+
# Calculate bounds based on the midpoint and side lengths
|
|
96
|
+
half_lengths = tuple(len / 2 for len in self.side_lengths)
|
|
97
|
+
lower_bound = tuple(map(sub, self.midpoint, half_lengths))
|
|
98
|
+
upper_bound = tuple(map(add, self.midpoint, half_lengths))
|
|
99
|
+
|
|
100
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
101
|
+
|
|
102
|
+
def get_vertices(self, slice_axis: int, slice_position: float) -> ConfigValue:
|
|
103
|
+
"""
|
|
104
|
+
Get the corner vertices of a slice of the cuboid along a given axis.
|
|
105
|
+
|
|
106
|
+
Parameters:
|
|
107
|
+
slice_axis (int): Axis along which to slice (0 for x, 1 for y, 2 for z).
|
|
108
|
+
slice_index (float): Position along the slice_axis where the slice is taken.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
vertices (list): List of corner vertices defining the slice polygon.
|
|
112
|
+
"""
|
|
113
|
+
assert 0 <= slice_axis < 3, "slice_axis must be 0, 1, or 2."
|
|
114
|
+
inverted = self.inverted
|
|
115
|
+
# TODO: replace slice_index with slice_position
|
|
116
|
+
# TODO: Check if the slice_position is in the bounds in that direction
|
|
117
|
+
|
|
118
|
+
# Calculate the corners of the slice plane
|
|
119
|
+
# Use the bounds to get the other two axes
|
|
120
|
+
axis1 = (slice_axis + 1) % 3
|
|
121
|
+
axis2 = (slice_axis + 2) % 3
|
|
122
|
+
|
|
123
|
+
# List to store the slice corners
|
|
124
|
+
b0 = self.lower_bound
|
|
125
|
+
b1 = self.upper_bound
|
|
126
|
+
if not b0[slice_axis] <= slice_position <= b1[slice_axis]:
|
|
127
|
+
return xp.array([]), inverted
|
|
128
|
+
|
|
129
|
+
vertices = [
|
|
130
|
+
[b0[axis1], b0[axis2]],
|
|
131
|
+
[b1[axis1], b0[axis2]],
|
|
132
|
+
[b1[axis1], b1[axis2]],
|
|
133
|
+
[b0[axis1], b1[axis2]],
|
|
134
|
+
]
|
|
135
|
+
# TODO: Is this inverted stuff even working?
|
|
136
|
+
return xp.array(vertices), inverted
|
|
137
|
+
|
|
138
|
+
def contains(self, coords: Array) -> bool:
|
|
139
|
+
"""
|
|
140
|
+
Check if the given coordinates are inside the cuboid.
|
|
141
|
+
|
|
142
|
+
Parameters:
|
|
143
|
+
coords (list or tuple): Coordinates for each dimension.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
bool: True if all coordinates are inside the cuboid, False otherwise.
|
|
147
|
+
"""
|
|
148
|
+
coords = xp.asarray(coords, dtype=float)
|
|
149
|
+
lower_bound = xp.asarray(self.lower_bound, dtype=float)
|
|
150
|
+
upper_bound = xp.asarray(self.upper_bound, dtype=float)
|
|
151
|
+
inside = bool(xp.all((coords >= lower_bound) & (coords <= upper_bound)))
|
|
152
|
+
return (not inside) if self.inverted else inside
|
|
153
|
+
|
|
154
|
+
def contains_points(self, points: Array) -> Array:
|
|
155
|
+
"""Vectorised :meth:`contains`: a mask of the ``(3, n)`` points inside."""
|
|
156
|
+
points = _component_major(points, 3)
|
|
157
|
+
lower = xp.asarray(self.lower_bound, dtype=float)[:, None]
|
|
158
|
+
upper = xp.asarray(self.upper_bound, dtype=float)[:, None]
|
|
159
|
+
inside = xp.all((points >= lower) & (points <= upper), axis=0)
|
|
160
|
+
return ~inside if self.inverted else inside
|
|
161
|
+
|
|
162
|
+
def volume(self) -> float:
|
|
163
|
+
"""Calculate the volume of the cuboid."""
|
|
164
|
+
return float(xp.prod(self.side_lengths))
|
|
165
|
+
|
|
166
|
+
def surface_area(self) -> float:
|
|
167
|
+
"""Calculate the surface area of the cuboid."""
|
|
168
|
+
areas = [
|
|
169
|
+
self.side_lengths[i] * self.side_lengths[j]
|
|
170
|
+
for i in range(3)
|
|
171
|
+
for j in range(i + 1, 3)
|
|
172
|
+
]
|
|
173
|
+
return float(2 * sum(areas))
|