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,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))