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
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
@@ -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