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,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
|
+
)
|
simgeometry/plotting.py
ADDED
|
@@ -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
|