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,38 @@
1
+ """Shapes, grids, and domains for numerical simulations."""
2
+
3
+ from simgeometry.collection import GeometryCollection
4
+ from simgeometry.grid import Grid
5
+ from simgeometry.objects import (
6
+ Aperture2D,
7
+ Aperture3D,
8
+ Circle,
9
+ Cuboid,
10
+ FunctionObject2D,
11
+ FunctionObject3D,
12
+ Line,
13
+ Polygon,
14
+ Polyline,
15
+ Rectangle,
16
+ Sphere,
17
+ Triangle,
18
+ )
19
+ from simgeometry.simulation_domain import SimulationDomain
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = [
23
+ "GeometryCollection",
24
+ "Grid",
25
+ "SimulationDomain",
26
+ "Aperture2D",
27
+ "Aperture3D",
28
+ "Circle",
29
+ "Cuboid",
30
+ "FunctionObject2D",
31
+ "FunctionObject3D",
32
+ "Line",
33
+ "Polygon",
34
+ "Polyline",
35
+ "Rectangle",
36
+ "Sphere",
37
+ "Triangle",
38
+ ]
simgeometry/_bounds.py ADDED
@@ -0,0 +1,16 @@
1
+ """Array bounds shared by geometric regions and grids."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Bounds:
7
+ def __init__(self, lower_bound, upper_bound) -> None:
8
+ assert len(lower_bound) == len(upper_bound)
9
+ self.lower_bound = lower_bound
10
+ self.upper_bound = upper_bound
11
+ self.ndim = len(lower_bound)
12
+ assert self.ndim in (1, 2, 3)
13
+ for axis in range(self.ndim):
14
+ assert upper_bound[axis] >= lower_bound[axis]
15
+ self.sidelengths = upper_bound - lower_bound
16
+ self.midpoint = lower_bound + 0.5 * self.sidelengths
@@ -0,0 +1,23 @@
1
+ """Normalize scalar and sequence geometry arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+
8
+ def ensure_dim_tuple(value: object, *, ndim: int | None = None) -> tuple:
9
+ if isinstance(value, tuple):
10
+ normalized = value
11
+ elif isinstance(value, list):
12
+ normalized = tuple(value)
13
+ elif isinstance(value, (str, bytes, dict)):
14
+ raise TypeError(
15
+ f"value must be a scalar number/bool or sequence, got {type(value)}"
16
+ )
17
+ elif isinstance(value, Iterable):
18
+ normalized = tuple(value)
19
+ else:
20
+ normalized = (value,)
21
+ if ndim is not None and len(normalized) != ndim:
22
+ raise ValueError(f"value must have length {ndim}, got {len(normalized)}")
23
+ return normalized
@@ -0,0 +1,37 @@
1
+ """Apply particle boundary conditions on the active array backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cunumpy as xp
6
+
7
+
8
+ def apply_particle_boundaries(
9
+ positions, velocities, alive, reinject, lower, upper, lower_type, upper_type
10
+ ) -> None:
11
+ """Update component-major particle arrays in place.
12
+
13
+ Boundary codes are kill=0, reflect=1, periodic=2, reinject=3.
14
+ """
15
+ reinject[:] = False
16
+ in_domain = alive.copy()
17
+ for axis in range(positions.shape[0]):
18
+ active = alive & ~reinject
19
+ x = positions[axis]
20
+ low, high = lower[axis], upper[axis]
21
+ below = active & (x < low)
22
+ above = active & ((x >= high) if upper_type[axis] == 2 else (x > high))
23
+ for mask, bound, side_type in (
24
+ (below, low, lower_type[axis]),
25
+ (above, high, upper_type[axis]),
26
+ ):
27
+ if side_type == 1:
28
+ x[mask] = 2.0 * bound - x[mask]
29
+ velocities[axis, mask] *= -1.0
30
+ elif side_type == 2:
31
+ wrapped = low + (x[mask] - low) % (high - low)
32
+ x[mask] = xp.where(wrapped >= high, low, wrapped)
33
+ elif side_type == 3:
34
+ reinject |= mask
35
+ checked = alive & ~reinject
36
+ in_domain &= ~checked | ((x >= low) & (x < high))
37
+ alive &= in_domain
simgeometry/_types.py ADDED
@@ -0,0 +1,22 @@
1
+ """Types used by geometry without importing simulation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TypeAlias
7
+
8
+ from numpy import ndarray
9
+
10
+ Array: TypeAlias = ndarray
11
+ Scalar: TypeAlias = str | int | float | bool | None
12
+ ConfigValue: TypeAlias = (
13
+ Scalar
14
+ | Array
15
+ | list["ConfigValue"]
16
+ | tuple["ConfigValue", ...]
17
+ | set["ConfigValue"]
18
+ | dict[str, "ConfigValue"]
19
+ )
20
+ Printable: TypeAlias = str | int | float | bool | None
21
+ BoundsLike: TypeAlias = Sequence[int | float] | ndarray
22
+ Vertices2DLike: TypeAlias = Sequence[BoundsLike] | ndarray
simgeometry/api.py ADDED
@@ -0,0 +1,247 @@
1
+ """Shared inspection and construction methods for geometry objects.
2
+
3
+ Array queries use component-major coordinates, ``(ndim, n)``. Inspection,
4
+ meshing and labelled evaluation return host arrays; containment retains the
5
+ selected cunumpy backend. Optional dependencies are imported on demand.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+
12
+
13
+ class GeometryAPI:
14
+ """Convenience operations shared by shapes, sections and collections."""
15
+
16
+ def evaluate(
17
+ self,
18
+ x=None,
19
+ y=None,
20
+ z=None,
21
+ *,
22
+ grid=None,
23
+ bounds=None,
24
+ resolution=128,
25
+ quantity="occupancy",
26
+ ):
27
+ """Sample geometry as an xarray.DataArray (requires the ``evaluate`` extra).
28
+
29
+ Scalars fix axes, sequences form a tensor product, and DataArrays with
30
+ shared dimension names describe scattered points. Unspecified axes use
31
+ grid nodes, or ``resolution`` samples across ``bounds``/the shape bounds.
32
+ ``quantity`` is occupancy, labels (collections), or signed_distance.
33
+ This is a local host operation, with no implicit MPI collectives.
34
+ """
35
+ from simgeometry.evaluation import evaluate
36
+
37
+ return evaluate(
38
+ self,
39
+ x=x,
40
+ y=y,
41
+ z=z,
42
+ grid=grid,
43
+ bounds=bounds,
44
+ resolution=resolution,
45
+ quantity=quantity,
46
+ )
47
+
48
+ def slice(self, *, x=None, y=None, z=None, origin=None, normal=None):
49
+ """Return a queryable section with local-to-world coordinate mappings.
50
+
51
+ Fix exactly one coordinate, or give a 3D plane's origin and normal.
52
+ Axis-aligned sections retain the remaining coordinate names; oblique
53
+ sections use x/y as their local plane coordinates.
54
+ """
55
+ from simgeometry.operations import Section
56
+
57
+ return Section(self, x=x, y=y, z=z, origin=origin, normal=normal)
58
+
59
+ def show(self, **kwargs):
60
+ """Display geometry, dispatching to 3D unless a slice is requested.
61
+
62
+ Returns Matplotlib axes, or a PyVista plotter for ``backend='pyvista'``.
63
+ Set ``display=False`` for scripts. ``save=path`` saves the figure.
64
+ """
65
+ from simgeometry.visualization import show
66
+
67
+ return show(self, **kwargs)
68
+
69
+ def show_3d(self, **kwargs):
70
+ """Display a surface with optional wireframe, normals and clipping.
71
+
72
+ ``backend='pyvista'`` enables an interactive clipping-plane widget via
73
+ ``clip=True`` (requires the ``interactive`` extra). Matplotlib supports
74
+ a static ``clip=(origin, normal)`` plane. Meshes are resolution-dependent.
75
+ """
76
+ from simgeometry.visualization import show_3d
77
+
78
+ return show_3d(self, **kwargs)
79
+
80
+ def plot(self, ax=None, **kwargs):
81
+ """Draw without displaying and return axes for composition."""
82
+ return self.show(ax=ax, display=False, **kwargs)
83
+
84
+ def copy(self):
85
+ """Return an independent deep copy."""
86
+ return copy.deepcopy(self)
87
+
88
+ def transformed(self, matrix, offset=None):
89
+ """Return an affine-transformed snapshot; the source is unchanged."""
90
+ from simgeometry.operations import TransformedShape
91
+
92
+ return TransformedShape(self, matrix, offset)
93
+
94
+ def translated(self, offset):
95
+ """Return a translated snapshot (unlike legacy in-place translate)."""
96
+ import numpy as np
97
+
98
+ return self.transformed(np.eye(self.ndim), offset)
99
+
100
+ def rotated(self, angle, *, axis=(0, 0, 1), origin=None):
101
+ """Return a rotated snapshot; angle is in degrees, about origin (default 0)."""
102
+ from simgeometry.operations import rotation
103
+
104
+ return rotation(self, angle, axis=axis, origin=origin)
105
+
106
+ def scaled(self, factors, *, origin=None):
107
+ """Return a snapshot scaled by a nonzero scalar or per-axis factors."""
108
+ import numpy as np
109
+
110
+ factors = np.broadcast_to(factors, (self.ndim,)).astype(float)
111
+ centre = np.zeros(self.ndim) if origin is None else np.asarray(origin, float)
112
+ return self.transformed(np.diag(factors), centre * (1 - factors))
113
+
114
+ def union(self, other):
115
+ from simgeometry.operations import BooleanShape
116
+
117
+ return BooleanShape(self, other, "union")
118
+
119
+ def intersection(self, other):
120
+ from simgeometry.operations import BooleanShape
121
+
122
+ return BooleanShape(self, other, "intersection")
123
+
124
+ def difference(self, other):
125
+ from simgeometry.operations import BooleanShape
126
+
127
+ return BooleanShape(self, other, "difference")
128
+
129
+ def __or__(self, other):
130
+ return self.union(other)
131
+
132
+ def __and__(self, other):
133
+ return self.intersection(other)
134
+
135
+ def __sub__(self, other):
136
+ return self.difference(other)
137
+
138
+ def intersect_segment(self, start, end, *, samples=256, tolerance=1e-10):
139
+ """Return ordered surface hits, including multiple crossings and tangencies.
140
+
141
+ Primitive intersections are analytic. Generic membership functions use
142
+ ``samples`` intervals and may miss features smaller than that sampling.
143
+ Result.exact distinguishes these cases. Coincident surface segments do
144
+ not have a discrete set of hits; their end contacts may be returned.
145
+ """
146
+ from simgeometry.queries import intersect_segment
147
+
148
+ return intersect_segment(self, start, end, samples=samples, tolerance=tolerance)
149
+
150
+ def crossings(self, start, end, *, samples=64, iterations=40):
151
+ """First boundary crossing of every segment ``start[:, i] -> end[:, i]``.
152
+
153
+ ``start`` and ``end`` are ``(ndim, N)``, such as particle positions before and
154
+ after a step. Returns a :class:`~simgeometry.crossings.Crossings` with ``hit``,
155
+ ``fraction``, ``point`` and ``normal`` (out of the region where ``contains`` is
156
+ true) on the active array backend. Polygons, circles, spheres, boxes and their
157
+ affine transforms are analytic; other shapes sample ``samples`` points per
158
+ segment and bisect ``iterations`` times.
159
+ """
160
+ from simgeometry.crossings import crossings
161
+
162
+ return crossings(self, start, end, samples=samples, iterations=iterations)
163
+
164
+ def closest_point(self, points, *, resolution=64, bounds=None):
165
+ """Closest surface coordinates; primitives are exact, others use a mesh.
166
+
167
+ Mesh fallback requires ``mesh`` extras. Its accuracy is controlled by
168
+ resolution and may miss unresolved components. Points are (ndim, n),
169
+ or one (ndim,) point; the returned array has the same layout.
170
+ """
171
+ from simgeometry.queries import closest_point
172
+
173
+ return closest_point(self, points, resolution=resolution, bounds=bounds)
174
+
175
+ def signed_distance(self, points, *, resolution=64, bounds=None):
176
+ """Distance to the boundary, negative inside, positive outside.
177
+
178
+ Exact for balls, boxes and simple polygons; otherwise distance to the
179
+ sampled surface mesh. A membership function is not itself a distance.
180
+ """
181
+ from simgeometry.queries import signed_distance
182
+
183
+ return signed_distance(self, points, resolution=resolution, bounds=bounds)
184
+
185
+ def surface_normal(self, points, *, scale=None):
186
+ """Outward unit normals at boundary points; NaN where undefined.
187
+
188
+ Primitive faces use analytic normals. Other geometries estimate the
189
+ normal from nearby membership samples. At corners normals are not unique.
190
+ """
191
+ from simgeometry.queries import surface_normal
192
+
193
+ return surface_normal(self, points, scale=scale)
194
+
195
+ def to_mesh(self, *, resolution=64, bounds=None):
196
+ """Return sampled contours (2D) or triangles (3D), with spacing metadata.
197
+
198
+ Requires the ``mesh`` extra. Open contours/surfaces at the sampling bounds
199
+ remain open; no artificial caps are added. Refine to resolve thin features.
200
+ """
201
+ from simgeometry.mesh import to_mesh
202
+
203
+ return to_mesh(self, resolution=resolution, bounds=bounds)
204
+
205
+ def export(self, path, **kwargs):
206
+ """Export a sampled mesh as NPZ, OBJ, or STL (3D only)."""
207
+ return self.to_mesh(**kwargs).save(path)
208
+
209
+ def sample_interior(self, count, *, rng=None, bounds=None, max_draws=1_000_000):
210
+ """Uniform rejection samples, returned as (ndim, count).
211
+
212
+ Supply bounds for inverted/unbounded shapes. Raises if max_draws is
213
+ exhausted; it never silently returns too few samples.
214
+ """
215
+ from simgeometry.sampling import sample_interior
216
+
217
+ return sample_interior(self, count, rng=rng, bounds=bounds, max_draws=max_draws)
218
+
219
+ def sample_surface(self, count, *, rng=None, resolution=64, bounds=None):
220
+ """Uniform-by-length/area samples from the faceted boundary mesh."""
221
+ from simgeometry.sampling import sample_surface
222
+
223
+ return sample_surface(
224
+ self, count, rng=rng, resolution=resolution, bounds=bounds
225
+ )
226
+
227
+ def cut_edges(self, grid, **kwargs):
228
+ """Locate all detected grid-edge surface hits, without solver clamping."""
229
+ from simgeometry.cut_edges import cut_edges
230
+
231
+ return cut_edges(self, grid, **kwargs)
232
+
233
+ def diagnose(self, grid, **kwargs):
234
+ """Report sampled overlaps, unresolved components, and short cut distances."""
235
+ from simgeometry.cut_edges import diagnose
236
+
237
+ return diagnose(self, grid, **kwargs)
238
+
239
+ def cell_fractions(self, grid, *, samples_per_axis=4):
240
+ """Estimate solid cell-volume and face-area fractions by subcell sampling.
241
+
242
+ Increase samples_per_axis to check convergence; unresolved thin features
243
+ can be missed. This is not an exact finite-volume interface reconstruction.
244
+ """
245
+ from simgeometry.fractions import cell_fractions
246
+
247
+ return cell_fractions(self, grid, samples_per_axis=samples_per_axis)
@@ -0,0 +1,231 @@
1
+ """Boundingbox helpers for geometry, grids, and simulation domains."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cunumpy as xp
6
+ from maybempi import MPI
7
+
8
+ from simgeometry._bounds import Bounds
9
+ from simgeometry._normalize import ensure_dim_tuple
10
+ from simgeometry._types import Array, ConfigValue
11
+ from simgeometry.domain_decomposition import DomainDecomposition
12
+
13
+
14
+ class BoundingBox:
15
+ """Represent axis-aligned bounds for a simulation region."""
16
+
17
+ # Applications may supply their own seeded stream. The default follows
18
+ # the active cunumpy backend and works without any Picnic configuration.
19
+ random = staticmethod(xp.random.random)
20
+
21
+ def __init__(
22
+ self,
23
+ lower_bound: tuple,
24
+ upper_bound: tuple,
25
+ comm: MPI.Comm | None = None,
26
+ domain_decomposition: ConfigValue = None,
27
+ periodic: bool | None = None,
28
+ ) -> None:
29
+ """
30
+ Initialize the simulation domain.
31
+
32
+ Parameters:
33
+ ndim (int): Number of dimensions (e.g., 1, 2, or 3).
34
+ bounds (list of tuples): List of tuples specifying min and max for each dimension.
35
+ For example, [(x_min, y_min, z_min), (y_max, y_max, z_max)] for 3D.
36
+ """
37
+
38
+ lower_bound = ensure_dim_tuple(lower_bound)
39
+ upper_bound = ensure_dim_tuple(upper_bound)
40
+ if len(lower_bound) != len(upper_bound):
41
+ raise ValueError(
42
+ "lower_bound and upper_bound must have the same length, "
43
+ f"got {len(lower_bound)} and {len(upper_bound)}",
44
+ )
45
+ ndim = len(lower_bound)
46
+ if ndim not in [1, 2, 3]:
47
+ raise ValueError("Number of dimensions must be 1, 2, or 3")
48
+ if domain_decomposition is not None:
49
+ domain_decomposition = ensure_dim_tuple(
50
+ domain_decomposition,
51
+ ndim=ndim,
52
+ )
53
+ if periodic is not None:
54
+ periodic = ensure_dim_tuple(periodic, ndim=ndim)
55
+ for dir in range(ndim):
56
+ if upper_bound[dir] < lower_bound[dir]:
57
+ raise ValueError("upper bound must be >= lower bound in each dimension")
58
+ # Set attributes
59
+ self._bounds = Bounds(
60
+ lower_bound=xp.array(lower_bound, dtype=float),
61
+ upper_bound=xp.array(upper_bound, dtype=float),
62
+ )
63
+
64
+ self._lower_bound = lower_bound
65
+ self._upper_bound = upper_bound
66
+ self._comm = comm
67
+
68
+ if isinstance(comm, MPI.Comm):
69
+ self._mpi_rank = comm.Get_rank()
70
+ self._mpi_size = comm.Get_size()
71
+ else:
72
+ self._mpi_rank = 0
73
+ self._mpi_size = 1
74
+
75
+ self._periodic = periodic
76
+ self._domain_decomposition = DomainDecomposition(
77
+ comm=comm,
78
+ decompose=domain_decomposition,
79
+ periodic=periodic,
80
+ ndim=self.ndim,
81
+ )
82
+ self._proc_sizes = self.domain_decomposition_class.proc_sizes
83
+
84
+ # TODO: Use a tuple here instead
85
+ self._sidelengths = tuple(
86
+ upper_bound[dir] - lower_bound[dir] for dir in range(ndim)
87
+ )
88
+
89
+ def contains(self, coords: tuple) -> bool:
90
+ """
91
+ Check if the given coordinates are inside the domain.
92
+
93
+ Parameters:
94
+ coords (float): Coordinates for each dimension.
95
+
96
+ Returns:
97
+ bool: True if all coordinates are within the domain bounds, False otherwise.
98
+ """
99
+
100
+ assert len(coords) == self.ndim, (
101
+ f"Expected {self.ndim} dimensions, got {len(coords)}."
102
+ )
103
+
104
+ return all(
105
+ self.lower_bound[i] <= coord < self.upper_bound[i]
106
+ for i, coord in enumerate(coords)
107
+ )
108
+
109
+ def get_relative_position(self, coords: Array) -> Array:
110
+ """
111
+ Determine the relative position of the given coordinates with respect to the domain bounds.
112
+
113
+ Parameters:
114
+ coords (float): Coordinates for each dimension.
115
+
116
+ Returns:
117
+ list: A list representing the relative position.
118
+ [0, 0] if inside,
119
+ [-1, 0] if to the left,
120
+ [1, 0] if to the right,
121
+ [0, -1] if below,
122
+ [0, 1] if above,
123
+ etc.
124
+ """
125
+ assert len(coords) == self.ndim, (
126
+ f"Expected {self.ndim} dimensions, got {len(coords)}."
127
+ )
128
+
129
+ position = [0] * self.ndim
130
+
131
+ for i, coord in enumerate(coords):
132
+ if coord < self.lower_bound[i]:
133
+ position[i] = -1
134
+ elif coord >= self.upper_bound[i]:
135
+ position[i] = 1
136
+ else:
137
+ position[i] = 0
138
+
139
+ return position
140
+
141
+ def draw_random_position(self) -> Array:
142
+ """Draw one random position inside the bounding box."""
143
+ pos = self.random(self.ndim)
144
+ for i, (b0, b1) in enumerate(zip(self.lower_bound, self.upper_bound)):
145
+ pos[i] = b0 + pos[i] * (b1 - b0)
146
+ return pos
147
+
148
+ def draw_random_positions(self, num_positions: int, out: Array = None) -> Array:
149
+ """Draw `num_positions` random positions inside the bounding box.
150
+
151
+ The result has shape ``(ndim, num_positions)``; ``out`` must have it.
152
+ """
153
+ shape = (self.ndim, num_positions)
154
+ if out is None:
155
+ out = self.random(shape)
156
+ ret = True
157
+ else:
158
+ assert out.shape == shape, (
159
+ f"Expected out with shape {shape}, got {out.shape}"
160
+ )
161
+ out[:] = self.random(shape)
162
+ ret = False
163
+
164
+ for dir in range(self.ndim):
165
+ out[dir] = out[dir] * self.bounds.sidelengths[dir] + self.lower_bound[dir]
166
+
167
+ if ret:
168
+ return out
169
+
170
+ @property
171
+ def ndim(self) -> int:
172
+ """Return the number of spatial dimensions."""
173
+ return self._bounds.ndim
174
+
175
+ @property
176
+ def bounds(self) -> Bounds:
177
+ """Return the lower and upper bounds."""
178
+ return self._bounds
179
+
180
+ @property
181
+ def lower_bound(self) -> tuple:
182
+ """Return the lower coordinate bounds."""
183
+ return self._lower_bound
184
+
185
+ @property
186
+ def upper_bound(self) -> tuple:
187
+ """Return the upper coordinate bounds."""
188
+ return self._upper_bound
189
+
190
+ @property
191
+ def comm(self) -> MPI.Comm | None:
192
+ """Return the MPI communicator."""
193
+ return self._comm
194
+
195
+ @property
196
+ def mpi_rank(self) -> int:
197
+ """Return the MPI rank."""
198
+ return self._mpi_rank
199
+
200
+ @property
201
+ def mpi_size(self) -> int:
202
+ """Return the MPI size."""
203
+ return self._mpi_size
204
+
205
+ @property
206
+ def domain_decomposition_class(self) -> ConfigValue:
207
+ """Return the domain decomposition class."""
208
+ return self._domain_decomposition
209
+
210
+ @property
211
+ def periodic(self) -> tuple[bool, ...]:
212
+ """Return periodicity flags for each axis."""
213
+ return self._periodic
214
+
215
+ @property
216
+ def proc_sizes(self) -> list[int]:
217
+ """Return process counts along each axis."""
218
+ return self._proc_sizes
219
+
220
+ @property
221
+ def midpoint(self) -> tuple[float, ...]:
222
+ """Return the center point of the bounding box."""
223
+ return tuple(
224
+ 0.5 * (self.lower_bound[dir] + self.upper_bound[dir])
225
+ for dir in range(self.ndim)
226
+ )
227
+
228
+ @property
229
+ def sidelengths(self) -> tuple[float, ...]:
230
+ """Return the side lengths."""
231
+ return self._sidelengths
@@ -0,0 +1,65 @@
1
+ """Named collections with explicit overlap precedence and display styles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ import cunumpy as xp
8
+ import numpy as np
9
+
10
+ from simgeometry.operations import mask
11
+ from simgeometry.shapes import Shape, _component_major
12
+
13
+
14
+ class GeometryCollection(Shape):
15
+ """Named objects; last listed object wins labels in overlaps.
16
+
17
+ Label 0 is background, labels 1..N follow insertion order. Styles map object
18
+ names to plotting keyword dictionaries. Explicit bounds also permit an empty
19
+ collection and define the default viewing/sampling region, without clipping
20
+ object membership. Collection objects are references, not snapshots.
21
+ """
22
+
23
+ def __init__(self, objects, *, bounds=None, styles=None):
24
+ if not isinstance(objects, Mapping):
25
+ objects = {f"object_{i + 1}": obj for i, obj in enumerate(objects)}
26
+ self.named_objects = dict(objects)
27
+ if any(not isinstance(name, str) or not name for name in self.named_objects):
28
+ raise ValueError("Object names must be nonempty strings")
29
+ self.names = tuple(self.named_objects)
30
+ self.objects = list(self.named_objects.values())
31
+ self.styles = dict(styles or {})
32
+ if set(self.styles) - set(self.names):
33
+ raise ValueError("Style names must identify objects")
34
+ if bounds is None:
35
+ if not self.objects:
36
+ raise ValueError("An empty collection needs bounds")
37
+ bounds = (
38
+ np.min([s.lower_bound for s in self.objects], axis=0),
39
+ np.max([s.upper_bound for s in self.objects], axis=0),
40
+ )
41
+ ndim = len(bounds[0])
42
+ if any(obj.ndim != ndim for obj in self.objects):
43
+ raise ValueError("All collection objects must have the same dimension")
44
+ self._inverted = any(obj.inverted for obj in self.objects)
45
+ super().__init__(ndim, *bounds)
46
+
47
+ def __getitem__(self, name):
48
+ return self.named_objects[name]
49
+
50
+ def labels(self, points):
51
+ """Integer object identities for (ndim, n) points; last object wins."""
52
+ points = np.asarray(xp.to_numpy(_component_major(points, self.ndim)), float)
53
+ result = np.zeros(points.shape[1], dtype=np.int64)
54
+ for label, obj in enumerate(self.objects, start=1):
55
+ result[mask(obj, points)] = label
56
+ return result
57
+
58
+ def contains_points(self, points):
59
+ return xp.asarray(self.labels(points) != 0)
60
+
61
+ def contains(self, coords):
62
+ return any(obj.contains(coords) for obj in self.objects)
63
+
64
+ def evaluate(self, *args, quantity="labels", **kwargs):
65
+ return super().evaluate(*args, quantity=quantity, **kwargs)