BornSim 0.2.6__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.
bornsim/geometry.py ADDED
@@ -0,0 +1,716 @@
1
+ """Voxel-centre geometry for finite structured dielectric samples.
2
+
3
+ Lengths use SI metres or compatible quantities. Shapes set absolute refractive index,
4
+ not dielectric contrast; Volume retains the solver's linearized constitutive
5
+ law. Interfaces are sampled at cell centres without subvoxel averaging.
6
+ """
7
+
8
+ from dataclasses import asdict, dataclass, field, replace
9
+ import numpy as np
10
+ from typing import Any
11
+ from .units import _refractive_index_values, Quantity, validate_units, ureg
12
+ from .media import Medium, RandomMedium
13
+ from .volume import Volume
14
+ from .grid import Grid
15
+ from .rotation import Rotation
16
+ from .material import Material
17
+ import warnings
18
+
19
+
20
+ def _refractive_index(*, value: float) -> float:
21
+ refractive_index = _refractive_index_values(
22
+ value=value,
23
+ name="refractive_index",
24
+ scalar=True,
25
+ )
26
+
27
+ if not np.isfinite(refractive_index) or refractive_index <= 0:
28
+ raise ValueError("refractive_index must be finite and positive.")
29
+
30
+ return float(refractive_index)
31
+
32
+
33
+ def _axis(*, value):
34
+ if isinstance(value, bool) or not isinstance(value, (int, np.integer)) or value not in (0, 1, 2):
35
+ raise ValueError("axis must be 0, 1, or 2 (x, y, z).")
36
+
37
+ return int(value)
38
+
39
+
40
+ @dataclass(frozen=True, kw_only=True)
41
+ class _Transformable:
42
+ """Return transformed immutable shapes in global SI coordinates."""
43
+
44
+ centre: Quantity = field(default_factory=lambda: np.zeros(3) * ureg.meter)
45
+ refractive_index: float | None = None
46
+ material: Material | None = None
47
+
48
+ def _replaced(self, *, settings: dict[str, Any]) -> "_Transformable":
49
+ """Clone geometry while retaining unit-bearing lengths."""
50
+
51
+ return replace(self, **settings)
52
+
53
+ def translated(self, *, offset: Quantity) -> "_Transformable":
54
+ """Return a translated shape; offset uses metres or length quantities."""
55
+
56
+ validate_units(
57
+ offset,
58
+ unit="meter",
59
+ name="offset",
60
+ scalar=False,
61
+ )
62
+
63
+ if np.shape(offset) != (3,):
64
+ raise ValueError("offset must contain three coordinates.")
65
+
66
+ if np.any(~np.isfinite(offset)):
67
+ raise ValueError("offset must be finite.")
68
+
69
+ displacement = offset
70
+
71
+ centre = self.centre + displacement
72
+
73
+ return self._replaced(settings={"centre": centre, "refractive_index": None})
74
+
75
+ def rotated(self, *, rotation: Rotation | np.ndarray, about: Quantity | None = None) -> "_Transformable":
76
+ """Return a rotated shape around its centre or an explicit global point.
77
+
78
+ Rotation is active: R maps local column coordinates to the global
79
+ frame. Successive world rotations compose as R_new @ R_existing.
80
+ Membership uses local row coordinates (positions-centre) @ R.
81
+ """
82
+
83
+ matrix = Rotation._matrix(rotation=rotation)
84
+
85
+ centre = self.centre
86
+
87
+ if about is not None:
88
+ validate_units(
89
+ about,
90
+ unit="meter",
91
+ name="about",
92
+ scalar=False,
93
+ )
94
+
95
+ if np.shape(about) != (3,):
96
+ raise ValueError("about must contain three coordinates.")
97
+
98
+ if np.any(~np.isfinite(about)):
99
+ raise ValueError("about must be finite.")
100
+
101
+ point = about
102
+
103
+ centre = point + matrix @ (centre - point)
104
+
105
+ settings: dict[str, Any] = {"centre": centre, "refractive_index": None}
106
+
107
+ if hasattr(self, "rotation"):
108
+ settings["rotation"] = tuple(tuple(row) for row in matrix @ np.asarray(self.rotation))
109
+
110
+ return self._replaced(settings=settings)
111
+
112
+
113
+ @dataclass(frozen=True, kw_only=True)
114
+ class Layer(_Transformable):
115
+ """A slab with absolute refractive index inside ``lower <= local[axis] < upper``.
116
+
117
+ Bounds are SI lengths in the slab local frame, offset by centre and
118
+ oriented by rotation. The default axis is
119
+ z (2); x and y are 0 and 1. The half-open interval gives adjacent slabs an
120
+ unambiguous interface. The slab spans the voxel box in transverse axes;
121
+ it represents a finite sample, not an infinite planar background.
122
+ """
123
+
124
+ lower: Quantity
125
+ upper: Quantity
126
+ refractive_index: float | None = None
127
+ material: Material | None = None
128
+ axis: int = 2
129
+ centre: Quantity = field(default_factory=lambda: np.zeros(3) * ureg.meter)
130
+ rotation: tuple = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
131
+
132
+ def __post_init__(self) -> None:
133
+ validate_units(
134
+ self.lower,
135
+ unit="meter",
136
+ name="lower",
137
+ scalar=True,
138
+ )
139
+
140
+ if np.any(~np.isfinite(self.lower)):
141
+ raise ValueError("lower must be finite.")
142
+
143
+ validate_units(
144
+ self.upper,
145
+ unit="meter",
146
+ name="upper",
147
+ scalar=True,
148
+ )
149
+
150
+ if np.any(~np.isfinite(self.upper)):
151
+ raise ValueError("upper must be finite.")
152
+
153
+ material = Material._resolve(material=self.material, refractive_index=self.refractive_index)
154
+
155
+ object.__setattr__(self, "material", material)
156
+
157
+ object.__setattr__(self, "refractive_index", material.refractive_index)
158
+
159
+ object.__setattr__(self, "axis", _axis(value=self.axis))
160
+
161
+ validate_units(
162
+ self.centre,
163
+ unit="meter",
164
+ name="centre",
165
+ scalar=False,
166
+ )
167
+
168
+ if np.shape(self.centre) != (3,):
169
+ raise ValueError("centre must contain three coordinates.")
170
+
171
+ if np.any(~np.isfinite(self.centre)):
172
+ raise ValueError("centre must be finite.")
173
+
174
+ object.__setattr__(self, "rotation", tuple(tuple(row) for row in Rotation._matrix(rotation=self.rotation)))
175
+
176
+ if self.lower >= self.upper:
177
+ raise ValueError("lower must be smaller than upper.")
178
+
179
+ def mask(self, *, positions: Quantity) -> np.ndarray:
180
+ """Return membership at unit-bearing voxel positions, shape (..., 3)."""
181
+
182
+ validate_units(
183
+ positions,
184
+ unit="meter",
185
+ name="positions",
186
+ )
187
+
188
+ coordinate = ((positions - self.centre) @ np.asarray(self.rotation))[..., self.axis]
189
+
190
+ return (coordinate >= self.lower) & (coordinate < self.upper)
191
+
192
+
193
+ @dataclass(frozen=True, kw_only=True)
194
+ class Sphere(_Transformable):
195
+ """Set absolute refractive index where ``sum((r-centre)**2) <= radius**2``.
196
+
197
+ Radius is positive; centre is a three-component SI length or quantity.
198
+ The closed surface is sampled at voxel centres, giving a staircase boundary.
199
+ """
200
+
201
+ radius: Quantity
202
+ refractive_index: float | None = None
203
+ material: Material | None = None
204
+ centre: Quantity = field(default_factory=lambda: np.zeros(3) * ureg.meter)
205
+
206
+ def __post_init__(self) -> None:
207
+ validate_units(
208
+ self.radius,
209
+ unit="meter",
210
+ name="radius",
211
+ scalar=True,
212
+ )
213
+
214
+ if np.any(~np.isfinite(self.radius)) or np.any(self.radius <= 0):
215
+ raise ValueError("radius must be finite and positive.")
216
+
217
+ material = Material._resolve(material=self.material, refractive_index=self.refractive_index)
218
+
219
+ object.__setattr__(self, "material", material)
220
+
221
+ object.__setattr__(self, "refractive_index", material.refractive_index)
222
+
223
+ validate_units(
224
+ self.centre,
225
+ unit="meter",
226
+ name="centre",
227
+ scalar=False,
228
+ )
229
+
230
+ if np.shape(self.centre) != (3,):
231
+ raise ValueError("centre must contain three coordinates.")
232
+
233
+ if np.any(~np.isfinite(self.centre)):
234
+ raise ValueError("centre must be finite.")
235
+
236
+ def mask(self, *, positions: Quantity) -> np.ndarray:
237
+ """Return membership at unit-bearing voxel positions, shape (..., 3)."""
238
+
239
+ validate_units(
240
+ positions,
241
+ unit="meter",
242
+ name="positions",
243
+ )
244
+
245
+ return np.sum((positions - self.centre) ** 2, axis=-1) <= self.radius**2
246
+
247
+
248
+ @dataclass(frozen=True, kw_only=True)
249
+ class Ellipsoid(_Transformable):
250
+ """Set absolute refractive index where ``sum((local/radii)**2) <= 1``.
251
+
252
+ Three positive semi-axis radii align with local x, y, z. All lengths use SI or
253
+ compatible quantities. rotation maps local semi-axes into global coordinates.
254
+ """
255
+
256
+ radii: Quantity
257
+ refractive_index: float | None = None
258
+ material: Material | None = None
259
+ centre: Quantity = field(default_factory=lambda: np.zeros(3) * ureg.meter)
260
+ rotation: tuple = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
261
+
262
+ def __post_init__(self) -> None:
263
+ validate_units(
264
+ self.radii,
265
+ unit="meter",
266
+ name="radii",
267
+ scalar=False,
268
+ )
269
+
270
+ if np.shape(self.radii) != (3,):
271
+ raise ValueError("radii must contain three coordinates.")
272
+
273
+ if np.any(~np.isfinite(self.radii)) or np.any(self.radii <= 0):
274
+ raise ValueError("radii must be finite and positive.")
275
+
276
+ material = Material._resolve(material=self.material, refractive_index=self.refractive_index)
277
+
278
+ object.__setattr__(self, "material", material)
279
+
280
+ object.__setattr__(self, "refractive_index", material.refractive_index)
281
+
282
+ validate_units(
283
+ self.centre,
284
+ unit="meter",
285
+ name="centre",
286
+ scalar=False,
287
+ )
288
+
289
+ if np.shape(self.centre) != (3,):
290
+ raise ValueError("centre must contain three coordinates.")
291
+
292
+ if np.any(~np.isfinite(self.centre)):
293
+ raise ValueError("centre must be finite.")
294
+
295
+ object.__setattr__(self, "rotation", tuple(tuple(row) for row in Rotation._matrix(rotation=self.rotation)))
296
+
297
+ def mask(self, *, positions: Quantity) -> np.ndarray:
298
+ """Return membership at unit-bearing voxel positions, shape (..., 3)."""
299
+
300
+ validate_units(
301
+ positions,
302
+ unit="meter",
303
+ name="positions",
304
+ )
305
+
306
+ local = (positions - self.centre) @ np.asarray(self.rotation)
307
+
308
+ return np.sum((local / self.radii) ** 2, axis=-1) <= 1
309
+
310
+
311
+ @dataclass(frozen=True, kw_only=True)
312
+ class Box(_Transformable):
313
+ """Set absolute refractive index where ``abs(local) <= size/2`` in every axis.
314
+
315
+ Size contains three positive full side lengths along the local axes.
316
+ All lengths use SI or compatible quantities; the boundary is closed.
317
+ """
318
+
319
+ size: Quantity
320
+ refractive_index: float | None = None
321
+ material: Material | None = None
322
+ centre: Quantity = field(default_factory=lambda: np.zeros(3) * ureg.meter)
323
+ rotation: tuple = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
324
+
325
+ def __post_init__(self) -> None:
326
+ validate_units(
327
+ self.size,
328
+ unit="meter",
329
+ name="size",
330
+ scalar=False,
331
+ )
332
+
333
+ if np.shape(self.size) != (3,):
334
+ raise ValueError("size must contain three coordinates.")
335
+
336
+ if np.any(~np.isfinite(self.size)) or np.any(self.size <= 0):
337
+ raise ValueError("size must be finite and positive.")
338
+
339
+ material = Material._resolve(material=self.material, refractive_index=self.refractive_index)
340
+
341
+ object.__setattr__(self, "material", material)
342
+
343
+ object.__setattr__(self, "refractive_index", material.refractive_index)
344
+
345
+ validate_units(
346
+ self.centre,
347
+ unit="meter",
348
+ name="centre",
349
+ scalar=False,
350
+ )
351
+
352
+ if np.shape(self.centre) != (3,):
353
+ raise ValueError("centre must contain three coordinates.")
354
+
355
+ if np.any(~np.isfinite(self.centre)):
356
+ raise ValueError("centre must be finite.")
357
+
358
+ object.__setattr__(self, "rotation", tuple(tuple(row) for row in Rotation._matrix(rotation=self.rotation)))
359
+
360
+ def mask(self, *, positions: Quantity) -> np.ndarray:
361
+ """Return membership at unit-bearing voxel positions, shape (..., 3)."""
362
+
363
+ validate_units(
364
+ positions,
365
+ unit="meter",
366
+ name="positions",
367
+ )
368
+
369
+ local = (positions - self.centre) @ np.asarray(self.rotation)
370
+
371
+ return np.all(np.abs(local) <= self.size / 2, axis=-1)
372
+
373
+
374
+ @dataclass(frozen=True, kw_only=True)
375
+ class Cylinder(_Transformable):
376
+ """A finite circular cylinder with absolute refractive index and closed boundary.
377
+
378
+ Membership requires ``abs(local[axis]) <= height/2`` and squared
379
+ transverse distance <= radius**2. Radius and full height are positive SI
380
+ lengths or quantities; axis is 0, 1, or 2, default z. Centre has three lengths.
381
+ """
382
+
383
+ radius: Quantity
384
+ height: Quantity
385
+ refractive_index: float | None = None
386
+ material: Material | None = None
387
+ centre: Quantity = field(default_factory=lambda: np.zeros(3) * ureg.meter)
388
+ axis: int = 2
389
+ rotation: tuple = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
390
+
391
+ def __post_init__(self) -> None:
392
+ validate_units(
393
+ self.radius,
394
+ unit="meter",
395
+ name="radius",
396
+ scalar=True,
397
+ )
398
+
399
+ if np.any(~np.isfinite(self.radius)) or np.any(self.radius <= 0):
400
+ raise ValueError("radius must be finite and positive.")
401
+
402
+ validate_units(
403
+ self.height,
404
+ unit="meter",
405
+ name="height",
406
+ scalar=True,
407
+ )
408
+
409
+ if np.any(~np.isfinite(self.height)) or np.any(self.height <= 0):
410
+ raise ValueError("height must be finite and positive.")
411
+
412
+ material = Material._resolve(material=self.material, refractive_index=self.refractive_index)
413
+
414
+ object.__setattr__(self, "material", material)
415
+
416
+ object.__setattr__(self, "refractive_index", material.refractive_index)
417
+
418
+ validate_units(
419
+ self.centre,
420
+ unit="meter",
421
+ name="centre",
422
+ scalar=False,
423
+ )
424
+
425
+ if np.shape(self.centre) != (3,):
426
+ raise ValueError("centre must contain three coordinates.")
427
+
428
+ if np.any(~np.isfinite(self.centre)):
429
+ raise ValueError("centre must be finite.")
430
+
431
+ object.__setattr__(self, "axis", _axis(value=self.axis))
432
+
433
+ object.__setattr__(self, "rotation", tuple(tuple(row) for row in Rotation._matrix(rotation=self.rotation)))
434
+
435
+ def mask(self, *, positions: Quantity) -> np.ndarray:
436
+ """Return membership at unit-bearing voxel positions, shape (..., 3)."""
437
+
438
+ validate_units(
439
+ positions,
440
+ unit="meter",
441
+ name="positions",
442
+ )
443
+
444
+ displacement = (positions - self.centre) @ np.asarray(self.rotation)
445
+
446
+ transverse = [i for i in range(3) if i != self.axis]
447
+
448
+ return (np.abs(displacement[..., self.axis]) <= self.height / 2) & (
449
+ np.sum(displacement[..., transverse] ** 2, axis=-1) <= self.radius**2
450
+ )
451
+
452
+
453
+ @dataclass(kw_only=True)
454
+ class StructuredMedium(Medium):
455
+ """Compose ordered material regions in a uniform background.
456
+
457
+ Parameters
458
+ ----------
459
+ regions : sequence of Layer, Sphere, Ellipsoid, Box, or Cylinder
460
+ Regions set absolute refractive index. Overlap precedence follows the
461
+ overlap policy; indices are not added. Default is empty.
462
+ background_refractive_index : float
463
+ Explicit positive uniform background refractive index. It fills uncovered
464
+ voxels and extends outside the finite voxel box.
465
+
466
+ overlap : {"replace", "preserve", "error"}
467
+ Later regions win, earlier regions win, or reject shared voxel centres.
468
+ warn_on_clipping : bool
469
+ Warn when geometry extends beyond the voxel box; default False.
470
+
471
+ Notes
472
+ -----
473
+ Configure this mutable builder with ``add_background`` and ``add_structures``.
474
+ An empty builder has no background; voxelization requires an explicit one.
475
+ Medium remains abstract; random statistics and geometry shapes remain frozen.
476
+ Calls update the builder and return None. Previously generated volumes do
477
+ not change. Constructor regions are still accepted for existing callers.
478
+ Voxelization returns ``delta_refractive_index(r) = n(r) - background_refractive_index``. The
479
+ numerical solver uses ``epsilon_r = n0**2 + 2*n0*delta_refractive_index``, omitting
480
+ ``delta_refractive_index**2`` even at higher Born orders. Strong refractive index differences
481
+ can invalidate this constitutive approximation or Born iteration.
482
+ Regions crossing the box are clipped. Layers therefore have finite lateral
483
+ extent; this is not a transfer-matrix or layered-background Green solver.
484
+ """
485
+
486
+ regions: tuple = ()
487
+ background_refractive_index: float | None = None
488
+ overlap: str = "replace"
489
+ warn_on_clipping: bool = False
490
+ _background: RandomMedium | None = field(default=None, init=False, repr=False)
491
+
492
+ def __post_init__(self) -> None:
493
+ regions = tuple(self.regions)
494
+
495
+ if any(not isinstance(region, (Layer, Sphere, Ellipsoid, Box, Cylinder)) for region in regions):
496
+ raise TypeError("regions must contain Layer, Sphere, Ellipsoid, Box, or Cylinder objects.")
497
+
498
+ if self.overlap not in ("replace", "preserve", "error"):
499
+ raise ValueError("overlap must be replace, preserve, or error.")
500
+
501
+ if not isinstance(self.warn_on_clipping, bool):
502
+ raise ValueError("warn_on_clipping must be a bool.")
503
+
504
+ self.regions = regions
505
+
506
+ if self.background_refractive_index is not None:
507
+ self.background_refractive_index = _refractive_index(value=self.background_refractive_index)
508
+
509
+ def add_background(self, *, refractive_index=None, medium=None, material=None):
510
+ """Set the background in place, retaining all existing structures.
511
+
512
+ Supply exactly one of ``refractive_index`` (a positive uniform refractive index)
513
+ or ``medium`` (RandomMedium statistics). A random background is sampled
514
+ with to_volume(seed=...) and fills only voxels outside structures.
515
+ The exterior scattering background remains uniform at background_refractive_index.
516
+ Replacing a background changes the reference n0, not structures'
517
+ absolute refractive indices. Validation precedes any state change.
518
+ Returns None. Random statistics are copied when added.
519
+ """
520
+
521
+ if material is not None:
522
+ resolved = Material._resolve(material=material, refractive_index=refractive_index)
523
+
524
+ refractive_index = resolved.refractive_index
525
+
526
+ if (refractive_index is None) == (medium is None):
527
+ raise ValueError("Supply exactly one of refractive_index or medium.")
528
+
529
+ if medium is not None:
530
+ if not isinstance(medium, RandomMedium):
531
+ raise TypeError("medium must be a RandomMedium.")
532
+
533
+ background = replace(medium)
534
+
535
+ background_refractive_index = background.background_refractive_index
536
+ else:
537
+ background = None
538
+
539
+ background_refractive_index = _refractive_index(value=refractive_index)
540
+
541
+ self.background_refractive_index = background_refractive_index
542
+
543
+ self._background = background
544
+
545
+ def add_structures(self, *structures):
546
+ """Append material regions in argument order; return None.
547
+
548
+ Accepts any number of positional Layer, Sphere, Ellipsoid, Box, or
549
+ Cylinder objects. Later arguments replace earlier material in overlaps,
550
+ including random background fluctuations. All arguments are validated
551
+ before editing the builder; an empty call is a no-op. Existing voxel
552
+ samples are independent snapshots.
553
+ """
554
+
555
+ if any(not isinstance(structure, (Layer, Sphere, Ellipsoid, Box, Cylinder)) for structure in structures):
556
+ raise TypeError("structures must be Layer, Sphere, Ellipsoid, Box, or Cylinder objects.")
557
+
558
+ self.regions = (*self.regions, *structures)
559
+
560
+ @property
561
+ def is_random(self):
562
+ """A random background supplies independent composed realizations."""
563
+
564
+ return self._background is not None
565
+
566
+ @property
567
+ def metadata(self):
568
+ """Return independent SI geometry descriptions in application order.
569
+
570
+ Region type, absolute refractive index, axis where applicable, and dimensions are
571
+ recorded. Length keys have the ``_m`` suffix, and vectors are JSON lists.
572
+ The list order preserves replacement precedence in overlapping regions.
573
+ Voxelization returns a manual volume; retain this description separately
574
+ when reconstructing a structured sample.
575
+ """
576
+
577
+ length_fields = {"lower", "upper", "radius", "height", "centre", "radii", "size"}
578
+
579
+ regions = []
580
+
581
+ for region in self.regions:
582
+ description: dict[str, object] = {"type": type(region).__name__}
583
+
584
+ for name, value in asdict(region).items():
585
+ if name == "material":
586
+ continue
587
+
588
+ if name == "rotation" and np.array_equal(value, np.eye(3)):
589
+ continue
590
+
591
+ if isinstance(region, Layer) and name == "centre" and np.all(value == 0):
592
+ continue
593
+
594
+ if name in length_fields:
595
+ magnitude = value.to("meter").magnitude
596
+
597
+ value = np.asarray(magnitude).tolist()
598
+
599
+ key = f"{name}_m" if name in length_fields else name
600
+
601
+ description[key] = list(value) if isinstance(value, tuple) else value
602
+
603
+ regions.append(description)
604
+
605
+ metadata: dict[str, object] = {**super().metadata, "regions": regions}
606
+
607
+ if self.overlap != "replace":
608
+ metadata["overlap"] = self.overlap
609
+
610
+ if self._background is not None:
611
+ metadata["background"] = self._background.metadata
612
+
613
+ return metadata
614
+
615
+ def to_volume(
616
+ self,
617
+ *,
618
+ grid: Grid | None = None,
619
+ shape: tuple[int, int, int] | None = None,
620
+ spacing: Quantity | None = None,
621
+ seed: int = 0,
622
+ ) -> Volume:
623
+ """Sample regions on cubic voxels and return a validated :class:`Volume`.
624
+
625
+ Pass a shared Grid as grid. Legacy shape and spacing keywords are
626
+ also supported, but cannot be combined with grid.
627
+ Shape contains three integers from 2 to 32. Spacing is positive, in
628
+ metres or compatible units. Coordinates are ``(i-(N-1)/2)*spacing``.
629
+ Interfaces use centre membership; check refinement at fixed dimensions.
630
+ For a random background, seed selects the realization. Structures replace
631
+ that background at their voxel centres; fluctuations are not added inside
632
+ structures. Uniform backgrounds ignore seed. Keep metadata and seed to
633
+ reproduce a composed sample, whose Volume is a manual field.
634
+ """
635
+
636
+ if self.background_refractive_index is None:
637
+ raise ValueError("Set a background with add_background before generating a volume.")
638
+
639
+ grid = Grid._resolve(
640
+ grid=grid,
641
+ shape=shape,
642
+ spacing=spacing,
643
+ )
644
+
645
+ if self.overlap not in ("replace", "preserve", "error"):
646
+ raise ValueError("overlap must be replace, preserve, or error.")
647
+
648
+ positions = grid.positions
649
+
650
+ if self._background is None:
651
+ contrast = np.zeros(grid.shape)
652
+ else:
653
+ background = self._background.to_volume(
654
+ grid=grid,
655
+ seed=seed,
656
+ )
657
+
658
+ contrast = background.delta_refractive_index.copy()
659
+
660
+ occupied = np.zeros(grid.shape, dtype=bool)
661
+
662
+ for region_index, region in enumerate(self.regions):
663
+ if self.warn_on_clipping and self._is_clipped(region=region, grid=grid):
664
+ warnings.warn(
665
+ f"Region {region_index} ({type(region).__name__}) crosses the voxel box and is clipped.",
666
+ UserWarning,
667
+ stacklevel=2,
668
+ )
669
+
670
+ mask = region.mask(positions=positions)
671
+
672
+ if self.overlap == "error" and np.any(mask & occupied):
673
+ raise ValueError(f"Region {region_index} overlaps existing structures at voxel centres.")
674
+
675
+ selected = mask & ~occupied if self.overlap == "preserve" else mask
676
+
677
+ contrast[selected] = region.refractive_index - self.background_refractive_index
678
+
679
+ occupied |= mask
680
+
681
+ return Volume(
682
+ delta_refractive_index=contrast,
683
+ grid=grid,
684
+ background_refractive_index=self.background_refractive_index,
685
+ )
686
+
687
+ @staticmethod
688
+ def _is_clipped(*, region, grid):
689
+ half_box = np.asarray(grid.shape) * grid.spacing / 2
690
+
691
+ centre = region.centre
692
+
693
+ if isinstance(region, Sphere):
694
+ half_extent = np.ones(3) * region.radius
695
+ else:
696
+ matrix = np.asarray(region.rotation)
697
+
698
+ if isinstance(region, Layer):
699
+ normal = matrix[:, region.axis]
700
+
701
+ reach = np.sum(np.abs(normal) * half_box)
702
+
703
+ shift = np.dot(centre, normal)
704
+
705
+ return region.lower + shift < -reach or region.upper + shift > reach
706
+
707
+ if isinstance(region, Box):
708
+ half_extent = np.abs(matrix) @ (region.size / 2)
709
+ elif isinstance(region, Ellipsoid):
710
+ half_extent = np.sqrt(matrix**2 @ region.radii**2)
711
+ else:
712
+ normal = matrix[:, region.axis]
713
+
714
+ half_extent = np.abs(normal) * region.height / 2 + region.radius * np.sqrt(np.maximum(0, 1 - normal**2))
715
+
716
+ return bool(np.any(np.abs(centre) + half_extent > half_box * (1 + 1e-12)))