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.
- anisotropy/__init__.py +28 -0
- anisotropy/anisotropy.py +13 -0
- anisotropy/evaluation.py +70 -0
- anisotropy/model.py +200 -0
- anisotropy/predefined.py +202 -0
- anisotropy/py.typed +1 -0
- anisotropy/values.py +84 -0
- mag_material/__init__.py +3 -0
- mag_material/mag_material.py +231 -0
- mag_material/parameters.py +150 -0
- mag_material/py.typed +1 -0
- nmag/__init__.py +36 -0
- nmag/backends.py +493 -0
- nmag/checkpoint.py +327 -0
- nmag/config.py +174 -0
- nmag/demag/__init__.py +39 -0
- nmag/demag/bem_operator.py +149 -0
- nmag/demag/geometry.py +134 -0
- nmag/demag/lindholm.py +133 -0
- nmag/demag/lindholm_fast.py +463 -0
- nmag/demag/linear.py +489 -0
- nmag/dynamics/__init__.py +255 -0
- nmag/output.py +27 -0
- nmag/parallel.py +45 -0
- nmag/py.typed +1 -0
- nmag/resources.py +38 -0
- nmag/simulation/__init__.py +519 -0
- nmag/simulation/anisotropy/__init__.py +5 -0
- nmag/simulation/anisotropy/fields.py +56 -0
- nmag/simulation/anisotropy/materials.py +138 -0
- nmag/simulation/demag/__init__.py +1 -0
- nmag/simulation/demag/bem/__init__.py +11 -0
- nmag/simulation/demag/bem/diagnostics.py +73 -0
- nmag/simulation/demag/bem/dirichlet.py +85 -0
- nmag/simulation/demag/bem/hierarchical.py +74 -0
- nmag/simulation/demag/bem/operator.py +292 -0
- nmag/simulation/demag/fem/__init__.py +11 -0
- nmag/simulation/demag/fem/assembly.py +114 -0
- nmag/simulation/demag/fem/charges.py +38 -0
- nmag/simulation/demag/fem/geometry.py +261 -0
- nmag/simulation/demag/fields/__init__.py +11 -0
- nmag/simulation/demag/fields/auxiliary.py +186 -0
- nmag/simulation/demag/fields/probe.py +77 -0
- nmag/simulation/demag/fields/recovery.py +207 -0
- nmag/simulation/demag/solver.py +17 -0
- nmag/simulation/dynamics/__init__.py +92 -0
- nmag/simulation/dynamics/advance.py +199 -0
- nmag/simulation/dynamics/integrator.py +263 -0
- nmag/simulation/exchange/__init__.py +13 -0
- nmag/simulation/exchange/coefficients.py +185 -0
- nmag/simulation/exchange/fields.py +106 -0
- nmag/simulation/exchange/llg_rhs.py +218 -0
- nmag/simulation/fields/__init__.py +19 -0
- nmag/simulation/fields/arrays.py +111 -0
- nmag/simulation/fields/availability.py +188 -0
- nmag/simulation/fields/averages.py +293 -0
- nmag/simulation/fields/derived.py +164 -0
- nmag/simulation/fields/maxangle.py +163 -0
- nmag/simulation/fields/probes.py +106 -0
- nmag/simulation/implicit_dynamics.py +221 -0
- nmag/simulation/mesh/__init__.py +13 -0
- nmag/simulation/mesh/geometry.py +153 -0
- nmag/simulation/mesh/materials.py +299 -0
- nmag/simulation/mesh/probe.py +234 -0
- nmag/simulation/restart.py +103 -0
- nmag/simulation/support.py +224 -0
- nmag_python_3-0.0.2.dist-info/METADATA +157 -0
- nmag_python_3-0.0.2.dist-info/RECORD +147 -0
- nmag_python_3-0.0.2.dist-info/WHEEL +5 -0
- nmag_python_3-0.0.2.dist-info/licenses/LICENSE +339 -0
- nmag_python_3-0.0.2.dist-info/top_level.txt +8 -0
- nmesh/__init__.py +130 -0
- nmesh/backend.py +286 -0
- nmesh/geometry/__init__.py +52 -0
- nmesh/geometry/boolean_operations.py +157 -0
- nmesh/geometry/primitives.py +453 -0
- nmesh/geometry/transform.py +126 -0
- nmesh/io/__init__.py +50 -0
- nmesh/io/ascii.py +132 -0
- nmesh/io/legacy_nmesh_hdf5.py +318 -0
- nmesh/io/meshio_support.py +170 -0
- nmesh/mesh_generation.py +182 -0
- nmesh/mesh_io.py +227 -0
- nmesh/mesh_model.py +147 -0
- nmesh/mesh_utilities.py +79 -0
- nmesh/mesher/__init__.py +21 -0
- nmesh/mesher/driver.py +146 -0
- nmesh/mesher/meshing_defaults.py +252 -0
- nmesh/mesher/meshing_parameters.py +185 -0
- nmesh/mesher/parity.py +21 -0
- nmesh/mesher/parity_canonical.py +142 -0
- nmesh/mesher/parity_comparison.py +191 -0
- nmesh/mesher/parity_metrics.py +114 -0
- nmesh/mesher/periodic.py +97 -0
- nmesh/mesher/relaxation/__init__.py +14 -0
- nmesh/mesher/relaxation/_constants.py +20 -0
- nmesh/mesher/relaxation/_types.py +15 -0
- nmesh/mesher/relaxation/density.py +170 -0
- nmesh/mesher/relaxation/engine/__init__.py +18 -0
- nmesh/mesher/relaxation/engine/state.py +155 -0
- nmesh/mesher/relaxation/engine/steps.py +248 -0
- nmesh/mesher/relaxation/engine/topology.py +230 -0
- nmesh/mesher/relaxation/forces/__init__.py +96 -0
- nmesh/mesher/relaxation/forces/jit.py +102 -0
- nmesh/mesher/relaxation/forces/neighbors.py +186 -0
- nmesh/mesher/relaxation/forces/simplex.py +302 -0
- nmesh/mesher/relaxation/forces/summary.py +207 -0
- nmesh/mesher/relaxation/forces/types.py +92 -0
- nmesh/mesher/relaxation/geometry/__init__.py +6 -0
- nmesh/mesher/relaxation/geometry/builder.py +154 -0
- nmesh/mesher/relaxation/geometry/model.py +194 -0
- nmesh/mesher/relaxation/seeding/__init__.py +74 -0
- nmesh/mesher/relaxation/seeding/periodic.py +88 -0
- nmesh/mesher/relaxation/seeding/points.py +88 -0
- nmesh/mesher/relaxation/seeding/sampling.py +142 -0
- nmesh/mesher/relaxation/topology/__init__.py +297 -0
- nmesh/mesher/relaxation/topology/finalize.py +78 -0
- nmesh/mesher/relaxation/topology/recovery.py +310 -0
- nmesh/mesher/sectioned_config.py +70 -0
- nmesh/nmesh.py +99 -0
- nmesh/py.typed +1 -0
- nmesh/utils/__init__.py +33 -0
- nmesh/utils/array_list_utils.py +128 -0
- nmesh/utils/constants.py +22 -0
- nmesh/utils/timing_memory_utils.py +51 -0
- nmesh/utils/types.py +13 -0
- si/constants.py +49 -0
- si/physical.py +722 -0
- si/py.typed +1 -0
- simulation/__init__.py +1 -0
- simulation/clock.py +237 -0
- simulation/data_writer.py +273 -0
- simulation/data_writer_collection.py +267 -0
- simulation/hysteresis.py +74 -0
- simulation/hysteresis_runner.py +286 -0
- simulation/hysteresis_schedule.py +180 -0
- simulation/inference/__init__.py +3 -0
- simulation/inference/inference.py +95 -0
- simulation/py.typed +1 -0
- simulation/quantity.py +88 -0
- simulation/simulation_core.py +458 -0
- throttler/__init__.py +3 -0
- throttler/py.typed +1 -0
- throttler/throttler.py +55 -0
- when/__init__.py +3 -0
- when/py.typed +1 -0
- 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}")
|