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
simgeometry/queries.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Surface queries, analytic for primitives and sampled for general membership.
|
|
2
|
+
|
|
3
|
+
All coordinates and results here are NumPy host arrays. The bisection helper
|
|
4
|
+
batches containment queries so GPU implementations can still accelerate them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import cunumpy as xp
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from simgeometry.objects import (
|
|
15
|
+
Circle,
|
|
16
|
+
Cuboid,
|
|
17
|
+
Line,
|
|
18
|
+
Polygon,
|
|
19
|
+
Rectangle,
|
|
20
|
+
Sphere,
|
|
21
|
+
Triangle,
|
|
22
|
+
)
|
|
23
|
+
from simgeometry.operations import BooleanShape, TransformedShape, mask
|
|
24
|
+
|
|
25
|
+
BALLS = (Circle, Sphere)
|
|
26
|
+
BOXES = (Rectangle, Cuboid, Line)
|
|
27
|
+
POLYGONS = (Polygon, Triangle)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def points_array(points, ndim):
|
|
31
|
+
p = np.asarray(xp.to_numpy(points), float)
|
|
32
|
+
single = p.ndim == 1
|
|
33
|
+
if single:
|
|
34
|
+
p = p[:, None]
|
|
35
|
+
if p.ndim != 2 or p.shape[0] != ndim or not np.all(np.isfinite(p)):
|
|
36
|
+
raise ValueError(f"Expected finite ({ndim}, n) or ({ndim},) coordinates")
|
|
37
|
+
return p, single
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def intersection_fractions(obj, outside, inside, *, iterations=48, wrap=None):
|
|
41
|
+
"""Bisect bracketed outside-to-inside edges; return raw, unclamped fractions.
|
|
42
|
+
|
|
43
|
+
Endpoints have shape (ndim, n). Each segment must bracket a single crossing;
|
|
44
|
+
use intersect_segment for multiple crossings or equal-membership endpoints.
|
|
45
|
+
``wrap`` maps periodic images into the domain for membership tests.
|
|
46
|
+
"""
|
|
47
|
+
outside, _ = points_array(outside, obj.ndim)
|
|
48
|
+
inside, _ = points_array(inside, obj.ndim)
|
|
49
|
+
if outside.shape != inside.shape or iterations < 1:
|
|
50
|
+
raise ValueError("Endpoints must match and iterations must be positive")
|
|
51
|
+
if wrap is None:
|
|
52
|
+
|
|
53
|
+
def wrap(p):
|
|
54
|
+
return p
|
|
55
|
+
|
|
56
|
+
if mask(obj, wrap(outside)).any() or not mask(obj, wrap(inside)).all():
|
|
57
|
+
raise ValueError("Bisection requires outside-to-inside brackets")
|
|
58
|
+
lo, hi = np.zeros(outside.shape[1]), np.ones(outside.shape[1])
|
|
59
|
+
step = inside - outside
|
|
60
|
+
for _ in range(iterations):
|
|
61
|
+
mid = (lo + hi) / 2
|
|
62
|
+
hit = mask(obj, wrap(outside + mid * step))
|
|
63
|
+
hi = np.where(hit, mid, hi)
|
|
64
|
+
lo = np.where(hit, lo, mid)
|
|
65
|
+
return hi
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class SegmentIntersections:
|
|
70
|
+
"""Ordered hits; points/normals have shape (ndim, nhits).
|
|
71
|
+
|
|
72
|
+
Fractions are in [0, 1]. ``entering`` is +1 entering, -1 leaving, 0 for
|
|
73
|
+
tangency. ``exact`` means analytic intersections rather than sampled search.
|
|
74
|
+
Object names are provided for collections. Normals can be NaN at singularities.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
fractions: np.ndarray
|
|
78
|
+
points: np.ndarray
|
|
79
|
+
normals: np.ndarray
|
|
80
|
+
entering: np.ndarray
|
|
81
|
+
object_names: tuple[str | None, ...]
|
|
82
|
+
exact: bool
|
|
83
|
+
|
|
84
|
+
def __len__(self):
|
|
85
|
+
return len(self.fractions)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _polygon_vertices(shape):
|
|
89
|
+
return np.asarray(xp.to_numpy(shape._vertices), float)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _candidates(shape, a, b, samples, tolerance):
|
|
93
|
+
d = b - a
|
|
94
|
+
if isinstance(shape, TransformedShape):
|
|
95
|
+
return _candidates(
|
|
96
|
+
shape.source,
|
|
97
|
+
shape.inverse @ (a - shape.offset),
|
|
98
|
+
shape.inverse @ (b - shape.offset),
|
|
99
|
+
samples,
|
|
100
|
+
tolerance,
|
|
101
|
+
)
|
|
102
|
+
if isinstance(shape, BooleanShape):
|
|
103
|
+
left, e1 = _candidates(shape.left, a, b, samples, tolerance)
|
|
104
|
+
right, e2 = _candidates(shape.right, a, b, samples, tolerance)
|
|
105
|
+
candidates = sorted(left + right)
|
|
106
|
+
kept = []
|
|
107
|
+
# Remove interfaces hidden by the Boolean operation. Tangencies remain
|
|
108
|
+
# if the membership at the contact differs from its neighbourhood.
|
|
109
|
+
for i, t in enumerate(candidates):
|
|
110
|
+
gaps = [abs(t - s) for s in candidates if abs(t - s) > tolerance]
|
|
111
|
+
epsilon = min([1e-6, *(g / 4 for g in gaps)])
|
|
112
|
+
state = mask(
|
|
113
|
+
shape, a[:, None] + d[:, None] * np.array([t - epsilon, t, t + epsilon])
|
|
114
|
+
)
|
|
115
|
+
if state[0] != state[2] or state[1] != state[0]:
|
|
116
|
+
kept.append(t)
|
|
117
|
+
return kept, e1 and e2
|
|
118
|
+
if isinstance(shape, BALLS):
|
|
119
|
+
rel = a - np.asarray(shape.midpoint)
|
|
120
|
+
aa = np.dot(d, d)
|
|
121
|
+
centre = -np.dot(rel, d) / aa
|
|
122
|
+
nearest = rel + centre * d
|
|
123
|
+
radial = shape.radius**2 - np.dot(nearest, nearest)
|
|
124
|
+
roundoff = (
|
|
125
|
+
32 * np.finfo(float).eps * max(shape.radius**2, np.dot(nearest, nearest))
|
|
126
|
+
)
|
|
127
|
+
if radial < -roundoff:
|
|
128
|
+
return [], True
|
|
129
|
+
half = np.sqrt(max(radial, 0) / aa)
|
|
130
|
+
return [centre - half, centre + half], True
|
|
131
|
+
if isinstance(shape, BOXES):
|
|
132
|
+
lower, upper = np.asarray(shape.lower_bound), np.asarray(shape.upper_bound)
|
|
133
|
+
lo, hi = -np.inf, np.inf
|
|
134
|
+
for i in range(shape.ndim):
|
|
135
|
+
if d[i] == 0:
|
|
136
|
+
if a[i] < lower[i] or a[i] > upper[i]:
|
|
137
|
+
return [], True
|
|
138
|
+
else:
|
|
139
|
+
ts = sorted(((lower[i] - a[i]) / d[i], (upper[i] - a[i]) / d[i]))
|
|
140
|
+
lo, hi = max(lo, ts[0]), min(hi, ts[1])
|
|
141
|
+
return ([lo, hi] if lo <= hi else []), True
|
|
142
|
+
if isinstance(shape, POLYGONS):
|
|
143
|
+
vertices = _polygon_vertices(shape)
|
|
144
|
+
hits = []
|
|
145
|
+
|
|
146
|
+
def cross(u, v):
|
|
147
|
+
return u[0] * v[1] - u[1] * v[0]
|
|
148
|
+
|
|
149
|
+
for p, q in zip(vertices, np.roll(vertices, -1, axis=0)):
|
|
150
|
+
edge = q - p
|
|
151
|
+
denominator = cross(d, edge)
|
|
152
|
+
if abs(denominator) > np.finfo(float).eps * np.linalg.norm(
|
|
153
|
+
d
|
|
154
|
+
) * np.linalg.norm(edge):
|
|
155
|
+
t, u = cross(p - a, edge) / denominator, cross(p - a, d) / denominator
|
|
156
|
+
if -tolerance <= u <= 1 + tolerance:
|
|
157
|
+
hits.append(t)
|
|
158
|
+
elif abs(cross(p - a, d)) <= tolerance * np.dot(d, d):
|
|
159
|
+
hits.extend(
|
|
160
|
+
[np.dot(p - a, d) / np.dot(d, d), np.dot(q - a, d) / np.dot(d, d)]
|
|
161
|
+
)
|
|
162
|
+
return hits, True
|
|
163
|
+
if int(samples) != samples or samples < 2:
|
|
164
|
+
raise ValueError("samples must be an integer >= 2")
|
|
165
|
+
ts = np.linspace(0, 1, int(samples) + 1)
|
|
166
|
+
states = mask(shape, a[:, None] + d[:, None] * ts)
|
|
167
|
+
changes = np.flatnonzero(states[:-1] != states[1:])
|
|
168
|
+
hits = []
|
|
169
|
+
for i in changes:
|
|
170
|
+
lo, hi = ts[i], ts[i + 1]
|
|
171
|
+
for _ in range(48):
|
|
172
|
+
mid = (lo + hi) / 2
|
|
173
|
+
if shape.contains(a + mid * d) == states[i]:
|
|
174
|
+
lo = mid
|
|
175
|
+
else:
|
|
176
|
+
hi = mid
|
|
177
|
+
hits.append((lo + hi) / 2)
|
|
178
|
+
return hits, False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def intersect_segment(shape, start, end, *, samples=256, tolerance=1e-10):
|
|
182
|
+
a, b = np.asarray(start, float), np.asarray(end, float)
|
|
183
|
+
if (
|
|
184
|
+
a.shape != (shape.ndim,)
|
|
185
|
+
or b.shape != a.shape
|
|
186
|
+
or not np.all(np.isfinite([a, b]))
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("Segment endpoints must be finite coordinate vectors")
|
|
189
|
+
if tolerance <= 0 or not np.isfinite(tolerance) or np.linalg.norm(b - a) == 0:
|
|
190
|
+
raise ValueError("Segment length and tolerance must be positive")
|
|
191
|
+
if hasattr(shape, "named_objects"):
|
|
192
|
+
hits = [
|
|
193
|
+
(name, intersect_segment(obj, a, b, samples=samples, tolerance=tolerance))
|
|
194
|
+
for name, obj in shape.named_objects.items()
|
|
195
|
+
]
|
|
196
|
+
records = sorted(
|
|
197
|
+
(float(t), name, hit, j)
|
|
198
|
+
for name, hit in hits
|
|
199
|
+
for j, t in enumerate(hit.fractions)
|
|
200
|
+
)
|
|
201
|
+
return SegmentIntersections(
|
|
202
|
+
np.array([r[0] for r in records]),
|
|
203
|
+
np.column_stack([r[2].points[:, r[3]] for r in records])
|
|
204
|
+
if records
|
|
205
|
+
else np.empty((shape.ndim, 0)),
|
|
206
|
+
np.column_stack([r[2].normals[:, r[3]] for r in records])
|
|
207
|
+
if records
|
|
208
|
+
else np.empty((shape.ndim, 0)),
|
|
209
|
+
np.array([r[2].entering[r[3]] for r in records], dtype=int),
|
|
210
|
+
tuple(r[1] for r in records),
|
|
211
|
+
all(h.exact for _, h in hits),
|
|
212
|
+
)
|
|
213
|
+
candidates, exact = _candidates(shape, a, b, samples, tolerance)
|
|
214
|
+
ts = []
|
|
215
|
+
for t in sorted(t for t in candidates if -tolerance <= t <= 1 + tolerance):
|
|
216
|
+
t = float(np.clip(t, 0, 1))
|
|
217
|
+
if not ts or t - ts[-1] > tolerance:
|
|
218
|
+
ts.append(t)
|
|
219
|
+
ts = np.asarray(ts)
|
|
220
|
+
points = a[:, None] + (b - a)[:, None] * ts
|
|
221
|
+
eps = np.full(len(ts), 1e-7)
|
|
222
|
+
if len(ts) > 1:
|
|
223
|
+
eps[:-1] = np.minimum(eps[:-1], np.diff(ts) / 4)
|
|
224
|
+
eps[1:] = np.minimum(eps[1:], np.diff(ts) / 4)
|
|
225
|
+
before = mask(shape, a[:, None] + (b - a)[:, None] * (ts - eps))
|
|
226
|
+
after = mask(shape, a[:, None] + (b - a)[:, None] * (ts + eps))
|
|
227
|
+
normals = surface_normal(shape, points)
|
|
228
|
+
return SegmentIntersections(
|
|
229
|
+
ts,
|
|
230
|
+
points,
|
|
231
|
+
normals,
|
|
232
|
+
after.astype(int) - before.astype(int),
|
|
233
|
+
(None,) * len(ts),
|
|
234
|
+
exact,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _closest_segments(p, segments):
|
|
239
|
+
a, b = segments[:, 0], segments[:, 1]
|
|
240
|
+
d = b - a
|
|
241
|
+
denominator = np.sum(d * d, axis=1)
|
|
242
|
+
t = np.sum((p - a) * d, axis=1) / np.where(denominator > 0, denominator, 1)
|
|
243
|
+
return a + np.clip(t, 0, 1)[:, None] * d
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def closest_on_mesh(points, vertices, cells):
|
|
247
|
+
if not len(cells):
|
|
248
|
+
raise ValueError("No surface resolved; enlarge bounds or increase resolution")
|
|
249
|
+
faces = vertices[cells]
|
|
250
|
+
result = []
|
|
251
|
+
for p in points.T:
|
|
252
|
+
if cells.shape[1] == 2:
|
|
253
|
+
candidates = _closest_segments(p, faces)
|
|
254
|
+
else:
|
|
255
|
+
# Closest projection within a triangle, or its three edges.
|
|
256
|
+
a, b, c = faces[:, 0], faces[:, 1], faces[:, 2]
|
|
257
|
+
u, v = b - a, c - a
|
|
258
|
+
n = np.cross(u, v)
|
|
259
|
+
n2 = np.sum(n * n, axis=1)
|
|
260
|
+
q = p - (np.sum((p - a) * n, axis=1) / np.where(n2 > 0, n2, 1))[:, None] * n
|
|
261
|
+
uu, uv, vv = (
|
|
262
|
+
np.sum(u * u, axis=1),
|
|
263
|
+
np.sum(u * v, axis=1),
|
|
264
|
+
np.sum(v * v, axis=1),
|
|
265
|
+
)
|
|
266
|
+
wu, wv = np.sum((q - a) * u, axis=1), np.sum((q - a) * v, axis=1)
|
|
267
|
+
den = uu * vv - uv * uv
|
|
268
|
+
s = (vv * wu - uv * wv) / np.where(den > 0, den, 1)
|
|
269
|
+
t = (uu * wv - uv * wu) / np.where(den > 0, den, 1)
|
|
270
|
+
valid = (den > 0) & (s >= 0) & (t >= 0) & (s + t <= 1)
|
|
271
|
+
candidates = np.concatenate(
|
|
272
|
+
[
|
|
273
|
+
q[valid],
|
|
274
|
+
*(
|
|
275
|
+
_closest_segments(p, faces[:, pair])
|
|
276
|
+
for pair in ([0, 1], [1, 2], [2, 0])
|
|
277
|
+
),
|
|
278
|
+
]
|
|
279
|
+
)
|
|
280
|
+
result.append(candidates[np.argmin(np.sum((candidates - p) ** 2, axis=1))])
|
|
281
|
+
return np.asarray(result).reshape(-1, points.shape[0]).T
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def boundary_points_1d(shape, *, resolution=64, bounds=None):
|
|
285
|
+
"""Resolve boundary points of a 1D shape or section by segment crossings."""
|
|
286
|
+
lower, upper = (
|
|
287
|
+
bounds if bounds is not None else (shape.lower_bound, shape.upper_bound)
|
|
288
|
+
)
|
|
289
|
+
lower, upper = np.asarray(lower, float), np.asarray(upper, float)
|
|
290
|
+
if bounds is None:
|
|
291
|
+
margin = (upper - lower) * 0.05
|
|
292
|
+
lower, upper = lower - margin, upper + margin
|
|
293
|
+
hits = shape.intersect_segment(lower, upper, samples=resolution)
|
|
294
|
+
if not len(hits):
|
|
295
|
+
raise ValueError("No boundary resolved; enlarge bounds or increase resolution")
|
|
296
|
+
return hits.points
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def closest_point(shape, points, *, resolution=64, bounds=None):
|
|
300
|
+
p, single = points_array(points, shape.ndim)
|
|
301
|
+
if isinstance(shape, BALLS):
|
|
302
|
+
delta = p - np.asarray(shape.midpoint)[:, None]
|
|
303
|
+
radius = np.linalg.norm(delta, axis=0)
|
|
304
|
+
unit = delta / np.where(radius > 0, radius, 1)
|
|
305
|
+
unit[0, radius == 0] = 1
|
|
306
|
+
q = np.asarray(shape.midpoint)[:, None] + shape.radius * unit
|
|
307
|
+
elif isinstance(shape, BOXES):
|
|
308
|
+
lower, upper = np.asarray(shape.lower_bound), np.asarray(shape.upper_bound)
|
|
309
|
+
q = np.clip(p, lower[:, None], upper[:, None])
|
|
310
|
+
inside = np.all((p >= lower[:, None]) & (p <= upper[:, None]), axis=0)
|
|
311
|
+
for i in np.flatnonzero(inside):
|
|
312
|
+
distances = np.r_[p[:, i] - lower, upper - p[:, i]]
|
|
313
|
+
face = np.argmin(distances)
|
|
314
|
+
axis = face % shape.ndim
|
|
315
|
+
q[axis, i] = lower[axis] if face < shape.ndim else upper[axis]
|
|
316
|
+
elif isinstance(shape, POLYGONS):
|
|
317
|
+
vertices = _polygon_vertices(shape)
|
|
318
|
+
cells = np.column_stack(
|
|
319
|
+
(np.arange(len(vertices)), np.roll(np.arange(len(vertices)), -1))
|
|
320
|
+
)
|
|
321
|
+
q = closest_on_mesh(p, vertices, cells)
|
|
322
|
+
elif isinstance(shape, TransformedShape) and np.allclose(
|
|
323
|
+
shape.matrix.T @ shape.matrix,
|
|
324
|
+
np.eye(shape.ndim) * np.sum(shape.matrix[:, 0] ** 2),
|
|
325
|
+
):
|
|
326
|
+
q = (
|
|
327
|
+
shape.matrix
|
|
328
|
+
@ closest_point(
|
|
329
|
+
shape.source,
|
|
330
|
+
shape.inverse @ (p - shape.offset[:, None]),
|
|
331
|
+
resolution=resolution,
|
|
332
|
+
bounds=None,
|
|
333
|
+
)
|
|
334
|
+
+ shape.offset[:, None]
|
|
335
|
+
)
|
|
336
|
+
elif shape.ndim == 1:
|
|
337
|
+
boundary = boundary_points_1d(shape, resolution=resolution, bounds=bounds)
|
|
338
|
+
closest = np.argmin(abs(p.T - boundary), axis=1)
|
|
339
|
+
q = boundary[:, closest]
|
|
340
|
+
else:
|
|
341
|
+
mesh = shape.to_mesh(resolution=resolution, bounds=bounds)
|
|
342
|
+
q = closest_on_mesh(p, mesh.vertices, mesh.cells)
|
|
343
|
+
return q[:, 0] if single else q
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def signed_distance(shape, points, *, resolution=64, bounds=None):
|
|
347
|
+
p, single = points_array(points, shape.ndim)
|
|
348
|
+
q = closest_point(shape, p, resolution=resolution, bounds=bounds)
|
|
349
|
+
distance = np.linalg.norm(p - q, axis=0)
|
|
350
|
+
distance = np.where(mask(shape, p), -distance, distance)
|
|
351
|
+
return float(distance[0]) if single else distance
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def estimate_surface_normal(obj, point, scale, *, require_boundary=True):
|
|
355
|
+
"""Solid-outward unit normal estimated by directional membership samples.
|
|
356
|
+
|
|
357
|
+
Set require_boundary=False to retain the original Picnic estimator at
|
|
358
|
+
periodic image points, including its cancellation behavior away from a surface.
|
|
359
|
+
"""
|
|
360
|
+
point = np.asarray(point, float)
|
|
361
|
+
ndim = point.size
|
|
362
|
+
if ndim == 1:
|
|
363
|
+
directions = np.asarray([[-1.0], [1.0]])
|
|
364
|
+
elif ndim == 2:
|
|
365
|
+
theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
|
|
366
|
+
directions = np.column_stack((np.cos(theta), np.sin(theta)))
|
|
367
|
+
else:
|
|
368
|
+
indices = np.arange(128, dtype=float) + 0.5
|
|
369
|
+
z = 1 - 2 * indices / 128
|
|
370
|
+
theta = np.pi * (1 + np.sqrt(5)) * indices
|
|
371
|
+
radial = np.sqrt(1 - z * z)
|
|
372
|
+
directions = np.column_stack(
|
|
373
|
+
(radial * np.cos(theta), radial * np.sin(theta), z)
|
|
374
|
+
)
|
|
375
|
+
epsilon = max(scale * 1e-5, np.finfo(float).eps * 128)
|
|
376
|
+
hits = mask(obj, point[:, None] + epsilon * directions.T)
|
|
377
|
+
normal = np.sum(np.where(hits[:, None], -directions, directions), axis=0)
|
|
378
|
+
magnitude = np.linalg.norm(normal)
|
|
379
|
+
if magnitude == 0 or (require_boundary and (hits.all() or not hits.any())):
|
|
380
|
+
return np.full(ndim, np.nan)
|
|
381
|
+
return normal / magnitude
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def surface_normal(shape, points, *, scale=None):
|
|
385
|
+
p, single = points_array(points, shape.ndim)
|
|
386
|
+
if isinstance(shape, TransformedShape):
|
|
387
|
+
n = shape.inverse.T @ surface_normal(
|
|
388
|
+
shape.source, shape.inverse @ (p - shape.offset[:, None]), scale=scale
|
|
389
|
+
)
|
|
390
|
+
elif isinstance(shape, BALLS):
|
|
391
|
+
n = p - np.asarray(shape.midpoint)[:, None]
|
|
392
|
+
if shape.inverted:
|
|
393
|
+
n = -n
|
|
394
|
+
elif isinstance(shape, BOXES):
|
|
395
|
+
distances = np.concatenate(
|
|
396
|
+
(
|
|
397
|
+
abs(p - np.asarray(shape.lower_bound)[:, None]),
|
|
398
|
+
abs(p - np.asarray(shape.upper_bound)[:, None]),
|
|
399
|
+
),
|
|
400
|
+
axis=0,
|
|
401
|
+
)
|
|
402
|
+
face = np.argmin(distances, axis=0)
|
|
403
|
+
n = np.zeros_like(p)
|
|
404
|
+
n[face % shape.ndim, np.arange(p.shape[1])] = np.where(face < shape.ndim, -1, 1)
|
|
405
|
+
if shape.inverted:
|
|
406
|
+
n = -n
|
|
407
|
+
elif isinstance(shape, POLYGONS):
|
|
408
|
+
vertices = _polygon_vertices(shape)
|
|
409
|
+
segments = np.stack((vertices, np.roll(vertices, -1, axis=0)), axis=1)
|
|
410
|
+
edges = segments[:, 1] - segments[:, 0]
|
|
411
|
+
signed_area = np.sum(
|
|
412
|
+
vertices[:, 0] * np.roll(vertices[:, 1], -1)
|
|
413
|
+
- vertices[:, 1] * np.roll(vertices[:, 0], -1)
|
|
414
|
+
)
|
|
415
|
+
if signed_area == 0:
|
|
416
|
+
n = np.full_like(p, np.nan)
|
|
417
|
+
else:
|
|
418
|
+
outward = np.column_stack((edges[:, 1], -edges[:, 0])) * np.sign(
|
|
419
|
+
signed_area
|
|
420
|
+
)
|
|
421
|
+
n = np.empty_like(p)
|
|
422
|
+
for i, point in enumerate(p.T):
|
|
423
|
+
closest = _closest_segments(point, segments)
|
|
424
|
+
edge = np.argmin(np.sum((closest - point) ** 2, axis=1))
|
|
425
|
+
n[:, i] = outward[edge]
|
|
426
|
+
if shape.inverted:
|
|
427
|
+
n = -n
|
|
428
|
+
else:
|
|
429
|
+
scale = (
|
|
430
|
+
float(np.max(np.asarray(shape.upper_bound) - shape.lower_bound))
|
|
431
|
+
if scale is None
|
|
432
|
+
else float(scale)
|
|
433
|
+
)
|
|
434
|
+
if not np.isfinite(scale) or scale <= 0:
|
|
435
|
+
raise ValueError("Normal sampling scale must be positive and finite")
|
|
436
|
+
n = (
|
|
437
|
+
np.column_stack(
|
|
438
|
+
[estimate_surface_normal(shape, point, scale) for point in p.T]
|
|
439
|
+
)
|
|
440
|
+
if p.shape[1]
|
|
441
|
+
else np.empty_like(p)
|
|
442
|
+
)
|
|
443
|
+
length = np.linalg.norm(n, axis=0)
|
|
444
|
+
n = n / np.where(length > 0, length, np.nan)
|
|
445
|
+
return n[:, 0] if single else n
|
simgeometry/sampling.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Reproducible interior and boundary sampling."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from simgeometry.operations import mask
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _count(count):
|
|
9
|
+
if int(count) != count or count < 0:
|
|
10
|
+
raise ValueError("count must be a nonnegative integer")
|
|
11
|
+
return int(count)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sample_interior(shape, count, *, rng=None, bounds=None, max_draws=1_000_000):
|
|
15
|
+
count = _count(count)
|
|
16
|
+
if shape.inverted and bounds is None:
|
|
17
|
+
raise ValueError("Inverted/unbounded shapes require explicit sampling bounds")
|
|
18
|
+
lower, upper = (
|
|
19
|
+
bounds if bounds is not None else (shape.lower_bound, shape.upper_bound)
|
|
20
|
+
)
|
|
21
|
+
lower, upper = np.asarray(lower, float), np.asarray(upper, float)
|
|
22
|
+
if (
|
|
23
|
+
lower.shape != (shape.ndim,)
|
|
24
|
+
or upper.shape != lower.shape
|
|
25
|
+
or not np.all(np.isfinite([lower, upper]))
|
|
26
|
+
or np.any(upper <= lower)
|
|
27
|
+
):
|
|
28
|
+
raise ValueError("Sampling bounds must be finite and increasing")
|
|
29
|
+
if max_draws < count:
|
|
30
|
+
raise ValueError("max_draws must be at least count")
|
|
31
|
+
rng = np.random.default_rng(rng)
|
|
32
|
+
accepted, remaining, drawn = [], count, 0
|
|
33
|
+
while remaining and drawn < max_draws:
|
|
34
|
+
n = min(max(remaining * 2, 256), max_draws - drawn)
|
|
35
|
+
p = rng.uniform(lower[:, None], upper[:, None], (shape.ndim, n))
|
|
36
|
+
p = p[:, mask(shape, p)][:, :remaining]
|
|
37
|
+
accepted.append(p)
|
|
38
|
+
remaining -= p.shape[1]
|
|
39
|
+
drawn += n
|
|
40
|
+
if remaining:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Only {count - remaining}/{count} samples after {drawn} draws; check bounds or increase max_draws"
|
|
43
|
+
)
|
|
44
|
+
return np.concatenate(accepted, axis=1) if count else np.empty((shape.ndim, 0))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sample_surface(shape, count, *, rng=None, resolution=64, bounds=None):
|
|
48
|
+
count = _count(count)
|
|
49
|
+
if count == 0:
|
|
50
|
+
return np.empty((shape.ndim, 0))
|
|
51
|
+
if shape.ndim == 1:
|
|
52
|
+
from simgeometry.queries import boundary_points_1d
|
|
53
|
+
|
|
54
|
+
points = boundary_points_1d(shape, resolution=resolution, bounds=bounds)
|
|
55
|
+
return points[:, np.random.default_rng(rng).integers(0, points.shape[1], count)]
|
|
56
|
+
mesh = shape.to_mesh(resolution=resolution, bounds=bounds)
|
|
57
|
+
if not len(mesh.cells):
|
|
58
|
+
raise ValueError("No surface resolved; enlarge bounds or increase resolution")
|
|
59
|
+
faces = mesh.vertices[mesh.cells]
|
|
60
|
+
if shape.ndim == 2:
|
|
61
|
+
weights = np.linalg.norm(faces[:, 1] - faces[:, 0], axis=1)
|
|
62
|
+
else:
|
|
63
|
+
weights = (
|
|
64
|
+
np.linalg.norm(
|
|
65
|
+
np.cross(faces[:, 1] - faces[:, 0], faces[:, 2] - faces[:, 0]), axis=1
|
|
66
|
+
)
|
|
67
|
+
/ 2
|
|
68
|
+
)
|
|
69
|
+
rng = np.random.default_rng(rng)
|
|
70
|
+
faces = faces[rng.choice(len(faces), size=count, p=weights / weights.sum())]
|
|
71
|
+
if shape.ndim == 2:
|
|
72
|
+
t = rng.random((count, 1))
|
|
73
|
+
return ((1 - t) * faces[:, 0] + t * faces[:, 1]).T
|
|
74
|
+
r, s = np.sqrt(rng.random((count, 1))), rng.random((count, 1))
|
|
75
|
+
return ((1 - r) * faces[:, 0] + r * (1 - s) * faces[:, 1] + r * s * faces[:, 2]).T
|