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,386 @@
1
+ """Boolean composition, affine transformations and queryable plane sections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from itertools import product
7
+
8
+ import cunumpy as xp
9
+ import numpy as np
10
+
11
+ from simgeometry.shapes import Shape, _component_major, _refine_loop, _signed_area
12
+
13
+
14
+ def corners(shape):
15
+ return np.array(list(product(*zip(shape.lower_bound, shape.upper_bound))), float).T
16
+
17
+
18
+ def mask(shape, points):
19
+ return np.asarray(xp.to_numpy(shape.contains_points(points)), dtype=bool)
20
+
21
+
22
+ class BooleanShape(Shape):
23
+ """A snapshot of a union, intersection, or difference of two shapes.
24
+
25
+ Bounds conservatively enclose operands. ``inverted`` indicates the result
26
+ may be unbounded, so sampling requires explicit bounds in that case.
27
+ """
28
+
29
+ def __init__(self, left, right, operation):
30
+ if left.ndim != right.ndim:
31
+ raise ValueError("Boolean operands must have the same dimension")
32
+ if operation not in ("union", "intersection", "difference"):
33
+ raise ValueError("Unknown Boolean operation")
34
+ self.left, self.right = copy.deepcopy(left), copy.deepcopy(right)
35
+ self.operation = operation
36
+ self._inverted = bool(left.inverted or right.inverted)
37
+ super().__init__(
38
+ left.ndim,
39
+ np.minimum(left.lower_bound, right.lower_bound),
40
+ np.maximum(left.upper_bound, right.upper_bound),
41
+ )
42
+
43
+ def contains_points(self, points):
44
+ points = _component_major(points, self.ndim)
45
+ a, b = self.left.contains_points(points), self.right.contains_points(points)
46
+ if self.operation == "union":
47
+ return a | b
48
+ if self.operation == "intersection":
49
+ return a & b
50
+ return a & ~b
51
+
52
+ def contains(self, coords):
53
+ return bool(
54
+ xp.to_numpy(self.contains_points(np.asarray(coords).reshape(self.ndim, 1)))[
55
+ 0
56
+ ]
57
+ )
58
+
59
+ def outlines(self, max_spacing=None):
60
+ """The boundary loops of a 2D Boolean shape, as ``(2, n)`` host arrays.
61
+
62
+ Loops keep the region on their left: outer boundaries are counter-clockwise
63
+ and holes clockwise (a plate minus a disc gives one of each). Each operand
64
+ enters as the polygon through its own ``outlines(max_spacing)``, so a curved
65
+ operand such as a ``Circle`` is approximated by its inscribed polygon; the
66
+ clipped loops are then refined to ``max_spacing`` (see ``Shape2D.outlines``).
67
+ Needs shapely (the ``clip`` extra). Inverted operands, non-2D shapes and
68
+ operands without outlines raise ``NotImplementedError``.
69
+ """
70
+ geometry = _shapely_geometry(self, max_spacing)
71
+ loops = []
72
+ for polygon in _polygons(geometry):
73
+ loops.append(_shapely_ring(polygon.exterior, counter_clockwise=True))
74
+ loops.extend(
75
+ _shapely_ring(ring, counter_clockwise=False)
76
+ for ring in polygon.interiors
77
+ )
78
+ loops = [loop for loop in loops if loop.shape[1] >= 3]
79
+ if max_spacing is None:
80
+ return loops
81
+ return [_refine_loop(loop, max_spacing) for loop in loops]
82
+
83
+ def boundary_panels(self, max_spacing=None):
84
+ """The edges of :meth:`outlines` as panels; see ``Shape2D.boundary_panels``."""
85
+ from simgeometry.panels import boundary_panels
86
+
87
+ return boundary_panels(self, max_spacing)
88
+
89
+
90
+ def _shapely():
91
+ try:
92
+ import shapely
93
+ except ImportError as exc: # pragma: no cover - depends on the environment
94
+ raise ImportError(
95
+ "Outlines of Boolean shapes need shapely: pip install 'simgeometry[clip]'"
96
+ ) from exc
97
+ return shapely
98
+
99
+
100
+ def _shapely_geometry(shape, max_spacing):
101
+ """A shapely geometry of a 2D shape, built from its outline loops."""
102
+ shapely = _shapely()
103
+ if shape.ndim != 2:
104
+ raise NotImplementedError("outlines() is only defined for 2D shapes")
105
+ if shape.inverted:
106
+ raise NotImplementedError(
107
+ "outlines() of a Boolean shape needs bounded operands, not inverted ones"
108
+ )
109
+ if isinstance(shape, BooleanShape):
110
+ left = _shapely_geometry(shape.left, max_spacing)
111
+ right = _shapely_geometry(shape.right, max_spacing)
112
+ result = {
113
+ "union": shapely.union,
114
+ "intersection": shapely.intersection,
115
+ "difference": shapely.difference,
116
+ }[shape.operation](left, right)
117
+ return shapely.make_valid(result)
118
+ from simgeometry.objects.function_object import FunctionObject2D
119
+
120
+ if isinstance(shape, FunctionObject2D) or not hasattr(shape, "outlines"):
121
+ raise NotImplementedError(
122
+ f"{type(shape).__name__} has no outlines to combine in a Boolean shape"
123
+ )
124
+ if not getattr(shape, "closed", True):
125
+ raise NotImplementedError(
126
+ f"{type(shape).__name__} is an open curve and encloses no area"
127
+ )
128
+ outer, holes = [], []
129
+ for loop in shape.outlines(max_spacing):
130
+ loop = np.asarray(loop, dtype=float)
131
+ if loop.shape[1] < 3:
132
+ continue
133
+ (outer if _signed_area(loop) > 0 else holes).append(shapely.Polygon(loop.T))
134
+ geometry = shapely.union_all(outer) if outer else shapely.Polygon()
135
+ if holes:
136
+ geometry = shapely.difference(geometry, shapely.union_all(holes))
137
+ return shapely.make_valid(geometry)
138
+
139
+
140
+ def _polygons(geometry):
141
+ """The nonempty polygons of a shapely geometry, in a deterministic order."""
142
+ shapely = _shapely()
143
+ polygons = []
144
+ for part in shapely.get_parts(geometry):
145
+ if isinstance(part, shapely.Polygon):
146
+ if part.area > 0:
147
+ polygons.append(part)
148
+ elif isinstance(part, (shapely.MultiPolygon, shapely.GeometryCollection)):
149
+ polygons.extend(_polygons(part))
150
+ return sorted(polygons, key=lambda p: (p.bounds[0], p.bounds[1], -p.area))
151
+
152
+
153
+ def _shapely_ring(ring, *, counter_clockwise):
154
+ """A shapely ring as a ``(2, n)`` loop, without repeated vertices."""
155
+ coords = np.asarray(ring.coords, dtype=float)[:-1]
156
+ keep = np.any(coords != np.roll(coords, 1, axis=0), axis=1)
157
+ loop = np.ascontiguousarray(coords[keep].T)
158
+ if (_signed_area(loop) > 0) != counter_clockwise:
159
+ loop = np.ascontiguousarray(loop[:, ::-1])
160
+ return loop
161
+
162
+
163
+ class TransformedShape(Shape):
164
+ """Independent affine image of a source, mapping p to matrix @ p + offset."""
165
+
166
+ def __init__(self, source, matrix, offset=None):
167
+ self.source = copy.deepcopy(source)
168
+ self.matrix = np.asarray(matrix, float)
169
+ self.offset = (
170
+ np.zeros(source.ndim) if offset is None else np.asarray(offset, float)
171
+ )
172
+ if self.matrix.shape != (source.ndim, source.ndim) or self.offset.shape != (
173
+ source.ndim,
174
+ ):
175
+ raise ValueError("Matrix and offset must match the shape dimension")
176
+ if not np.all(np.isfinite(self.matrix)) or not np.all(np.isfinite(self.offset)):
177
+ raise ValueError("Transform must be finite")
178
+ try:
179
+ self.inverse = np.linalg.inv(self.matrix)
180
+ except np.linalg.LinAlgError as exc:
181
+ raise ValueError("Transform must be invertible") from exc
182
+ vertices = self.matrix @ corners(source) + self.offset[:, None]
183
+ self._inverted = source.inverted
184
+ self._bc = getattr(source, "_bc", None)
185
+ self._bc_type = getattr(source, "_bc_type", "dirichlet")
186
+ super().__init__(source.ndim, vertices.min(axis=1), vertices.max(axis=1))
187
+
188
+ def contains_points(self, points):
189
+ points = _component_major(points, self.ndim)
190
+ local = xp.asarray(self.inverse) @ (points - xp.asarray(self.offset)[:, None])
191
+ return self.source.contains_points(local)
192
+
193
+ def contains(self, coords):
194
+ return self.source.contains(self.inverse @ (np.asarray(coords) - self.offset))
195
+
196
+ def outlines(self, max_spacing=None):
197
+ """The source's outline loops, transformed (2D only); see ``Shape2D.outlines``.
198
+
199
+ The source is refined to ``max_spacing`` divided by the transform's largest
200
+ stretch, so the transformed vertices are at most ``max_spacing`` apart.
201
+ """
202
+ if self.ndim != 2 or not hasattr(self.source, "outlines"):
203
+ raise NotImplementedError("outlines() needs a 2D source with outlines")
204
+ stretch = float(np.linalg.norm(self.matrix, 2))
205
+ if max_spacing is None:
206
+ local = None
207
+ elif callable(max_spacing):
208
+
209
+ def local(points):
210
+ world = self.matrix @ points + self.offset[:, None]
211
+ return np.asarray(max_spacing(world), dtype=float) / stretch
212
+
213
+ else:
214
+ local = max_spacing / stretch
215
+ # a reflection reverses the loops; an open curve keeps its vertex order
216
+ flip = np.linalg.det(self.matrix) < 0 and self.closed
217
+ loops = []
218
+ for loop in self.source.outlines(local):
219
+ loop = self.matrix @ loop + self.offset[:, None]
220
+ loops.append(loop[:, ::-1] if flip else loop)
221
+ return loops
222
+
223
+ @property
224
+ def closed(self):
225
+ """Whether the source encloses an area (False for an open curve)."""
226
+ return getattr(self.source, "closed", True)
227
+
228
+ @property
229
+ def exact_outline(self):
230
+ """Whether the outline is exact: an affine map keeps a polygon a polygon."""
231
+ return getattr(self.source, "exact_outline", False)
232
+
233
+ def boundary_panels(self, max_spacing=None):
234
+ """The edges of :meth:`outlines` as panels; see ``Shape2D.boundary_panels``."""
235
+ from simgeometry.panels import boundary_panels
236
+
237
+ return boundary_panels(self, max_spacing)
238
+
239
+ @property
240
+ def named_objects(self):
241
+ if not hasattr(self.source, "named_objects"):
242
+ raise AttributeError("This transformed shape has no named children")
243
+ return {
244
+ name: obj.transformed(self.matrix, self.offset)
245
+ for name, obj in self.source.named_objects.items()
246
+ }
247
+
248
+ @property
249
+ def names(self):
250
+ return getattr(self.source, "names", ("inside",))
251
+
252
+ @property
253
+ def styles(self):
254
+ return getattr(self.source, "styles", {})
255
+
256
+ def labels(self, points):
257
+ if not hasattr(self.source, "labels"):
258
+ return mask(self, points).astype(int)
259
+ local = self.inverse @ (np.asarray(xp.to_numpy(points)) - self.offset[:, None])
260
+ return self.source.labels(local)
261
+
262
+ def evaluate(self, *args, quantity=None, **kwargs):
263
+ if quantity is None:
264
+ quantity = "labels" if hasattr(self.source, "labels") else "occupancy"
265
+ return super().evaluate(*args, quantity=quantity, **kwargs)
266
+
267
+
268
+ def rotation(shape, angle, *, axis=(0, 0, 1), origin=None):
269
+ angle = np.deg2rad(float(angle))
270
+ c, s = np.cos(angle), np.sin(angle)
271
+ if shape.ndim == 2:
272
+ matrix = np.array([[c, -s], [s, c]])
273
+ elif shape.ndim == 3:
274
+ axis = np.asarray(axis, float)
275
+ if (
276
+ axis.shape != (3,)
277
+ or not np.all(np.isfinite(axis))
278
+ or np.linalg.norm(axis) == 0
279
+ ):
280
+ raise ValueError("Rotation axis must be a finite nonzero 3-vector")
281
+ axis /= np.linalg.norm(axis)
282
+ x, y, z = axis
283
+ cross = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]])
284
+ matrix = c * np.eye(3) + (1 - c) * np.outer(axis, axis) + s * cross
285
+ else:
286
+ raise ValueError("Rotation requires 2D or 3D geometry")
287
+ origin = np.zeros(shape.ndim) if origin is None else np.asarray(origin, float)
288
+ return shape.transformed(matrix, origin - matrix @ origin)
289
+
290
+
291
+ class Section(Shape):
292
+ """A membership-preserving section, including holes and disconnected pieces.
293
+
294
+ ``to_world`` and ``to_local`` use component-major arrays. ``to_local`` is
295
+ an orthogonal projection. For oblique planes, x/y denote plane coordinates.
296
+ """
297
+
298
+ def __init__(self, source, *, x=None, y=None, z=None, origin=None, normal=None):
299
+ if source.ndim < 2:
300
+ raise ValueError("Cannot slice one-dimensional geometry")
301
+ self.source = copy.deepcopy(source)
302
+ selection = {k: v for k, v in zip("xyz", (x, y, z)) if v is not None}
303
+ if origin is not None or normal is not None:
304
+ if selection or source.ndim != 3 or origin is None or normal is None:
305
+ raise ValueError(
306
+ "An oblique section requires only a 3D origin and normal"
307
+ )
308
+ self.origin, n = np.asarray(origin, float), np.asarray(normal, float)
309
+ if (
310
+ self.origin.shape != (3,)
311
+ or n.shape != (3,)
312
+ or not np.all(np.isfinite(n))
313
+ or np.linalg.norm(n) == 0
314
+ ):
315
+ raise ValueError("Plane origin and nonzero normal must be 3-vectors")
316
+ n = n / np.linalg.norm(n)
317
+ u = np.eye(3)[np.argmin(np.abs(n))]
318
+ u -= n * np.dot(u, n)
319
+ u /= np.linalg.norm(u)
320
+ self.basis = np.column_stack((u, np.cross(n, u)))
321
+ self.axis_names = ("x", "y")
322
+ else:
323
+ if len(selection) != 1:
324
+ raise ValueError("Fix exactly one axis to form a section")
325
+ name, value = next(iter(selection.items()))
326
+ source_names = getattr(source, "axis_names", tuple("xyz"[: source.ndim]))
327
+ if name not in source_names or not np.isscalar(value):
328
+ raise ValueError(
329
+ "Slice axis must exist and its position must be scalar"
330
+ )
331
+ axis = source_names.index(name)
332
+ self.origin = np.zeros(source.ndim)
333
+ self.origin[axis] = float(value)
334
+ remaining = [i for i in range(source.ndim) if i != axis]
335
+ self.basis = np.eye(source.ndim)[:, remaining]
336
+ self.axis_names = tuple(source_names[i] for i in remaining)
337
+ if not np.all(np.isfinite(self.origin)):
338
+ raise ValueError("Plane origin must be finite")
339
+ vertices = self.basis.T @ (corners(source) - self.origin[:, None])
340
+ self._inverted = source.inverted
341
+ super().__init__(source.ndim - 1, vertices.min(axis=1), vertices.max(axis=1))
342
+
343
+ def _to_parent(self, points):
344
+ points = np.asarray(points, float)
345
+ return (
346
+ self.origin + self.basis @ points
347
+ if points.ndim == 1
348
+ else self.origin[:, None] + self.basis @ points
349
+ )
350
+
351
+ def to_world(self, points):
352
+ """Map section coordinates back through any parent sections."""
353
+ parent = self._to_parent(points)
354
+ return (
355
+ self.source.to_world(parent) if isinstance(self.source, Section) else parent
356
+ )
357
+
358
+ def to_local(self, points):
359
+ """Orthogonally project world points through any parent sections."""
360
+ points = np.asarray(points, float)
361
+ if isinstance(self.source, Section):
362
+ points = self.source.to_local(points)
363
+ return self.basis.T @ (
364
+ points - self.origin if points.ndim == 1 else points - self.origin[:, None]
365
+ )
366
+
367
+ def contains_points(self, points):
368
+ points = np.asarray(xp.to_numpy(_component_major(points, self.ndim)), float)
369
+ return self.source.contains_points(self._to_parent(points))
370
+
371
+ def contains(self, coords):
372
+ return self.source.contains(self._to_parent(np.atleast_1d(coords)))
373
+
374
+ def labels(self, points):
375
+ if not hasattr(self.source, "labels"):
376
+ return mask(self, points).astype(int)
377
+ return self.source.labels(self._to_parent(points))
378
+
379
+ def evaluate(self, *args, quantity=None, **kwargs):
380
+ if quantity is None:
381
+ quantity = "labels" if hasattr(self.source, "labels") else "occupancy"
382
+ return super().evaluate(*args, quantity=quantity, **kwargs)
383
+
384
+ @property
385
+ def names(self):
386
+ return getattr(self.source, "names", ("inside",))
simgeometry/panels.py ADDED
@@ -0,0 +1,80 @@
1
+ """Boundary panels: the straight edges of a 2D outline, for boundary-element solvers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Panels:
12
+ """Straight panels along the boundary of a 2D shape, as component-major host arrays.
13
+
14
+ ``start`` and ``end`` are ``(2, n)``; ``piece`` ``(n,)`` is the index of the
15
+ outline loop (see ``outlines()``) each panel belongs to, so the panels of one loop
16
+ are contiguous and in order. ``closed`` is False for an open curve.
17
+
18
+ :attr:`normal` is the unit normal to the right of each panel's direction. Loops
19
+ keep the shape on their left, so for a closed shape it points out of the
20
+ geometry, at outer boundaries and holes alike (inversion is ignored, as in
21
+ ``outlines()``). For an open curve it is the side to the right of the curve's
22
+ direction, from its first vertex to its last.
23
+ """
24
+
25
+ start: np.ndarray
26
+ end: np.ndarray
27
+ piece: np.ndarray
28
+ closed: bool = True
29
+
30
+ def __len__(self) -> int:
31
+ return int(self.start.shape[1])
32
+
33
+ @property
34
+ def midpoint(self) -> np.ndarray:
35
+ """Panel midpoints, ``(2, n)``."""
36
+ return 0.5 * (self.start + self.end)
37
+
38
+ @property
39
+ def length(self) -> np.ndarray:
40
+ """Panel lengths, ``(n,)``."""
41
+ return np.hypot(*(self.end - self.start))
42
+
43
+ @property
44
+ def tangent(self) -> np.ndarray:
45
+ """Unit tangents from start to end, ``(2, n)``."""
46
+ return (self.end - self.start) / self.length
47
+
48
+ @property
49
+ def normal(self) -> np.ndarray:
50
+ """Unit normals to the right of the tangents, ``(2, n)``."""
51
+ tx, ty = self.tangent
52
+ return np.stack((ty, -tx))
53
+
54
+
55
+ def boundary_panels(shape, max_spacing=None) -> Panels:
56
+ """The edges of ``shape.outlines(max_spacing)`` as :class:`Panels`.
57
+
58
+ Each edge of a closed loop is a panel, including the one back to its first
59
+ vertex; an open curve has one panel fewer than vertices. Zero-length edges are
60
+ dropped.
61
+ """
62
+ closed = bool(getattr(shape, "closed", True))
63
+ starts, ends, pieces = [], [], []
64
+ for index, loop in enumerate(shape.outlines(max_spacing)):
65
+ loop = np.asarray(loop, dtype=float)
66
+ start = loop if closed else loop[:, :-1]
67
+ end = np.roll(loop, -1, axis=1) if closed else loop[:, 1:]
68
+ keep = np.hypot(*(end - start)) > 0
69
+ starts.append(start[:, keep])
70
+ ends.append(end[:, keep])
71
+ pieces.append(np.full(int(keep.sum()), index))
72
+ if not starts:
73
+ empty = np.zeros((2, 0))
74
+ return Panels(empty, empty.copy(), np.zeros(0, dtype=int), closed)
75
+ return Panels(
76
+ np.concatenate(starts, axis=1),
77
+ np.concatenate(ends, axis=1),
78
+ np.concatenate(pieces),
79
+ closed,
80
+ )
@@ -0,0 +1,179 @@
1
+ """Optional Matplotlib drawing for geometric shapes and domains."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from simgeometry.shapes import Shape2D
11
+ from simgeometry.simulation_domain import SimulationDomain
12
+
13
+ # Draw order: grid lines, objects, then the domain outline.
14
+ ZORDER_GRID = 1.5
15
+ ZORDER_OBJECT = 3.0
16
+ ZORDER_FRAME = 3.5
17
+
18
+
19
+ def _axes(ax: Any | None) -> Any:
20
+ if ax is None:
21
+ import matplotlib.pyplot as plt
22
+
23
+ ax = plt.gca()
24
+ return ax
25
+
26
+
27
+ def _region(
28
+ shape: Shape2D,
29
+ bounds: tuple[Sequence[float], Sequence[float]] | None,
30
+ ) -> tuple[float, float, float, float]:
31
+ """Return ``(x_min, y_min, x_max, y_max)`` of the region to draw ``shape`` in.
32
+
33
+ An inverted shape covers everything outside its geometry, so it is drawn
34
+ over ``bounds`` (the simulation domain) when given; otherwise, and for a
35
+ regular shape, over the shape's bounding box.
36
+ """
37
+ lower, upper = (
38
+ bounds
39
+ if bounds is not None and shape.inverted
40
+ else (shape.lower_bound, shape.upper_bound)
41
+ )
42
+ x_min, y_min = (float(v) for v in lower)
43
+ x_max, y_max = (float(v) for v in upper)
44
+ return x_min, y_min, x_max, y_max
45
+
46
+
47
+ def _signed_area(vertices: np.ndarray) -> float:
48
+ x, y = vertices[:, 0], vertices[:, 1]
49
+ return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y))
50
+
51
+
52
+ def _draw_function_object(
53
+ shape: Any,
54
+ ax: Any,
55
+ resolution: int = 128,
56
+ bounds: tuple[Sequence[float], Sequence[float]] | None = None,
57
+ **kwargs: Any,
58
+ ) -> Any:
59
+ x_min, y_min, x_max, y_max = _region(shape, bounds)
60
+ xs = np.linspace(x_min, x_max, resolution)
61
+ ys = np.linspace(y_min, y_max, resolution)
62
+ mask = np.asarray([[shape.contains((x, y)) for x in xs] for y in ys], dtype=bool)
63
+ if not mask.any():
64
+ return ax
65
+ style = {
66
+ "origin": "lower",
67
+ "extent": [x_min, x_max, y_min, y_max],
68
+ "cmap": "Greys",
69
+ "vmin": 0.0,
70
+ "vmax": 1.0,
71
+ "aspect": "auto",
72
+ "zorder": ZORDER_OBJECT,
73
+ **kwargs,
74
+ }
75
+ ax.imshow(np.ma.masked_where(~mask, mask.astype(float)), **style)
76
+ return ax
77
+
78
+
79
+ def draw_shape(
80
+ shape: Shape2D,
81
+ ax: Any | None = None,
82
+ bounds: tuple[Sequence[float], Sequence[float]] | None = None,
83
+ **kwargs: Any,
84
+ ) -> Any:
85
+ """Draw a 2D shape as filled polygons (a mask for ``FunctionObject2D``).
86
+
87
+ An inverted shape is drawn as the region ``bounds = (lower, upper)`` (the
88
+ simulation domain; the shape's bounding box by default) with its geometry
89
+ cut out.
90
+ """
91
+ from matplotlib.patches import PathPatch, Polygon
92
+ from matplotlib.path import Path
93
+
94
+ from simgeometry.objects.function_object import FunctionObject2D
95
+
96
+ ax = _axes(ax)
97
+ if isinstance(shape, FunctionObject2D):
98
+ return _draw_function_object(shape, ax, bounds=bounds, **kwargs)
99
+
100
+ style = {
101
+ "facecolor": "gray",
102
+ "edgecolor": "black",
103
+ "zorder": ZORDER_OBJECT,
104
+ **kwargs,
105
+ }
106
+ if not getattr(shape, "closed", True):
107
+ # an open curve: draw its line, there is no area to fill
108
+ line_style = {"color": style["edgecolor"], "zorder": style["zorder"]}
109
+ for loop in shape.outlines():
110
+ ax.plot(*loop, **line_style)
111
+ ax.autoscale_view()
112
+ return ax
113
+ polygons = [loop.T for loop in shape.outlines()]
114
+ if not shape.inverted:
115
+ for vertices in polygons:
116
+ ax.add_patch(Polygon(vertices, closed=True, **style))
117
+ ax.autoscale_view()
118
+ return ax
119
+
120
+ # Fill the region counter-clockwise and the geometry clockwise, so that
121
+ # the geometry is a hole under matplotlib's nonzero winding rule.
122
+ x_min, y_min, x_max, y_max = _region(shape, bounds)
123
+ outer = np.array([[x_min, y_min], [x_max, y_min], [x_max, y_max], [x_min, y_max]])
124
+ holes = [v if _signed_area(v) < 0.0 else v[::-1] for v in polygons if len(v) >= 3]
125
+ path = Path.make_compound_path(
126
+ *(Path(np.vstack([ring, ring[:1]]), closed=True) for ring in (outer, *holes))
127
+ )
128
+ ax.add_patch(PathPatch(path, **style))
129
+ ax.autoscale_view()
130
+ return ax
131
+
132
+
133
+ def draw_domain(
134
+ domain: SimulationDomain,
135
+ ax: Any | None = None,
136
+ show_grid: bool = False,
137
+ objects: bool = True,
138
+ **kwargs: Any,
139
+ ) -> Any:
140
+ """Draw the domain outline, optionally its grid lines, and its objects."""
141
+ from matplotlib.collections import LineCollection
142
+
143
+ ax = _axes(ax)
144
+ style = {"color": "black", "linewidth": 1.0, "zorder": ZORDER_FRAME, **kwargs}
145
+ lower = [float(bound) for bound in domain.lower_bound]
146
+ upper = [float(bound) for bound in domain.upper_bound]
147
+
148
+ if domain.ndim == 1:
149
+ ax.axvline(lower[0], **style)
150
+ ax.axvline(upper[0], **style)
151
+ return ax
152
+ if domain.ndim != 2:
153
+ raise ValueError(
154
+ f"draw_domain draws 1D and 2D domains, got ndim={domain.ndim}; "
155
+ "use Plotter3D for 3D geometry"
156
+ )
157
+
158
+ (x0, y0), (x1, y1) = lower, upper
159
+ ax.plot([x0, x1, x1, x0, x0], [y0, y0, y1, y1, y0], **style)
160
+
161
+ if show_grid:
162
+ xs, ys = (np.asarray(points) for points in domain.grid.get_grid_points())
163
+ segments = [[(x, y0), (x, y1)] for x in xs[1:-1]]
164
+ segments += [[(x0, y), (x1, y)] for y in ys[1:-1]]
165
+ ax.add_collection(
166
+ LineCollection(
167
+ segments,
168
+ colors="gray",
169
+ linewidths=0.5,
170
+ alpha=0.6,
171
+ zorder=ZORDER_GRID,
172
+ )
173
+ )
174
+
175
+ if objects:
176
+ for shape in domain.objects:
177
+ if isinstance(shape, Shape2D):
178
+ draw_shape(shape, ax=ax, bounds=(lower, upper))
179
+ return ax
simgeometry/py.typed ADDED
File without changes