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,214 @@
|
|
|
1
|
+
"""Function-based geometric object definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
import cunumpy as xp
|
|
8
|
+
|
|
9
|
+
from simgeometry._types import Array, BoundsLike, ConfigValue
|
|
10
|
+
from simgeometry.shapes import Shape2D, Shape3D
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FunctionObject2D(Shape2D):
|
|
14
|
+
"""Represent an arbitrary 2D geometry object defined by a Boolean function.
|
|
15
|
+
|
|
16
|
+
The object interior is the set of all points for which ``func(coords)``
|
|
17
|
+
returns ``True``. A bounding box must be supplied by the caller because
|
|
18
|
+
it cannot in general be derived from an arbitrary function.
|
|
19
|
+
|
|
20
|
+
Example
|
|
21
|
+
-------
|
|
22
|
+
Create a ring (annulus) with inner radius 1 and outer radius 2::
|
|
23
|
+
|
|
24
|
+
def ring(coords):
|
|
25
|
+
x, y = coords
|
|
26
|
+
r2 = x**2 + y**2
|
|
27
|
+
return 1.0**2 <= r2 <= 2.0**2
|
|
28
|
+
|
|
29
|
+
obj = FunctionObject2D(
|
|
30
|
+
func=ring,
|
|
31
|
+
lower_bound=(-2.0, -2.0),
|
|
32
|
+
upper_bound=(2.0, 2.0),
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
func: Callable[[Array], bool],
|
|
39
|
+
lower_bound: BoundsLike,
|
|
40
|
+
upper_bound: BoundsLike,
|
|
41
|
+
bc: ConfigValue = None,
|
|
42
|
+
inverted: bool = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Initialize a function-based 2D object.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
func:
|
|
50
|
+
A callable that accepts a coordinate array ``(x, y)`` and returns
|
|
51
|
+
``True`` if the point lies inside the object.
|
|
52
|
+
lower_bound:
|
|
53
|
+
Lower corner ``(x_min, y_min)`` of the bounding box.
|
|
54
|
+
upper_bound:
|
|
55
|
+
Upper corner ``(x_max, y_max)`` of the bounding box.
|
|
56
|
+
bc:
|
|
57
|
+
Optional boundary condition information.
|
|
58
|
+
inverted:
|
|
59
|
+
If ``True``, the object covers every point where ``func`` is falsy.
|
|
60
|
+
"""
|
|
61
|
+
assert callable(func), "func must be callable."
|
|
62
|
+
assert len(lower_bound) == 2, "lower_bound must have two coordinates."
|
|
63
|
+
assert len(upper_bound) == 2, "upper_bound must have two coordinates."
|
|
64
|
+
|
|
65
|
+
self._func = func
|
|
66
|
+
self._bc = bc
|
|
67
|
+
self._inverted = bool(inverted)
|
|
68
|
+
|
|
69
|
+
super().__init__(
|
|
70
|
+
lower_bound=tuple(lower_bound),
|
|
71
|
+
upper_bound=tuple(upper_bound),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def func(self) -> Callable[[Array], bool]:
|
|
76
|
+
"""Return the Boolean membership function."""
|
|
77
|
+
return self._func
|
|
78
|
+
|
|
79
|
+
def contains(self, coords: Array) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Check if the given coordinates are inside the object.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
coords:
|
|
86
|
+
``(x, y)`` coordinates to test.
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
bool
|
|
91
|
+
``True`` if ``func(coords)`` is truthy (falsy when inverted).
|
|
92
|
+
"""
|
|
93
|
+
return self._apply_inversion(bool(self._func(xp.to_numpy(coords))))
|
|
94
|
+
|
|
95
|
+
def plot(self, ax: Any = None, **kwargs: Any) -> Any:
|
|
96
|
+
"""Draw the object as a sampled mask into ``ax`` and return the axes.
|
|
97
|
+
|
|
98
|
+
``resolution`` (samples per axis) and ``bounds`` set the sampling of the
|
|
99
|
+
mask, other keyword arguments style the image. With an inspection option
|
|
100
|
+
(``x``, ``y``, ``z``, ``grid``, ``nodes``, ``normals``) this is
|
|
101
|
+
:meth:`Shape2D.plot`.
|
|
102
|
+
"""
|
|
103
|
+
if any(key in kwargs for key in ("x", "y", "z", "grid", "nodes", "normals")):
|
|
104
|
+
return super().plot(ax=ax, **kwargs)
|
|
105
|
+
from simgeometry.plotting import draw_shape
|
|
106
|
+
|
|
107
|
+
return draw_shape(self, ax=ax, **kwargs)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class FunctionObject3D(Shape3D):
|
|
111
|
+
"""Represent an arbitrary 3D geometry object defined by a Boolean function.
|
|
112
|
+
|
|
113
|
+
The object interior is the set of all points for which ``func(coords)``
|
|
114
|
+
returns ``True``. A bounding box must be supplied by the caller because
|
|
115
|
+
it cannot in general be derived from an arbitrary function.
|
|
116
|
+
|
|
117
|
+
Example
|
|
118
|
+
-------
|
|
119
|
+
Create a solid torus with major radius 3 and tube radius 1::
|
|
120
|
+
|
|
121
|
+
def torus(coords):
|
|
122
|
+
x, y, z = coords
|
|
123
|
+
r = (x**2 + y**2)**0.5
|
|
124
|
+
return (r - 3.0)**2 + z**2 <= 1.0**2
|
|
125
|
+
|
|
126
|
+
obj = FunctionObject3D(
|
|
127
|
+
func=torus,
|
|
128
|
+
lower_bound=(-4.0, -4.0, -1.0),
|
|
129
|
+
upper_bound=(4.0, 4.0, 1.0),
|
|
130
|
+
)
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
func: Callable[[Array], bool],
|
|
136
|
+
lower_bound: BoundsLike,
|
|
137
|
+
upper_bound: BoundsLike,
|
|
138
|
+
bc: ConfigValue = None,
|
|
139
|
+
inverted: bool = False,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Initialize a function-based 3D object.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
func:
|
|
147
|
+
A callable that accepts a coordinate array ``(x, y, z)`` and
|
|
148
|
+
returns ``True`` if the point lies inside the object.
|
|
149
|
+
lower_bound:
|
|
150
|
+
Lower corner ``(x_min, y_min, z_min)`` of the bounding box.
|
|
151
|
+
upper_bound:
|
|
152
|
+
Upper corner ``(x_max, y_max, z_max)`` of the bounding box.
|
|
153
|
+
bc:
|
|
154
|
+
Optional boundary condition information.
|
|
155
|
+
inverted:
|
|
156
|
+
If ``True``, the object covers every point where ``func`` is falsy.
|
|
157
|
+
"""
|
|
158
|
+
assert callable(func), "func must be callable."
|
|
159
|
+
assert len(lower_bound) == 3, "lower_bound must have three coordinates."
|
|
160
|
+
assert len(upper_bound) == 3, "upper_bound must have three coordinates."
|
|
161
|
+
|
|
162
|
+
self._func = func
|
|
163
|
+
self._bc = bc
|
|
164
|
+
self._inverted = bool(inverted)
|
|
165
|
+
|
|
166
|
+
super().__init__(
|
|
167
|
+
lower_bound=tuple(lower_bound),
|
|
168
|
+
upper_bound=tuple(upper_bound),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def func(self) -> Callable[[Array], bool]:
|
|
173
|
+
"""Return the Boolean membership function."""
|
|
174
|
+
return self._func
|
|
175
|
+
|
|
176
|
+
def contains(self, coords: Array) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
Check if the given coordinates are inside the object.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
coords:
|
|
183
|
+
``(x, y, z)`` coordinates to test.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
bool
|
|
188
|
+
``True`` if ``func(coords)`` is truthy (falsy when inverted).
|
|
189
|
+
"""
|
|
190
|
+
return self._apply_inversion(bool(self._func(xp.to_numpy(coords))))
|
|
191
|
+
|
|
192
|
+
def get_vertices(
|
|
193
|
+
self,
|
|
194
|
+
slice_axis: int = 0,
|
|
195
|
+
slice_position: float | None = None,
|
|
196
|
+
) -> tuple:
|
|
197
|
+
"""Return an empty array; vertices cannot be derived from an arbitrary function."""
|
|
198
|
+
import cunumpy as xp
|
|
199
|
+
|
|
200
|
+
return xp.array([]), self.inverted
|
|
201
|
+
|
|
202
|
+
def volume(self) -> float:
|
|
203
|
+
"""Volume cannot be analytically derived from an arbitrary function."""
|
|
204
|
+
raise NotImplementedError(
|
|
205
|
+
"volume() is not available for FunctionObject3D. "
|
|
206
|
+
"Use numerical integration if needed.",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def surface_area(self) -> float:
|
|
210
|
+
"""Surface area cannot be analytically derived from an arbitrary function."""
|
|
211
|
+
raise NotImplementedError(
|
|
212
|
+
"surface_area() is not available for FunctionObject3D. "
|
|
213
|
+
"Use numerical methods if needed.",
|
|
214
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Line helpers for geometric object definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
|
|
7
|
+
from simgeometry._types import Array, BoundsLike, ConfigValue
|
|
8
|
+
from simgeometry.shapes import Shape1D, _component_major
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Line(Shape1D):
|
|
12
|
+
"""Represent a line geometry object."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
midpoint: BoundsLike,
|
|
17
|
+
side_lengths: BoundsLike,
|
|
18
|
+
bc: ConfigValue = None,
|
|
19
|
+
inverted: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Initialize a line in 1D space.
|
|
23
|
+
|
|
24
|
+
Parameters:
|
|
25
|
+
midpoint (list or tuple): Coordinates of the midpoint of the rectangle.
|
|
26
|
+
side_lengths (list or tuple): Lengths of the sides of the rectangle in each dimension.
|
|
27
|
+
bc (optional): Boundary condition information.
|
|
28
|
+
inverted (bool, optional): Cover everything except the line.
|
|
29
|
+
"""
|
|
30
|
+
assert len(midpoint) == 1, (
|
|
31
|
+
f"Length of midpoint must match ndim (2). {len(midpoint) =}"
|
|
32
|
+
)
|
|
33
|
+
assert len(side_lengths) == 1, "Length of side_lengths must match ndim."
|
|
34
|
+
|
|
35
|
+
self.midpoint = xp.array(midpoint)
|
|
36
|
+
self.side_lengths = xp.array(side_lengths)
|
|
37
|
+
self._bc = bc
|
|
38
|
+
self._inverted = bool(inverted)
|
|
39
|
+
|
|
40
|
+
# Calculate bounds based on the midpoint and side lengths
|
|
41
|
+
half_lengths = self.side_lengths / 2
|
|
42
|
+
lower_bound = tuple(self.midpoint - half_lengths)
|
|
43
|
+
upper_bound = tuple(self.midpoint + half_lengths)
|
|
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 line across a specified axis at a given position.
|
|
53
|
+
|
|
54
|
+
Parameters:
|
|
55
|
+
axis (int): Axis index (0 for x-axis).
|
|
56
|
+
position (float): Position along the axis to mirror.
|
|
57
|
+
"""
|
|
58
|
+
if axis != 0:
|
|
59
|
+
raise ValueError("Axis must be 0 (x-axis).")
|
|
60
|
+
|
|
61
|
+
# Mirror the midpoint coordinate along the specified axis
|
|
62
|
+
self.midpoint[axis] = 2 * position - self.midpoint[axis]
|
|
63
|
+
|
|
64
|
+
half_lengths = self.side_lengths / 2
|
|
65
|
+
lower_bound = tuple(self.midpoint - half_lengths)
|
|
66
|
+
upper_bound = tuple(self.midpoint + half_lengths)
|
|
67
|
+
|
|
68
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
69
|
+
|
|
70
|
+
def copy(self) -> Line:
|
|
71
|
+
"""
|
|
72
|
+
Create a copy of the line.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Line: A new instance of Line with the same midpoint, side lengths, and boundary conditions.
|
|
76
|
+
"""
|
|
77
|
+
return Line(
|
|
78
|
+
midpoint=self.midpoint.copy(),
|
|
79
|
+
side_lengths=self.side_lengths.copy(),
|
|
80
|
+
bc=self._bc,
|
|
81
|
+
inverted=self._inverted,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def translate(self, offset: tuple[float]) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Translate the line by the given offset.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
offset (tuple): (dx,) translation vector.
|
|
90
|
+
"""
|
|
91
|
+
offset_vec = xp.asarray(offset)
|
|
92
|
+
|
|
93
|
+
# Apply translation
|
|
94
|
+
self.midpoint = self.midpoint + offset_vec
|
|
95
|
+
|
|
96
|
+
half_lengths = self.side_lengths / 2
|
|
97
|
+
lower_bound = tuple(self.midpoint - half_lengths)
|
|
98
|
+
upper_bound = tuple(self.midpoint + half_lengths)
|
|
99
|
+
|
|
100
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
101
|
+
|
|
102
|
+
def get_vertices(self) -> ConfigValue:
|
|
103
|
+
"""Return the vertices that define the object."""
|
|
104
|
+
return [(self.lower_bound[0], self.upper_bound[0])]
|
|
105
|
+
|
|
106
|
+
def contains(self, coords: Array) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Check if the given coordinates are inside the line.
|
|
109
|
+
|
|
110
|
+
Parameters:
|
|
111
|
+
coords (list or tuple): Coordinates for each dimension.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
bool: True if the coordinates are inside the line, False otherwise.
|
|
115
|
+
"""
|
|
116
|
+
assert len(coords) == 1, f"Expected 1 dimension, got {len(coords)}."
|
|
117
|
+
|
|
118
|
+
coords = xp.to_numpy(coords)
|
|
119
|
+
return self._apply_inversion(
|
|
120
|
+
bool(self.lower_bound[0] <= coords[0] <= self.upper_bound[0])
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def contains_points(self, points: Array) -> Array:
|
|
124
|
+
"""Vectorised :meth:`contains`: a mask of the ``(1, n)`` points inside."""
|
|
125
|
+
points = _component_major(points, 1)
|
|
126
|
+
inside = (self.lower_bound[0] <= points[0]) & (points[0] <= self.upper_bound[0])
|
|
127
|
+
return ~inside if self.inverted else inside
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Polygon helpers for geometric object definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy import ndarray
|
|
8
|
+
|
|
9
|
+
from simgeometry.kernels.contains_polygon_2d import kernel as contains_polygon_2d
|
|
10
|
+
from simgeometry.shapes import Shape2D, _component_major
|
|
11
|
+
from simgeometry.utils import vertices_to_bounds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Polygon(Shape2D):
|
|
15
|
+
"""Represent a polygon geometry object."""
|
|
16
|
+
|
|
17
|
+
exact_outline = True
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
vertices: ndarray,
|
|
22
|
+
bc: float | None = None,
|
|
23
|
+
inverted: bool = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Initialize a polygon in 2D space.
|
|
27
|
+
|
|
28
|
+
Parameters:
|
|
29
|
+
vertices (list or tuple): List of tuples, each representing a vertex (x, y).
|
|
30
|
+
bc (optional): Boundary condition information.
|
|
31
|
+
inverted (bool, optional): Cover everything except the polygon.
|
|
32
|
+
"""
|
|
33
|
+
assert len(vertices) >= 3, "Polygon must have at least three vertices."
|
|
34
|
+
assert all(len(vertex) == 2 for vertex in vertices), (
|
|
35
|
+
"Each vertex must have two coordinates."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self.vertices = xp.array(vertices) # Use setter
|
|
39
|
+
self._bc = bc
|
|
40
|
+
self._inverted = bool(inverted)
|
|
41
|
+
|
|
42
|
+
# Calculate bounds tuple
|
|
43
|
+
lower_bound, upper_bound = vertices_to_bounds(self.vertices)
|
|
44
|
+
|
|
45
|
+
super().__init__(
|
|
46
|
+
lower_bound=lower_bound,
|
|
47
|
+
upper_bound=upper_bound,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def vertices(self) -> ndarray:
|
|
52
|
+
"""Return object vertices."""
|
|
53
|
+
return self._vertices
|
|
54
|
+
|
|
55
|
+
@vertices.setter
|
|
56
|
+
def vertices(self, vertices: ndarray) -> None:
|
|
57
|
+
"""Set the vertices."""
|
|
58
|
+
self._vertices = vertices
|
|
59
|
+
|
|
60
|
+
def copy(self) -> Polygon:
|
|
61
|
+
"""
|
|
62
|
+
Create a copy of the polygon.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Polygon: A new instance of Polygon with the same vertices and boundary conditions.
|
|
66
|
+
"""
|
|
67
|
+
return Polygon(
|
|
68
|
+
vertices=self.vertices.copy(),
|
|
69
|
+
bc=self._bc,
|
|
70
|
+
inverted=self._inverted,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def translate(self, offset: tuple[float, float]) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Translate the polygon by the given offset.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
offset (tuple): (dx, dy) translation vector.
|
|
79
|
+
"""
|
|
80
|
+
offset_vec = xp.asarray(offset) # , dtype=self._vertices.dtype)
|
|
81
|
+
|
|
82
|
+
# Apply translation
|
|
83
|
+
self.vertices = self.vertices + offset_vec
|
|
84
|
+
|
|
85
|
+
lower_bound, upper_bound = vertices_to_bounds(self.vertices)
|
|
86
|
+
|
|
87
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
88
|
+
|
|
89
|
+
# TODO: Enable mirroring along arbitrary axes
|
|
90
|
+
def mirror(self, axis: int, position: float) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Mirror the polygon across a specified axis at a given position.
|
|
93
|
+
|
|
94
|
+
Parameters:
|
|
95
|
+
axis (int): Axis to mirror across (0 for x-axis, 1 for y-axis).
|
|
96
|
+
position (float): Position along the axis to mirror.
|
|
97
|
+
"""
|
|
98
|
+
assert axis in (0, 1), "Axis must be 0 (x-axis) or 1 (y-axis)."
|
|
99
|
+
|
|
100
|
+
# Create a copy of the vertices to avoid modifying during iteration
|
|
101
|
+
new_vertices = self.vertices.copy()
|
|
102
|
+
|
|
103
|
+
# Apply mirroring
|
|
104
|
+
for i in range(len(new_vertices)):
|
|
105
|
+
if axis == 0: # Mirror across x-axis
|
|
106
|
+
new_vertices[i][1] = 2 * position - new_vertices[i][1]
|
|
107
|
+
else: # Mirror across y-axis
|
|
108
|
+
new_vertices[i][0] = 2 * position - new_vertices[i][0]
|
|
109
|
+
|
|
110
|
+
self.vertices = new_vertices
|
|
111
|
+
|
|
112
|
+
lower_bound, upper_bound = vertices_to_bounds(self.vertices)
|
|
113
|
+
|
|
114
|
+
self.set_bounding_box(lower_bound, upper_bound)
|
|
115
|
+
|
|
116
|
+
def _corner_loops(self) -> list[np.ndarray]:
|
|
117
|
+
"""The polygon's vertices as one ``(2, n)`` loop."""
|
|
118
|
+
return [np.asarray(xp.to_numpy(self._vertices), dtype=float).T.copy()]
|
|
119
|
+
|
|
120
|
+
def _contains_polygon(self, coords: ndarray) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Check if the given coordinates are inside the polygon.
|
|
123
|
+
|
|
124
|
+
Parameters:
|
|
125
|
+
coords (list or tuple): Coordinates (x, y).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
bool: True if the point is inside the polygon, False otherwise.
|
|
129
|
+
"""
|
|
130
|
+
coords = xp.to_numpy(coords)
|
|
131
|
+
vertices = xp.to_numpy(self._vertices)
|
|
132
|
+
x, y = coords
|
|
133
|
+
|
|
134
|
+
# First check if the point is inside the bounding box
|
|
135
|
+
if not self.bounding_box.contains(coords):
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
# Ray casting algorithm for point-in-polygon
|
|
139
|
+
num_vertices = len(vertices)
|
|
140
|
+
inside = False
|
|
141
|
+
j = num_vertices - 1
|
|
142
|
+
for i in range(num_vertices):
|
|
143
|
+
xi, yi = vertices[i]
|
|
144
|
+
xj, yj = vertices[j]
|
|
145
|
+
if ((yi > y) != (yj > y)) and (
|
|
146
|
+
x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-16) + xi
|
|
147
|
+
):
|
|
148
|
+
inside = not inside
|
|
149
|
+
j = i
|
|
150
|
+
return inside
|
|
151
|
+
# return [self._vertices]
|
|
152
|
+
|
|
153
|
+
def contains(self, coords: ndarray) -> bool:
|
|
154
|
+
"""
|
|
155
|
+
Check if the given coordinates are inside the (possibly inverted) polygon.
|
|
156
|
+
|
|
157
|
+
Parameters:
|
|
158
|
+
coords (list or tuple): Coordinates (x, y).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
bool: True if the point is inside the object, False otherwise.
|
|
162
|
+
"""
|
|
163
|
+
return self._apply_inversion(self._contains_polygon(coords))
|
|
164
|
+
|
|
165
|
+
def contains_points(self, points: ndarray) -> ndarray:
|
|
166
|
+
"""Vectorised :meth:`contains`: a mask of the ``(2, n)`` points inside."""
|
|
167
|
+
points = xp.asarray(_component_major(points, 2), dtype=float)
|
|
168
|
+
if points.shape[1] == 0: # the compiled kernels reject empty arrays
|
|
169
|
+
return xp.zeros(0, dtype=bool)
|
|
170
|
+
vertices = xp.kernels.as_kernel_array(self._vertices, like=points, dtype=float)
|
|
171
|
+
lower = xp.asarray(self.bounding_box.lower_bound, dtype=float)[:, None]
|
|
172
|
+
upper = xp.asarray(self.bounding_box.upper_bound, dtype=float)[:, None]
|
|
173
|
+
inside = xp.empty(points.shape[1], dtype=bool)
|
|
174
|
+
contains_polygon_2d(
|
|
175
|
+
xp.kernels.as_kernel_array(points, like=points, dtype=float),
|
|
176
|
+
inside,
|
|
177
|
+
vertices,
|
|
178
|
+
0,
|
|
179
|
+
)
|
|
180
|
+
inside &= (points[0] >= lower[0]) & (points[0] < upper[0])
|
|
181
|
+
inside &= (points[1] >= lower[1]) & (points[1] < upper[1])
|
|
182
|
+
return ~inside if self.inverted else inside
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Open polylines: curves of zero thickness, such as thin plates and wires."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cunumpy as xp
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy import ndarray
|
|
8
|
+
|
|
9
|
+
from simgeometry._types import ConfigValue
|
|
10
|
+
from simgeometry.shapes import Shape2D, _component_major
|
|
11
|
+
from simgeometry.utils import vertices_to_bounds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Polyline(Shape2D):
|
|
15
|
+
"""An open curve through ``vertices``: a thin plate, wire or grid of zero thickness.
|
|
16
|
+
|
|
17
|
+
It encloses no area, so :meth:`contains` is always False and it cannot be
|
|
18
|
+
inverted or combined in Boolean shapes. Its geometry is its outline, an open
|
|
19
|
+
polyline from the first vertex to the last (``closed`` is False), which
|
|
20
|
+
:meth:`~simgeometry.shapes.Shape2D.boundary_panels` and ``crossings()`` use
|
|
21
|
+
directly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
exact_outline = True
|
|
25
|
+
closed = False
|
|
26
|
+
|
|
27
|
+
def __init__(self, vertices: ndarray, bc: ConfigValue = None) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize a polyline in 2D space.
|
|
30
|
+
|
|
31
|
+
Parameters:
|
|
32
|
+
vertices (list or tuple): At least two ``(x, y)`` vertices, in order along the
|
|
33
|
+
curve; consecutive vertices must differ.
|
|
34
|
+
bc (optional): Boundary condition information.
|
|
35
|
+
"""
|
|
36
|
+
points = np.asarray(xp.to_numpy(xp.asarray(vertices, dtype=float)), dtype=float)
|
|
37
|
+
if points.ndim != 2 or points.shape[1] != 2 or points.shape[0] < 2:
|
|
38
|
+
raise ValueError("A polyline needs at least two (x, y) vertices")
|
|
39
|
+
if np.any(np.hypot(*np.diff(points, axis=0).T) == 0):
|
|
40
|
+
raise ValueError("Consecutive polyline vertices must differ")
|
|
41
|
+
self._vertices = points
|
|
42
|
+
self._bc = bc
|
|
43
|
+
lower_bound, upper_bound = vertices_to_bounds(points)
|
|
44
|
+
super().__init__(lower_bound=lower_bound, upper_bound=upper_bound)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def vertices(self) -> ndarray:
|
|
48
|
+
"""The ``(n, 2)`` vertices, one row per point, as given."""
|
|
49
|
+
return self._vertices
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def length(self) -> float:
|
|
53
|
+
"""The length of the curve."""
|
|
54
|
+
return float(np.hypot(*np.diff(self._vertices, axis=0).T).sum())
|
|
55
|
+
|
|
56
|
+
def _corner_loops(self) -> list[np.ndarray]:
|
|
57
|
+
"""The vertices as one open ``(2, n)`` polyline."""
|
|
58
|
+
return [self._vertices.T.copy()]
|
|
59
|
+
|
|
60
|
+
def contains(self, coords: tuple[float, float]) -> bool:
|
|
61
|
+
"""Always False: a curve encloses no area."""
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def contains_points(self, points: ndarray) -> ndarray:
|
|
65
|
+
"""An all-False mask of the ``(2, n)`` points: a curve encloses no area."""
|
|
66
|
+
points = _component_major(points, 2)
|
|
67
|
+
return xp.zeros(points.shape[1], dtype=bool)
|