nmag-python-3 0.0.2__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 (147) hide show
  1. anisotropy/__init__.py +28 -0
  2. anisotropy/anisotropy.py +13 -0
  3. anisotropy/evaluation.py +70 -0
  4. anisotropy/model.py +200 -0
  5. anisotropy/predefined.py +202 -0
  6. anisotropy/py.typed +1 -0
  7. anisotropy/values.py +84 -0
  8. mag_material/__init__.py +3 -0
  9. mag_material/mag_material.py +231 -0
  10. mag_material/parameters.py +150 -0
  11. mag_material/py.typed +1 -0
  12. nmag/__init__.py +36 -0
  13. nmag/backends.py +493 -0
  14. nmag/checkpoint.py +327 -0
  15. nmag/config.py +174 -0
  16. nmag/demag/__init__.py +39 -0
  17. nmag/demag/bem_operator.py +149 -0
  18. nmag/demag/geometry.py +134 -0
  19. nmag/demag/lindholm.py +133 -0
  20. nmag/demag/lindholm_fast.py +463 -0
  21. nmag/demag/linear.py +489 -0
  22. nmag/dynamics/__init__.py +255 -0
  23. nmag/output.py +27 -0
  24. nmag/parallel.py +45 -0
  25. nmag/py.typed +1 -0
  26. nmag/resources.py +38 -0
  27. nmag/simulation/__init__.py +519 -0
  28. nmag/simulation/anisotropy/__init__.py +5 -0
  29. nmag/simulation/anisotropy/fields.py +56 -0
  30. nmag/simulation/anisotropy/materials.py +138 -0
  31. nmag/simulation/demag/__init__.py +1 -0
  32. nmag/simulation/demag/bem/__init__.py +11 -0
  33. nmag/simulation/demag/bem/diagnostics.py +73 -0
  34. nmag/simulation/demag/bem/dirichlet.py +85 -0
  35. nmag/simulation/demag/bem/hierarchical.py +74 -0
  36. nmag/simulation/demag/bem/operator.py +292 -0
  37. nmag/simulation/demag/fem/__init__.py +11 -0
  38. nmag/simulation/demag/fem/assembly.py +114 -0
  39. nmag/simulation/demag/fem/charges.py +38 -0
  40. nmag/simulation/demag/fem/geometry.py +261 -0
  41. nmag/simulation/demag/fields/__init__.py +11 -0
  42. nmag/simulation/demag/fields/auxiliary.py +186 -0
  43. nmag/simulation/demag/fields/probe.py +77 -0
  44. nmag/simulation/demag/fields/recovery.py +207 -0
  45. nmag/simulation/demag/solver.py +17 -0
  46. nmag/simulation/dynamics/__init__.py +92 -0
  47. nmag/simulation/dynamics/advance.py +199 -0
  48. nmag/simulation/dynamics/integrator.py +263 -0
  49. nmag/simulation/exchange/__init__.py +13 -0
  50. nmag/simulation/exchange/coefficients.py +185 -0
  51. nmag/simulation/exchange/fields.py +106 -0
  52. nmag/simulation/exchange/llg_rhs.py +218 -0
  53. nmag/simulation/fields/__init__.py +19 -0
  54. nmag/simulation/fields/arrays.py +111 -0
  55. nmag/simulation/fields/availability.py +188 -0
  56. nmag/simulation/fields/averages.py +293 -0
  57. nmag/simulation/fields/derived.py +164 -0
  58. nmag/simulation/fields/maxangle.py +163 -0
  59. nmag/simulation/fields/probes.py +106 -0
  60. nmag/simulation/implicit_dynamics.py +221 -0
  61. nmag/simulation/mesh/__init__.py +13 -0
  62. nmag/simulation/mesh/geometry.py +153 -0
  63. nmag/simulation/mesh/materials.py +299 -0
  64. nmag/simulation/mesh/probe.py +234 -0
  65. nmag/simulation/restart.py +103 -0
  66. nmag/simulation/support.py +224 -0
  67. nmag_python_3-0.0.2.dist-info/METADATA +157 -0
  68. nmag_python_3-0.0.2.dist-info/RECORD +147 -0
  69. nmag_python_3-0.0.2.dist-info/WHEEL +5 -0
  70. nmag_python_3-0.0.2.dist-info/licenses/LICENSE +339 -0
  71. nmag_python_3-0.0.2.dist-info/top_level.txt +8 -0
  72. nmesh/__init__.py +130 -0
  73. nmesh/backend.py +286 -0
  74. nmesh/geometry/__init__.py +52 -0
  75. nmesh/geometry/boolean_operations.py +157 -0
  76. nmesh/geometry/primitives.py +453 -0
  77. nmesh/geometry/transform.py +126 -0
  78. nmesh/io/__init__.py +50 -0
  79. nmesh/io/ascii.py +132 -0
  80. nmesh/io/legacy_nmesh_hdf5.py +318 -0
  81. nmesh/io/meshio_support.py +170 -0
  82. nmesh/mesh_generation.py +182 -0
  83. nmesh/mesh_io.py +227 -0
  84. nmesh/mesh_model.py +147 -0
  85. nmesh/mesh_utilities.py +79 -0
  86. nmesh/mesher/__init__.py +21 -0
  87. nmesh/mesher/driver.py +146 -0
  88. nmesh/mesher/meshing_defaults.py +252 -0
  89. nmesh/mesher/meshing_parameters.py +185 -0
  90. nmesh/mesher/parity.py +21 -0
  91. nmesh/mesher/parity_canonical.py +142 -0
  92. nmesh/mesher/parity_comparison.py +191 -0
  93. nmesh/mesher/parity_metrics.py +114 -0
  94. nmesh/mesher/periodic.py +97 -0
  95. nmesh/mesher/relaxation/__init__.py +14 -0
  96. nmesh/mesher/relaxation/_constants.py +20 -0
  97. nmesh/mesher/relaxation/_types.py +15 -0
  98. nmesh/mesher/relaxation/density.py +170 -0
  99. nmesh/mesher/relaxation/engine/__init__.py +18 -0
  100. nmesh/mesher/relaxation/engine/state.py +155 -0
  101. nmesh/mesher/relaxation/engine/steps.py +248 -0
  102. nmesh/mesher/relaxation/engine/topology.py +230 -0
  103. nmesh/mesher/relaxation/forces/__init__.py +96 -0
  104. nmesh/mesher/relaxation/forces/jit.py +102 -0
  105. nmesh/mesher/relaxation/forces/neighbors.py +186 -0
  106. nmesh/mesher/relaxation/forces/simplex.py +302 -0
  107. nmesh/mesher/relaxation/forces/summary.py +207 -0
  108. nmesh/mesher/relaxation/forces/types.py +92 -0
  109. nmesh/mesher/relaxation/geometry/__init__.py +6 -0
  110. nmesh/mesher/relaxation/geometry/builder.py +154 -0
  111. nmesh/mesher/relaxation/geometry/model.py +194 -0
  112. nmesh/mesher/relaxation/seeding/__init__.py +74 -0
  113. nmesh/mesher/relaxation/seeding/periodic.py +88 -0
  114. nmesh/mesher/relaxation/seeding/points.py +88 -0
  115. nmesh/mesher/relaxation/seeding/sampling.py +142 -0
  116. nmesh/mesher/relaxation/topology/__init__.py +297 -0
  117. nmesh/mesher/relaxation/topology/finalize.py +78 -0
  118. nmesh/mesher/relaxation/topology/recovery.py +310 -0
  119. nmesh/mesher/sectioned_config.py +70 -0
  120. nmesh/nmesh.py +99 -0
  121. nmesh/py.typed +1 -0
  122. nmesh/utils/__init__.py +33 -0
  123. nmesh/utils/array_list_utils.py +128 -0
  124. nmesh/utils/constants.py +22 -0
  125. nmesh/utils/timing_memory_utils.py +51 -0
  126. nmesh/utils/types.py +13 -0
  127. si/constants.py +49 -0
  128. si/physical.py +722 -0
  129. si/py.typed +1 -0
  130. simulation/__init__.py +1 -0
  131. simulation/clock.py +237 -0
  132. simulation/data_writer.py +273 -0
  133. simulation/data_writer_collection.py +267 -0
  134. simulation/hysteresis.py +74 -0
  135. simulation/hysteresis_runner.py +286 -0
  136. simulation/hysteresis_schedule.py +180 -0
  137. simulation/inference/__init__.py +3 -0
  138. simulation/inference/inference.py +95 -0
  139. simulation/py.typed +1 -0
  140. simulation/quantity.py +88 -0
  141. simulation/simulation_core.py +458 -0
  142. throttler/__init__.py +3 -0
  143. throttler/py.typed +1 -0
  144. throttler/throttler.py +55 -0
  145. when/__init__.py +3 -0
  146. when/py.typed +1 -0
  147. when/when.py +416 -0
@@ -0,0 +1,453 @@
1
+ """
2
+ Geometric primitives and implicit surface definitions.
3
+
4
+ This module provides pure-Python implementations of geometric primitives
5
+ (Box, Ellipsoid, Conic, Helix) using NumPy-based signed distance functions,
6
+ replacing the OCaml-based body primitives from mesh.ml.
7
+
8
+ Each primitive is represented as an implicit body with a scalar field
9
+ where positive values indicate interior points and negative values indicate
10
+ exterior points. Transformations are applied via affine matrix composition.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import itertools
16
+ import math
17
+ from collections.abc import Callable, Iterable, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import Literal, TypeAlias
20
+
21
+ import numpy as np
22
+
23
+ from ..utils.types import ArrayLike, BoolArray, FloatArray
24
+ from .transform import (
25
+ AffineTransform,
26
+ _as_vector,
27
+ inverse_axis_rotation,
28
+ inverse_plane_rotation,
29
+ inverse_scale,
30
+ inverse_shift,
31
+ )
32
+
33
+ SignedField: TypeAlias = Callable[[FloatArray], FloatArray]
34
+ ShiftTransform: TypeAlias = tuple[Literal["shift"], ArrayLike]
35
+ ScaleTransform: TypeAlias = tuple[Literal["scale"], ArrayLike]
36
+ RotateTransform: TypeAlias = tuple[Literal["rotate"], tuple[int, int], float]
37
+ Rotate2DTransform: TypeAlias = tuple[Literal["rotate2d"], float]
38
+ Rotate3DTransform: TypeAlias = tuple[Literal["rotate3d"], ArrayLike, float]
39
+ TransformationStep: TypeAlias = (
40
+ ShiftTransform | ScaleTransform | RotateTransform | Rotate2DTransform | Rotate3DTransform
41
+ )
42
+
43
+ # Number of complete spiral rotations for the helix primitive (4 turns = 8π)
44
+ _HELIX_SPIRAL_TURNS = 4.0
45
+
46
+
47
+ def _as_float_points(points: Sequence[Sequence[float]] | None) -> list[list[float]]:
48
+ return [list(map(float, point)) for point in (points or [])]
49
+
50
+
51
+ def _coerce_query_points(points: ArrayLike, dim: int) -> tuple[FloatArray, bool]:
52
+ coords = np.asarray(points, dtype=float)
53
+ if coords.ndim == 1:
54
+ if len(coords) != dim:
55
+ raise ValueError(f"Expected a point of length {dim}, got {len(coords)}")
56
+ return coords[np.newaxis, :].astype(np.float64, copy=False), True
57
+ if coords.ndim == 2 and coords.shape[1] == dim:
58
+ return coords.astype(np.float64, copy=False), False
59
+ raise ValueError(f"Expected points with shape ({dim},) or (N, {dim}), got {coords.shape}")
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Body:
64
+ """Implicit body evaluated through a sign-preserving scalar field."""
65
+
66
+ dim: int
67
+ evaluator: SignedField
68
+ transform: AffineTransform
69
+
70
+ def evaluate(self, points: ArrayLike) -> float | FloatArray:
71
+ """Evaluate the body's scalar field at one point or an array of points."""
72
+
73
+ coords, scalar = _coerce_query_points(points, self.dim)
74
+ local_coords = self.transform.apply(coords)
75
+ values = np.asarray(self.evaluator(local_coords), dtype=float).flatten()
76
+ if scalar:
77
+ return float(values[0])
78
+ return values.astype(np.float64, copy=False)
79
+
80
+ def transformed(
81
+ self,
82
+ inverse_transform: AffineTransform,
83
+ *,
84
+ system_coords: bool,
85
+ ) -> Body:
86
+ """Return a copy of the body with one more affine transform applied.
87
+
88
+ Args:
89
+ inverse_transform: Inverse affine map for the requested transformation.
90
+ system_coords: When `True`, apply the new transform in system/space
91
+ coordinates. When `False`, apply it in the body's local coordinates.
92
+
93
+ Returns:
94
+ A new body carrying the composed affine transform.
95
+
96
+ Note:
97
+ Composition order matters:
98
+ - `system_coords=True` applies the new transform outside the existing one
99
+ - `system_coords=False` applies the new transform inside the existing one
100
+ """
101
+
102
+ combined = (
103
+ self.transform.compose(inverse_transform)
104
+ if system_coords
105
+ else inverse_transform.compose(self.transform)
106
+ )
107
+ return Body(self.dim, self.evaluator, combined)
108
+
109
+
110
+ def _make_body(dim: int, evaluator: SignedField) -> Body:
111
+ """Create a body whose transform starts as the identity."""
112
+
113
+ return Body(dim, evaluator, AffineTransform.identity(dim))
114
+
115
+
116
+ def bc_ellipsoid(radii: ArrayLike) -> SignedField:
117
+ """Build the implicit field for an axis-aligned ellipsoid."""
118
+
119
+ radii_vector = _as_vector(radii)
120
+ if np.any(radii_vector <= 0.0):
121
+ raise ValueError("Ellipsoid radii must be positive")
122
+
123
+ def evaluate(points: FloatArray) -> FloatArray:
124
+ scaled = points / radii_vector
125
+ return 1.0 - np.sum(scaled * scaled, axis=1)
126
+
127
+ return evaluate
128
+
129
+
130
+ def bc_box(corner_nw: ArrayLike, corner_se: ArrayLike) -> SignedField:
131
+ """Build the implicit field for an axis-aligned box."""
132
+
133
+ point1 = _as_vector(corner_nw)
134
+ point2 = _as_vector(corner_se, dim=len(point1))
135
+ half_lengths = np.abs(point2 - point1) * 0.5
136
+ if np.any(half_lengths == 0.0):
137
+ raise ValueError("Box edge lengths must be non-zero")
138
+ midpoint = (point1 + point2) * 0.5
139
+
140
+ def evaluate(points: FloatArray) -> FloatArray:
141
+ relative = np.abs((points - midpoint) / half_lengths)
142
+ return 1.0 - np.max(relative, axis=1)
143
+
144
+ return evaluate
145
+
146
+
147
+ def _project_to_axis(points: FloatArray, axis: FloatArray) -> FloatArray:
148
+ return points @ axis
149
+
150
+
151
+ def bc_frustum(
152
+ center1: ArrayLike,
153
+ radius1: float,
154
+ center2: ArrayLike,
155
+ radius2: float,
156
+ ) -> SignedField:
157
+ """Build the implicit field for a conical frustum."""
158
+
159
+ point1 = _as_vector(center1)
160
+ point2 = _as_vector(center2, dim=len(point1))
161
+ if min(radius1, radius2) < 0.0 or max(radius1, radius2) == 0.0:
162
+ raise ValueError("Frustum radii must be non-negative and not both zero")
163
+
164
+ axis = point2 - point1
165
+ axis_projection_center1 = float(np.dot(axis, point1))
166
+ axis_projection_center2 = float(np.dot(axis, point2))
167
+ axis_projection_delta = axis_projection_center2 - axis_projection_center1
168
+ if axis_projection_delta == 0.0:
169
+ raise ValueError("Frustum endpoints must not coincide")
170
+
171
+ min_ap = min(axis_projection_center1, axis_projection_center2)
172
+ max_ap = max(axis_projection_center1, axis_projection_center2)
173
+ centre12 = 0.5 * (axis_projection_center1 + axis_projection_center2)
174
+ radius_delta = radius2 - radius1
175
+
176
+ def evaluate(points: FloatArray) -> FloatArray:
177
+ ap = _project_to_axis(points, axis)
178
+ out_top_bottom = np.where(
179
+ ap > centre12,
180
+ (ap - max_ap) / (centre12 - max_ap),
181
+ np.where(
182
+ ap < centre12,
183
+ (min_ap - ap) / (min_ap - centre12),
184
+ 1.0,
185
+ ),
186
+ )
187
+ axis_factor = (ap - axis_projection_center1) / axis_projection_delta
188
+ radius_here = radius1 + radius_delta * axis_factor
189
+ axis_projection = point1 + np.outer(axis_factor, axis)
190
+ delta = points - axis_projection
191
+ axis_distance = np.sqrt(np.sum(delta * delta, axis=1))
192
+ return np.minimum(out_top_bottom, radius_here - axis_distance)
193
+
194
+ return evaluate
195
+
196
+
197
+ def bc_helix(
198
+ center1: ArrayLike,
199
+ radius1: float,
200
+ center2: ArrayLike,
201
+ radius2: float,
202
+ ) -> SignedField:
203
+ """Build the implicit field for the tapered helix primitive."""
204
+
205
+ point1 = _as_vector(center1, dim=3)
206
+ point2 = _as_vector(center2, dim=3)
207
+ if min(radius1, radius2) <= 0.0:
208
+ raise ValueError("Helix radii must be positive")
209
+
210
+ axis = point2 - point1
211
+ axis_projection_center1 = float(np.dot(axis, point1))
212
+ axis_projection_center2 = float(np.dot(axis, point2))
213
+ axis_projection_delta = axis_projection_center2 - axis_projection_center1
214
+ if axis_projection_delta == 0.0:
215
+ raise ValueError("Helix endpoints must not coincide")
216
+
217
+ min_ap = min(axis_projection_center1, axis_projection_center2)
218
+ max_ap = max(axis_projection_center1, axis_projection_center2)
219
+ centre12 = 0.5 * (axis_projection_center1 + axis_projection_center2)
220
+
221
+ def evaluate(points: FloatArray) -> FloatArray:
222
+ ap = _project_to_axis(points, axis)
223
+ axis_factor = (ap - axis_projection_center1) / axis_projection_delta
224
+ out_top_bottom = np.where(
225
+ ap > centre12,
226
+ (ap - max_ap) / (centre12 - max_ap),
227
+ np.where(
228
+ ap < centre12,
229
+ (min_ap - ap) / (min_ap - centre12),
230
+ 1.0,
231
+ ),
232
+ )
233
+ alpha = 2.0 * math.pi * _HELIX_SPIRAL_TURNS * axis_factor
234
+ spiral_direction = np.column_stack((np.cos(alpha), np.sin(alpha), np.zeros_like(alpha)))
235
+ helix_circle_radius = radius2 * (1.0 - axis_factor)
236
+ helix_spiral_radius = radius1 * (1.0 - axis_factor)
237
+ axis_projection = point1 + np.outer(axis_factor, axis)
238
+ spiral_centres = axis_projection + helix_spiral_radius[:, None] * spiral_direction
239
+ helix_value = helix_circle_radius**2 - np.sum(
240
+ (points - spiral_centres) ** 2,
241
+ axis=1,
242
+ )
243
+ return np.minimum(out_top_bottom, helix_value)
244
+
245
+ return evaluate
246
+
247
+
248
+ class MeshObject:
249
+ """Base class for geometric primitives and CSG expressions."""
250
+
251
+ def __init__(
252
+ self,
253
+ dim: int,
254
+ fixed: Sequence[Sequence[float]] | None = None,
255
+ mobile: Sequence[Sequence[float]] | None = None,
256
+ *,
257
+ body: Body | None = None,
258
+ ) -> None:
259
+ self.dim = int(dim)
260
+ self.fixed_points = _as_float_points(fixed)
261
+ self.mobile_points = _as_float_points(mobile)
262
+ self.obj = body
263
+
264
+ def _require_body(self) -> Body:
265
+ if self.obj is None:
266
+ raise ValueError("MeshObject does not wrap a body")
267
+ return self.obj
268
+
269
+ def signed_distance(self, points: ArrayLike) -> float | FloatArray:
270
+ """Evaluate the object's scalar field at one point or many points."""
271
+
272
+ return self._require_body().evaluate(points)
273
+
274
+ def contains(self, points: ArrayLike) -> bool | BoolArray:
275
+ """Return whether the supplied point or points lie inside the object."""
276
+
277
+ values = self.signed_distance(points)
278
+ if isinstance(values, float):
279
+ return values > 0.0
280
+ return values > 0.0
281
+
282
+ def shift(self, vector: ArrayLike, system_coords: bool = True) -> None:
283
+ """Translate the object by the given vector."""
284
+
285
+ body = self._require_body()
286
+ inverse_transform = inverse_shift(_as_vector(vector, dim=self.dim))
287
+ self.obj = body.transformed(inverse_transform, system_coords=system_coords)
288
+
289
+ def scale(self, factors: ArrayLike) -> None:
290
+ """Scale the object in body coordinates by the supplied per-axis factors."""
291
+
292
+ body = self._require_body()
293
+ inverse_transform = inverse_scale(_as_vector(factors, dim=self.dim))
294
+ self.obj = body.transformed(inverse_transform, system_coords=False)
295
+
296
+ def rotate(self, a1: int, a2: int, angle: float, system_coords: bool = True) -> None:
297
+ """Rotate the object in the plane spanned by the two axis indices."""
298
+
299
+ body = self._require_body()
300
+ radians = math.radians(float(angle))
301
+ inverse_transform = inverse_plane_rotation(self.dim, int(a1), int(a2), radians)
302
+ self.obj = body.transformed(inverse_transform, system_coords=system_coords)
303
+
304
+ def rotate_3d(self, axis: ArrayLike, angle: float, system_coords: bool = True) -> None:
305
+ """Rotate a three-dimensional object about the supplied axis vector."""
306
+
307
+ body = self._require_body()
308
+ if self.dim != 3:
309
+ raise ValueError("3D axis rotation is only available for three-dimensional bodies")
310
+ radians = math.radians(float(angle))
311
+ inverse_transform = inverse_axis_rotation(axis, radians)
312
+ self.obj = body.transformed(inverse_transform, system_coords=system_coords)
313
+
314
+ def transform(
315
+ self,
316
+ transformations: Iterable[TransformationStep] | None,
317
+ system_coords: bool = True,
318
+ ) -> None:
319
+ """Apply a sequence of named transform tuples in the given order."""
320
+
321
+ for transformation in transformations or []:
322
+ match transformation:
323
+ case ("shift", vector):
324
+ self.shift(vector, system_coords)
325
+ case ("scale", factors):
326
+ self.scale(factors)
327
+ case ("rotate", (axis1, axis2), angle):
328
+ self.rotate(axis1, axis2, angle, system_coords)
329
+ case ("rotate2d", angle):
330
+ self.rotate(0, 1, angle, system_coords)
331
+ case ("rotate3d", axis, angle):
332
+ self.rotate_3d(axis, angle, system_coords)
333
+ case _:
334
+ raise ValueError(f"Unknown transformation {transformation!r}")
335
+
336
+
337
+ class Box(MeshObject):
338
+ """Create a box from two opposite corners.
339
+
340
+ Args:
341
+ p1: First corner coordinates.
342
+ p2: Opposite corner coordinates.
343
+ transform: Optional transformation steps applied in order.
344
+ fixed: Extra fixed mesher points.
345
+ mobile: Extra mobile mesher points.
346
+ system_coords: Apply transformations in system coordinates when true.
347
+ use_fixed_corners: Add every box corner as a fixed mesher point.
348
+ """
349
+
350
+ def __init__(
351
+ self,
352
+ p1: ArrayLike,
353
+ p2: ArrayLike,
354
+ transform: Iterable[TransformationStep] | None = None,
355
+ fixed: Sequence[Sequence[float]] | None = None,
356
+ mobile: Sequence[Sequence[float]] | None = None,
357
+ system_coords: bool = True,
358
+ use_fixed_corners: bool = False,
359
+ ) -> None:
360
+ point1 = _as_vector(p1)
361
+ point2 = _as_vector(p2, dim=len(point1))
362
+ fixed_points = _as_float_points(fixed)
363
+ if use_fixed_corners:
364
+ fixed_points.extend(
365
+ [list(corner) for corner in itertools.product(*zip(point1, point2, strict=True))]
366
+ )
367
+ body = _make_body(len(point1), bc_box(point1, point2))
368
+ super().__init__(len(point1), fixed_points, mobile, body=body)
369
+ self.transform(transform, system_coords)
370
+
371
+
372
+ class Ellipsoid(MeshObject):
373
+ """Create an axis-aligned ellipsoid from its principal radii.
374
+
375
+ Args:
376
+ lengths: Positive radius along each coordinate axis.
377
+ transform: Optional transformation steps applied in order.
378
+ fixed: Extra fixed mesher points.
379
+ mobile: Extra mobile mesher points.
380
+ system_coords: Apply transformations in system coordinates when true.
381
+ """
382
+
383
+ def __init__(
384
+ self,
385
+ lengths: ArrayLike,
386
+ transform: Iterable[TransformationStep] | None = None,
387
+ fixed: Sequence[Sequence[float]] | None = None,
388
+ mobile: Sequence[Sequence[float]] | None = None,
389
+ system_coords: bool = True,
390
+ ) -> None:
391
+ radii = _as_vector(lengths)
392
+ body = _make_body(len(radii), bc_ellipsoid(radii))
393
+ super().__init__(len(radii), fixed, mobile, body=body)
394
+ self.transform(transform, system_coords)
395
+
396
+
397
+ class Conic(MeshObject):
398
+ """Create a conical frustum from two centers and radii.
399
+
400
+ Args:
401
+ c1: First end center.
402
+ r1: Non-negative first-end radius.
403
+ c2: Second end center.
404
+ r2: Non-negative second-end radius.
405
+ transform: Optional transformation steps applied in order.
406
+ fixed: Extra fixed mesher points.
407
+ mobile: Extra mobile mesher points.
408
+ system_coords: Apply transformations in system coordinates when true.
409
+ """
410
+
411
+ def __init__(
412
+ self,
413
+ c1: ArrayLike,
414
+ r1: float,
415
+ c2: ArrayLike,
416
+ r2: float,
417
+ transform: Iterable[TransformationStep] | None = None,
418
+ fixed: Sequence[Sequence[float]] | None = None,
419
+ mobile: Sequence[Sequence[float]] | None = None,
420
+ system_coords: bool = True,
421
+ ) -> None:
422
+ point1 = _as_vector(c1)
423
+ point2 = _as_vector(c2, dim=len(point1))
424
+ body = _make_body(
425
+ len(point1),
426
+ bc_frustum(point1, float(r1), point2, float(r2)),
427
+ )
428
+ super().__init__(len(point1), fixed, mobile, body=body)
429
+ self.transform(transform, system_coords)
430
+
431
+
432
+ class Helix(MeshObject):
433
+ """Tapered helix primitive used by the original mesher geometry layer."""
434
+
435
+ def __init__(
436
+ self,
437
+ c1: ArrayLike,
438
+ r1: float,
439
+ c2: ArrayLike,
440
+ r2: float,
441
+ transform: Iterable[TransformationStep] | None = None,
442
+ fixed: Sequence[Sequence[float]] | None = None,
443
+ mobile: Sequence[Sequence[float]] | None = None,
444
+ system_coords: bool = True,
445
+ ) -> None:
446
+ point1 = _as_vector(c1, dim=3)
447
+ point2 = _as_vector(c2, dim=3)
448
+ body = _make_body(
449
+ 3,
450
+ bc_helix(point1, float(r1), point2, float(r2)),
451
+ )
452
+ super().__init__(3, fixed, mobile, body=body)
453
+ self.transform(transform, system_coords)
@@ -0,0 +1,126 @@
1
+ """
2
+ Affine transformations for geometric primitives.
3
+
4
+ This module provides affine transformation support for body primitives,
5
+ including translation, scaling, and rotation operations. Transforms are
6
+ composed using matrix multiplication to maintain efficiency when multiple
7
+ operations are chained together.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+
16
+ from ..utils.constants import MIN_ABS_SCALE_FACTOR
17
+ from ..utils.types import ArrayLike, FloatArray
18
+
19
+
20
+ def _as_vector(values: ArrayLike, dim: int | None = None) -> FloatArray:
21
+ vector = np.asarray(values, dtype=float)
22
+ if vector.ndim != 1:
23
+ raise ValueError("Expected a one-dimensional vector")
24
+ if dim is not None and len(vector) != dim:
25
+ raise ValueError(f"Expected a vector of length {dim}, got {len(vector)}")
26
+ return vector.astype(np.float64, copy=False)
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class AffineTransform:
31
+ """Affine map used to evaluate transformed bodies in their local coordinates."""
32
+
33
+ matrix: FloatArray
34
+ displacement: FloatArray
35
+
36
+ @classmethod
37
+ def identity(cls, dim: int) -> AffineTransform:
38
+ """Return the identity transform for a space of the given dimension."""
39
+
40
+ return cls(np.eye(dim, dtype=float), np.zeros(dim, dtype=float))
41
+
42
+ @property
43
+ def dim(self) -> int:
44
+ return int(self.displacement.shape[0])
45
+
46
+ def apply(self, points: ArrayLike) -> FloatArray:
47
+ """Apply the affine transform to an array of points."""
48
+
49
+ pts = np.asarray(points, dtype=float)
50
+ if pts.ndim != 2 or pts.shape[1] != self.dim:
51
+ raise ValueError(f"Expected points with shape (N, {self.dim}), got {pts.shape}")
52
+ transformed = np.matmul(pts, np.transpose(self.matrix)) + self.displacement
53
+ return np.asarray(transformed, dtype=np.float64)
54
+
55
+ def compose(self, other: AffineTransform) -> AffineTransform:
56
+ """Return the transform equivalent to applying `other` then `self`."""
57
+
58
+ if self.dim != other.dim:
59
+ raise ValueError("Cannot compose affine transforms with different dimensions")
60
+ return AffineTransform(
61
+ self.matrix @ other.matrix,
62
+ self.matrix @ other.displacement + self.displacement,
63
+ )
64
+
65
+
66
+ def inverse_shift(vector: ArrayLike) -> AffineTransform:
67
+ """Return the inverse transform for a translation by `vector`."""
68
+
69
+ shift = _as_vector(vector)
70
+ return AffineTransform(np.eye(len(shift), dtype=float), -shift)
71
+
72
+
73
+ def inverse_scale(factors: ArrayLike) -> AffineTransform:
74
+ """Return the inverse transform for a per-axis scale."""
75
+
76
+ scale = _as_vector(factors)
77
+ if np.any(np.abs(scale) < MIN_ABS_SCALE_FACTOR):
78
+ raise ValueError(
79
+ f"Scale factors must be non-zero (absolute value >= {MIN_ABS_SCALE_FACTOR})"
80
+ )
81
+ matrix = np.zeros((len(scale), len(scale)), dtype=np.float64)
82
+ for index, value in enumerate(scale):
83
+ matrix[index, index] = 1.0 / value
84
+ return AffineTransform(matrix, np.zeros(len(scale), dtype=float))
85
+
86
+
87
+ def inverse_plane_rotation(dim: int, axis1: int, axis2: int, radians: float) -> AffineTransform:
88
+ """Return the inverse transform for a planar rotation in `dim` dimensions."""
89
+
90
+ if axis1 == axis2:
91
+ raise ValueError("Rotation axes must be different")
92
+ if not (0 <= axis1 < dim and 0 <= axis2 < dim):
93
+ raise ValueError("Rotation axis index out of bounds")
94
+
95
+ matrix = np.eye(dim, dtype=float)
96
+ co = np.cos(-radians)
97
+ si = np.sin(-radians)
98
+ matrix[axis1, axis1] = co
99
+ matrix[axis2, axis2] = co
100
+ matrix[axis2, axis1] = si
101
+ matrix[axis1, axis2] = -si
102
+ return AffineTransform(matrix, np.zeros(dim, dtype=float))
103
+
104
+
105
+ def inverse_axis_rotation(axis: ArrayLike, radians: float) -> AffineTransform:
106
+ """Return the inverse transform for a three-dimensional axis rotation."""
107
+
108
+ axis_vector = _as_vector(axis, dim=3)
109
+ norm = np.linalg.norm(axis_vector)
110
+ if norm == 0.0:
111
+ raise ValueError("Rotation axis must be non-zero")
112
+
113
+ ax, ay, az = axis_vector / norm
114
+ co = np.cos(-radians)
115
+ si = np.sin(-radians)
116
+ t = 1.0 - co
117
+
118
+ matrix = np.array(
119
+ [
120
+ [co + t * ax * ax, t * ax * ay - az * si, t * ax * az + ay * si],
121
+ [t * ax * ay + az * si, co + t * ay * ay, t * ay * az - ax * si],
122
+ [t * ax * az - ay * si, t * ay * az + ax * si, co + t * az * az],
123
+ ],
124
+ dtype=float,
125
+ )
126
+ return AffineTransform(matrix, np.zeros(3, dtype=float))
nmesh/io/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Mesh file input and output helpers.
2
+
3
+ This package provides:
4
+ - `load_raw_mesh_with_meshio`: Load meshes via meshio or recognized legacy HDF5
5
+ - `save_raw_mesh_with_meshio`: Save meshes to any meshio-supported format
6
+ - `load_raw_mesh_from_legacy_nmesh_hdf5`: Direct legacy .nmesh.h5 loader
7
+ - `save_raw_mesh_as_legacy_nmesh_hdf5`: Direct legacy .nmesh.h5 writer
8
+
9
+ For most use cases, use `load_raw_mesh_with_meshio` which handles
10
+ both modern formats (via meshio) and recognized legacy .nmesh.h5 files.
11
+ """
12
+
13
+ from typing import Any
14
+
15
+ _LEGACY_HDF5_EXPORTS = {
16
+ "is_legacy_nmesh_hdf5",
17
+ "load_raw_mesh_from_legacy_nmesh_hdf5",
18
+ "save_raw_mesh_as_legacy_nmesh_hdf5",
19
+ }
20
+
21
+ _MESHIO_EXPORTS = {
22
+ "load_raw_mesh_with_meshio",
23
+ "save_raw_mesh_with_meshio",
24
+ }
25
+
26
+ __all__ = [
27
+ "is_legacy_nmesh_hdf5",
28
+ "load_raw_mesh_from_legacy_nmesh_hdf5",
29
+ "load_raw_mesh_with_meshio",
30
+ "save_raw_mesh_as_legacy_nmesh_hdf5",
31
+ "save_raw_mesh_with_meshio",
32
+ ]
33
+
34
+
35
+ def __getattr__(name: str) -> Any:
36
+ if name in _LEGACY_HDF5_EXPORTS:
37
+ from . import legacy_nmesh_hdf5
38
+
39
+ value = getattr(legacy_nmesh_hdf5, name)
40
+ globals()[name] = value
41
+ return value
42
+
43
+ if name in _MESHIO_EXPORTS:
44
+ from . import meshio_support
45
+
46
+ value = getattr(meshio_support, name)
47
+ globals()[name] = value
48
+ return value
49
+
50
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")