pyFracAggregate 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.
@@ -0,0 +1,105 @@
1
+ import numpy as np
2
+ from pyFracAggregate.core.aggregate import Aggregate
3
+ from pyFracAggregate.generators.factory import get_generator
4
+ from pyFracAggregate.core.distributions import Monodisperse, LognormalDistribution
5
+ from pyFracAggregate.analysis.morphology import radius_of_gyration, center_of_mass
6
+ from pyFracAggregate.analysis.correlation import (
7
+ pair_correlation_function,
8
+ estimate_fractal_dimension,
9
+ plot_pair_correlation
10
+ )
11
+ from pyFracAggregate.io.vtk import export_vtm, export_vtk
12
+ from pyFracAggregate.io.data import export_yaml
13
+ from pyFracAggregate.io.visualization import export_render, export_rotation_video
14
+ from pyFracAggregate.generators.pca import PCAGenerator
15
+ from pyFracAggregate.generators.cca import CCAGenerator
16
+ from pyFracAggregate.generators.fracval import FracVALGenerator
17
+ from pyFracAggregate.generators.tdcca import ThouyJullienGenerator
18
+ from pyFracAggregate.generators.placement.algebraic import AlgebraicPlacement
19
+ from pyFracAggregate.generators.placement.random_ import RandomPlacement
20
+
21
+ __version__ = "0.1.0"
22
+ __author__ = "Fan Zhang"
23
+
24
+ def generate(
25
+ n_particles: int,
26
+ df: float,
27
+ kf: float,
28
+ method: str = 'pca',
29
+ particle_dist = None,
30
+ overlap_tolerance: float = 1e-5,
31
+ placement: str = 'algebraic',
32
+ **kwargs
33
+ ) -> Aggregate:
34
+ """
35
+ High-level API to generate a fractal aggregate.
36
+
37
+ Args:
38
+ n_particles (int): Target number of particles.
39
+ df (float): Fractal dimension (typically 1.5 - 2.5).
40
+ kf (float): Fractal prefactor (typically 1.0 - 2.0).
41
+ method (str): Algorithm to use ('pca', 'cca', 'fracval', 'tdcca').
42
+ particle_dist: Particle radius distribution (defaults to Monodisperse(1.0)).
43
+ overlap_tolerance (float): Allowed overlap between spheres.
44
+ placement (str): Placement strategy ('algebraic' or 'random').
45
+
46
+ Returns:
47
+ Aggregate: The generated fractal aggregate.
48
+ """
49
+ if particle_dist is None:
50
+ particle_dist = Monodisperse(1.0)
51
+
52
+ generator = get_generator(
53
+ method=method,
54
+ n_particles=n_particles,
55
+ df=df,
56
+ kf=kf,
57
+ particle_dist=particle_dist,
58
+ overlap_tolerance=overlap_tolerance,
59
+ placement=placement,
60
+ **kwargs
61
+ )
62
+
63
+ return generator.generate()
64
+
65
+ def analyze(aggregate: Aggregate):
66
+ """
67
+ Compute core morphological properties.
68
+ """
69
+ rg = radius_of_gyration(aggregate)
70
+ r_centers, c_r = pair_correlation_function(aggregate)
71
+ df_est, r2, _ = estimate_fractal_dimension(r_centers, c_r,
72
+ r_min=np.mean(aggregate.radii),
73
+ r_max=rg)
74
+
75
+ return {
76
+ "Rg": rg,
77
+ "CoM": center_of_mass(aggregate),
78
+ "N": aggregate.current_size,
79
+ "Df_estimated": df_est,
80
+ "R2": r2
81
+ }
82
+
83
+ __all__ = [
84
+ "generate",
85
+ "analyze",
86
+ "Aggregate",
87
+ "Monodisperse",
88
+ "LognormalDistribution",
89
+ "radius_of_gyration",
90
+ "center_of_mass",
91
+ "pair_correlation_function",
92
+ "estimate_fractal_dimension",
93
+ "plot_pair_correlation",
94
+ "export_yaml",
95
+ "export_render",
96
+ "export_rotation_video",
97
+ "export_vtm",
98
+ "export_vtk",
99
+ "PCAGenerator",
100
+ "CCAGenerator",
101
+ "FracVALGenerator",
102
+ "ThouyJullienGenerator",
103
+ "AlgebraicPlacement",
104
+ "RandomPlacement",
105
+ ]
@@ -0,0 +1 @@
1
+ """Analysis tools for fractal aggregates."""
@@ -0,0 +1,207 @@
1
+ import numpy as np
2
+ from scipy.spatial import cKDTree
3
+ from pyFracAggregate.core.aggregate import Aggregate
4
+
5
+ def pair_correlation_function(
6
+ aggregate: Aggregate,
7
+ bins: int = 50,
8
+ r_max: float = None
9
+ ) -> tuple[np.ndarray, np.ndarray]:
10
+ """Calculates the two-point density correlation function C(r).
11
+
12
+ Efficient calculation based on scipy.spatial.cKDTree.
13
+
14
+ C(r) = n(r) / (4 * pi * r^2 * h * N)
15
+ where n(r) is the number of particle pairs between distance r and r+h,
16
+ N is the total number of particles, and h is the step size (bin width).
17
+
18
+ Args:
19
+ aggregate (Aggregate): Cluster object.
20
+ bins (int): Number of bins for r.
21
+ r_max (float, optional): Maximum distance for calculating the correlation function.
22
+ If None, the maximum distance between particles is used.
23
+
24
+ Returns:
25
+ tuple[np.ndarray, np.ndarray]: (r_centers, C_r) where r_centers are the bin
26
+ center distances and C_r are the corresponding correlation values.
27
+ """
28
+ if aggregate.current_size < 2:
29
+ return np.array([]), np.array([])
30
+
31
+ positions = aggregate.positions
32
+ N = aggregate.current_size
33
+
34
+ # Build KDTree for accelerated queries
35
+ tree = cKDTree(positions)
36
+
37
+ if r_max is None:
38
+ # Estimate maximum distance (upper bound of the furthest pair)
39
+ # Use 2x the maximum distance from center as a safe upper bound
40
+ com = np.mean(positions, axis=0)
41
+ max_dist_to_center = np.max(np.linalg.norm(positions - com, axis=1))
42
+ r_max = 2.0 * max_dist_to_center
43
+ if r_max == 0:
44
+ return np.array([]), np.array([])
45
+
46
+ # Calculate distance statistics (upper triangle only to avoid double counting,
47
+ # though tree.count_neighbors handles pair counting).
48
+ # tree.count_neighbors returns cumulative counts (<= r), so we differentiate.
49
+ r_edges = np.linspace(0, r_max, bins + 1)
50
+
51
+ # count_neighbors can take multiple radii at once
52
+ # cumulative_counts[i] contains number of pairs with distance <= r_edges[i]
53
+ cumulative_counts = tree.count_neighbors(tree, r_edges)
54
+
55
+ # Subtract self-matches (N) at r=0 to avoid interference with real pair statistics.
56
+ # (Coincident points are not expected due to collision detection).
57
+ cumulative_counts = np.array(cumulative_counts, dtype=np.float64)
58
+ # Subtract N self-matches for all r >= 0 counts
59
+ cumulative_counts -= N
60
+ # Ensure no negative values (preventing anomalies due to precision)
61
+ cumulative_counts = np.maximum(cumulative_counts, 0)
62
+
63
+ # Differentiate to get counts in each bin: n(r)
64
+ n_r = np.diff(cumulative_counts)
65
+
66
+ # r_centers are the interval midpoints
67
+ r_centers = (r_edges[:-1] + r_edges[1:]) / 2.0
68
+ h = r_edges[1] - r_edges[0]
69
+
70
+ # Avoid division by zero at r=0, although r_centers > 0
71
+ with np.errstate(divide='ignore', invalid='ignore'):
72
+ c_r = n_r / (4.0 * np.pi * (r_centers ** 2) * h * N)
73
+
74
+ # Assign 0 for r=0 or cases where distance is too small (causing div by 0)
75
+ c_r = np.nan_to_num(c_r, posinf=0.0)
76
+
77
+ return r_centers, c_r
78
+
79
+ def estimate_fractal_dimension(
80
+ r_centers: np.ndarray,
81
+ c_r: np.ndarray,
82
+ r_min: float = None,
83
+ r_max: float = None
84
+ ) -> tuple[float, float, dict]:
85
+ """Estimates the fractal dimension Df from the pair correlation function C(r).
86
+
87
+ Performs log-log linear regression on the fractal regime (a < r < Rg).
88
+ Df is calculated as: Df = slope + 3.
89
+
90
+ Args:
91
+ r_centers (np.ndarray): Bin center distances.
92
+ c_r (np.ndarray): Correlation function values.
93
+ r_min (float, optional): Lower bound for regression.
94
+ r_max (float, optional): Upper bound for regression.
95
+
96
+ Returns:
97
+ tuple[float, float, dict]: (Df, R_squared, fit_results)
98
+ - Df: Estimated fractal dimension.
99
+ - R_squared: Coefficient of determination.
100
+ - fit_results: Dictionary containing 'slope', 'intercept', 'x_fit', 'y_fit'.
101
+ """
102
+ # Filter valid data (C(r) > 0 for log)
103
+ mask = c_r > 0
104
+ if r_min is not None:
105
+ mask &= (r_centers >= r_min)
106
+ if r_max is not None:
107
+ mask &= (r_centers <= r_max)
108
+
109
+ x = r_centers[mask]
110
+ y = c_r[mask]
111
+
112
+ if len(x) < 2:
113
+ return 0.0, 0.0, {}
114
+
115
+ log_x = np.log10(x)
116
+ log_y = np.log10(y)
117
+
118
+ # Linear regression: log10(C(r)) = slope * log10(r) + intercept
119
+ slope, intercept = np.polyfit(log_x, log_y, 1)
120
+
121
+ # Calculate R-squared
122
+ y_pred = slope * log_x + intercept
123
+ ss_res = np.sum((log_y - y_pred) ** 2)
124
+ ss_tot = np.sum((log_y - np.mean(log_y)) ** 2)
125
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
126
+
127
+ df = slope + 3.0
128
+
129
+ fit_results = {
130
+ 'slope': slope,
131
+ 'intercept': intercept,
132
+ 'r_min': np.min(x),
133
+ 'r_max': np.max(x),
134
+ 'x_fit': x,
135
+ 'y_fit': 10 ** y_pred
136
+ }
137
+
138
+ return df, r_squared, fit_results
139
+
140
+ def plot_pair_correlation(
141
+ aggregate: Aggregate,
142
+ bins: int = 50,
143
+ show_fit: bool = True,
144
+ reference_df: float = None,
145
+ save_path: str = None
146
+ ) -> None:
147
+ """Plots the pair correlation function and optionally its fractal fit.
148
+
149
+ Args:
150
+ aggregate (Aggregate): Cluster object.
151
+ bins (int): Number of bins for PCF.
152
+ show_fit (bool): Whether to show the fractal dimension fit.
153
+ reference_df (float, optional): Reference Df to show in plot.
154
+ save_path (str, optional): Path to save the figure.
155
+ """
156
+ try:
157
+ import matplotlib.pyplot as plt
158
+ except ImportError:
159
+ print("Error: matplotlib is required for plotting. Install it with 'pip install matplotlib'.")
160
+ return
161
+
162
+ from pyFracAggregate.analysis.morphology import radius_of_gyration
163
+
164
+ r_centers, c_r = pair_correlation_function(aggregate, bins=bins)
165
+
166
+ if len(r_centers) == 0:
167
+ print("Warning: Aggregate has too few particles for correlation analysis.")
168
+ return
169
+
170
+ plt.figure(figsize=(8, 6))
171
+ plt.loglog(r_centers, c_r, 'o', label='Data', markersize=4, alpha=0.7)
172
+
173
+ if show_fit:
174
+ # Use r_min = mean radius, r_max = Rg as default bounds for fractal regime
175
+ r_min = np.mean(aggregate.radii)
176
+ r_max = radius_of_gyration(aggregate)
177
+
178
+ df, r2, fit = estimate_fractal_dimension(r_centers, c_r, r_min=r_min, r_max=r_max)
179
+
180
+ if fit:
181
+ plt.loglog(fit['x_fit'], fit['y_fit'], 'r-', linewidth=2,
182
+ label=f'Fit: $D_f$={df:.2f}, $R^2$={r2:.3f}')
183
+
184
+ # Draw vertical lines for fitting range
185
+ plt.axvline(r_min, color='gray', linestyle='--', alpha=0.5, label='Min Fit Bound')
186
+ plt.axvline(r_max, color='gray', linestyle=':', alpha=0.5, label='Max Fit Bound ($R_g$)')
187
+
188
+ if reference_df is not None:
189
+ # Show a reference slope (slope = reference_df - 3)
190
+ mid_idx = len(r_centers) // 2
191
+ ref_x = r_centers
192
+ # Arbitrary intercept to place it near the data
193
+ ref_intercept = np.log10(c_r[mid_idx]) - (reference_df - 3.0) * np.log10(r_centers[mid_idx])
194
+ ref_y = 10 ** ((reference_df - 3.0) * np.log10(ref_x) + ref_intercept)
195
+ plt.loglog(ref_x, ref_y, 'g--', alpha=0.5, label=f'Ref: $D_f$={reference_df}')
196
+
197
+ plt.xlabel(f'Distance $r$ [{aggregate.length_unit}]')
198
+ plt.ylabel('Correlation function $C(r)$')
199
+ plt.title('Pair Correlation Function Analysis')
200
+ plt.grid(True, which="both", ls="-", alpha=0.2)
201
+ plt.legend()
202
+
203
+ if save_path:
204
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
205
+ print(f"Plot saved to {save_path}")
206
+ else:
207
+ plt.show()
@@ -0,0 +1,72 @@
1
+ import numpy as np
2
+ from pyFracAggregate.core.aggregate import Aggregate
3
+
4
+ def center_of_mass(aggregate: Aggregate) -> np.ndarray:
5
+ """
6
+ Calculate the center of mass of the aggregate.
7
+
8
+ Args:
9
+ aggregate (Aggregate): The aggregate object.
10
+
11
+ Returns:
12
+ np.ndarray: A 1D array of shape (3,) representing the (x, y, z) coordinates of the center of mass.
13
+ """
14
+ if aggregate.current_size == 0:
15
+ return np.zeros(3)
16
+
17
+ masses = aggregate.masses
18
+ positions = aggregate.positions
19
+
20
+ total_mass = np.sum(masses)
21
+ if total_mass == 0:
22
+ return np.mean(positions, axis=0)
23
+
24
+ # sum(m_i * r_i) / sum(m_i)
25
+ com = np.sum(positions * masses[:, np.newaxis], axis=0) / total_mass
26
+ return com
27
+
28
+ def radius_of_gyration(aggregate: Aggregate) -> float:
29
+ """
30
+ Calculate the radius of gyration (Rg) of the aggregate.
31
+ Uses the parallel axis theorem to account for the finite size of the primary particles.
32
+ For a solid sphere, the radius of gyration about its own center is sqrt(3/5) * r.
33
+
34
+ Args:
35
+ aggregate (Aggregate): The aggregate object.
36
+
37
+ Returns:
38
+ float: The radius of gyration.
39
+ """
40
+ if aggregate.current_size == 0:
41
+ return 0.0
42
+
43
+ if aggregate.current_size == 1:
44
+ # For a single sphere, Rg = sphere radius according to Filippov 2000 definition eq [4]
45
+ # Or more accurately based on eq [4]: Rg^2 = a^2 for N=1.
46
+ # But for polydisperse spheres with parallel axis theorem: Rg^2 = 3/5 * r^2.
47
+ # Wait, the Filippov paper uses: Rg^2 = 1/N sum((ri - r0)^2 + a^2).
48
+ # Let's check the Moran 2019 FracVAL equation for polydisperse:
49
+ # Rg^2 = 1/m_a sum(m_i * [(R_i - R_c)^2 + r_{g,i}^2]) where r_{g,i}^2 = 3/5 * r_{p,i}^2
50
+ # Let's implement the standard physical Rg (with 3/5 factor).
51
+ return np.sqrt(3.0 / 5.0) * aggregate.radii[0]
52
+
53
+ masses = aggregate.masses
54
+ positions = aggregate.positions
55
+ radii = aggregate.radii
56
+
57
+ total_mass = np.sum(masses)
58
+ if total_mass == 0:
59
+ return 0.0
60
+
61
+ com = center_of_mass(aggregate)
62
+
63
+ # Distance squared from CoM to each particle center
64
+ dist_sq = np.sum((positions - com) ** 2, axis=1)
65
+
66
+ # Intrinsic Rg squared of each solid sphere: 3/5 * r^2
67
+ intrinsic_rg_sq = (3.0 / 5.0) * (radii ** 2)
68
+
69
+ # sum(m_i * (dist_sq + intrinsic_rg_sq)) / M
70
+ rg_sq = np.sum(masses * (dist_sq + intrinsic_rg_sq)) / total_mass
71
+
72
+ return float(np.sqrt(rg_sq))
@@ -0,0 +1 @@
1
+ """Core data structures and math utils."""
@@ -0,0 +1,93 @@
1
+ import numpy as np
2
+
3
+ class Aggregate:
4
+ """Core physical entity representing a fractal cluster.
5
+
6
+ Uses pre-allocated contiguous memory via NumPy for high data locality
7
+ and access performance.
8
+ """
9
+ def __init__(self, max_particles: int, length_unit: str = 'nm', mass_unit: str = 'g', density: float = 1.0):
10
+ """Initializes the Aggregate object.
11
+
12
+ Args:
13
+ max_particles (int): Maximum number of particles the cluster can hold.
14
+ length_unit (str, optional): Unit for length measurements (e.g., 'nm'). Defaults to 'nm'.
15
+ mass_unit (str, optional): Unit for mass measurements (e.g., 'g'). Defaults to 'g'.
16
+ density (float, optional): Density of particle material. Defaults to 1.0.
17
+
18
+ Raises:
19
+ ValueError: If max_particles <= 0.
20
+ """
21
+ if max_particles <= 0:
22
+ raise ValueError("max_particles must be positive")
23
+
24
+ # Data structure: [x, y, z, radius, mass] with pre-allocated memory
25
+ self._data = np.zeros((max_particles, 5), dtype=np.float64)
26
+ self._current_size = 0
27
+ self.length_unit = length_unit
28
+ self.mass_unit = mass_unit
29
+ self.density = density
30
+
31
+ @property
32
+ def positions(self) -> np.ndarray:
33
+ """Gets particle coordinates.
34
+
35
+ Returns:
36
+ np.ndarray: A zero-copy view with shape (N, 3).
37
+ """
38
+ return self._data[:self._current_size, :3]
39
+
40
+ @property
41
+ def radii(self) -> np.ndarray:
42
+ """Gets particle radii.
43
+
44
+ Returns:
45
+ np.ndarray: A zero-copy view with shape (N,).
46
+ """
47
+ return self._data[:self._current_size, 3]
48
+
49
+ @property
50
+ def masses(self) -> np.ndarray:
51
+ """Gets particle masses.
52
+
53
+ Returns:
54
+ np.ndarray: A zero-copy view with shape (N,).
55
+ """
56
+ return self._data[:self._current_size, 4]
57
+
58
+ @property
59
+ def current_size(self) -> int:
60
+ """Gets the current total number of particles."""
61
+ return self._current_size
62
+
63
+ @property
64
+ def max_size(self) -> int:
65
+ """Gets the maximum allowed number of particles."""
66
+ return len(self._data)
67
+
68
+ def add_particle(self, x: float, y: float, z: float, r: float, m: float) -> None:
69
+ """Adds a new particle. O(1) complexity.
70
+
71
+ Args:
72
+ x (float): X coordinate
73
+ y (float): Y coordinate
74
+ z (float): Z coordinate
75
+ r (float): Radius
76
+ m (float): Mass
77
+
78
+ Raises:
79
+ RuntimeError: If cluster reaches maximum capacity.
80
+ """
81
+ if self._current_size >= len(self._data):
82
+ raise RuntimeError("Aggregate capacity exceeded")
83
+ i = self._current_size
84
+ self._data[i] = [x, y, z, r, m]
85
+ self._current_size += 1
86
+
87
+ def to_numpy(self) -> np.ndarray:
88
+ """Exports a copy of valid particles.
89
+
90
+ Returns:
91
+ np.ndarray: A copy of the data with shape (N, 5).
92
+ """
93
+ return self._data[:self._current_size].copy()
@@ -0,0 +1,62 @@
1
+ from abc import ABC, abstractmethod
2
+ import numpy as np
3
+
4
+ class ParticleDistribution(ABC):
5
+ """Abstract base class for particle size distributions."""
6
+ @abstractmethod
7
+ def sample(self, n: int) -> np.ndarray:
8
+ """Samples n particle sizes.
9
+
10
+ Args:
11
+ n (int): Number of particle sizes to generate.
12
+
13
+ Returns:
14
+ np.ndarray: An array of particle sizes with shape (n,).
15
+ """
16
+ pass
17
+
18
+ class Monodisperse(ParticleDistribution):
19
+ """Monodisperse distribution (all particles have the same radius)."""
20
+ def __init__(self, radius: float):
21
+ """Initializes the distribution.
22
+
23
+ Args:
24
+ radius (float): Particle radius.
25
+ Raises:
26
+ ValueError: If radius <= 0.
27
+ """
28
+ if radius <= 0:
29
+ raise ValueError("Radius must be positive")
30
+ self.radius = radius
31
+
32
+ def sample(self, n: int) -> np.ndarray:
33
+ return np.full(n, self.radius, dtype=np.float64)
34
+
35
+ class LognormalDistribution(ParticleDistribution):
36
+ """Lognormal distribution."""
37
+ def __init__(self, mean: float, std: float):
38
+ """Initializes the distribution.
39
+
40
+ Args:
41
+ mean (float): Geometric mean.
42
+ std (float): Geometric standard deviation (should be >= 1.0).
43
+
44
+ Raises:
45
+ ValueError: If mean or std is invalid.
46
+ """
47
+ if mean <= 0:
48
+ raise ValueError("Mean must be positive")
49
+ if std <= 0:
50
+ raise ValueError("Standard deviation must be positive")
51
+
52
+ self.mean = mean
53
+ self.std = std
54
+
55
+ # Calculate mean and standard deviation of the underlying normal distribution
56
+ self.normal_mean = np.log(self.mean)
57
+ self.normal_std = np.log(self.std) if self.std > 1.0 else 0.0
58
+
59
+ def sample(self, n: int) -> np.ndarray:
60
+ if self.normal_std == 0.0:
61
+ return np.full(n, self.mean, dtype=np.float64)
62
+ return np.random.lognormal(self.normal_mean, self.normal_std, n)
@@ -0,0 +1,124 @@
1
+ import numpy as np
2
+ from typing import Tuple, Optional
3
+
4
+ import mathutils
5
+
6
+ def rotate_points(points: np.ndarray, euler_angles: Tuple[float, float, float]) -> np.ndarray:
7
+ """
8
+ Rotate points using given euler angles (in radians).
9
+
10
+ Args:
11
+ points (np.ndarray): Shape (N, 3) points to rotate.
12
+ euler_angles (Tuple[float, float, float]): Rotation angles (x, y, z) in radians.
13
+
14
+ Returns:
15
+ np.ndarray: Rotated points of shape (N, 3).
16
+ """
17
+ if points.size == 0:
18
+ return points.copy()
19
+
20
+ euler = mathutils.Euler(euler_angles, 'XYZ')
21
+ rot_matrix = np.array(euler.to_matrix())
22
+ return points @ rot_matrix.T
23
+
24
+ def rotate_points_quaternion(points: np.ndarray, quaternion: Tuple[float, float, float, float]) -> np.ndarray:
25
+ """
26
+ Rotate points using given quaternion (w, x, y, z).
27
+
28
+ Args:
29
+ points (np.ndarray): Shape (N, 3) points to rotate.
30
+ quaternion (Tuple[float, float, float, float]): Quaternion parameters (w, x, y, z).
31
+
32
+ Returns:
33
+ np.ndarray: Rotated points.
34
+ """
35
+ if points.size == 0:
36
+ return points.copy()
37
+
38
+ q = mathutils.Quaternion(quaternion)
39
+ rot_matrix = np.array(q.to_matrix())
40
+ return points @ rot_matrix.T
41
+
42
+ def euler_rodrigues_rotation(points: np.ndarray, axis: np.ndarray, angle: float) -> np.ndarray:
43
+ """Rotate points around an arbitrary axis by a given angle using Euler-Rodrigues formula.
44
+
45
+ Args:
46
+ points (np.ndarray): Shape (N, 3) points to rotate.
47
+ axis (np.ndarray): Rotation axis (3,), must be non-zero.
48
+ angle (float): Rotation angle in radians.
49
+
50
+ Returns:
51
+ np.ndarray: Rotated points of shape (N, 3).
52
+ """
53
+ if points.size == 0:
54
+ return points.copy()
55
+ axis = axis / np.linalg.norm(axis)
56
+ K = np.array([
57
+ [0, -axis[2], axis[1]],
58
+ [axis[2], 0, -axis[0]],
59
+ [-axis[1], axis[0], 0]
60
+ ])
61
+ R = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
62
+ return points @ R.T
63
+
64
+
65
+ def sphere_sphere_intersection(
66
+ c1: np.ndarray, r1: float, c2: np.ndarray, r2: float
67
+ ) -> Optional[Tuple[np.ndarray, float]]:
68
+ """Compute the intersection circle of two spheres.
69
+
70
+ Args:
71
+ c1: Center of sphere 1, shape (3,).
72
+ r1: Radius of sphere 1.
73
+ c2: Center of sphere 2, shape (3,).
74
+ r2: Radius of sphere 2.
75
+
76
+ Returns:
77
+ (circle_center, circle_radius) if intersection exists, None otherwise.
78
+ For tangent spheres, circle_radius is 0.0.
79
+ """
80
+ d_vec = c2 - c1
81
+ d = np.linalg.norm(d_vec)
82
+
83
+ if d > r1 + r2 + 1e-12:
84
+ return None
85
+ if d < abs(r1 - r2) - 1e-12:
86
+ return None
87
+ if d < 1e-12:
88
+ return None
89
+
90
+ a = (r1**2 - r2**2 + d**2) / (2 * d)
91
+ h_sq = r1**2 - a**2
92
+ if h_sq < -1e-12:
93
+ return None
94
+ h = np.sqrt(max(h_sq, 0.0))
95
+
96
+ circle_center = c1 + (a / d) * d_vec
97
+ return circle_center, h
98
+
99
+
100
+ def random_point_on_circle(
101
+ center: np.ndarray,
102
+ radius: float,
103
+ normal: np.ndarray,
104
+ ) -> np.ndarray:
105
+ """Sample a random point on a circle in 3D.
106
+
107
+ Args:
108
+ center: Circle center, shape (3,).
109
+ radius: Circle radius.
110
+ normal: Normal vector to the circle plane, shape (3,).
111
+
112
+ Returns:
113
+ A single point on the circle, shape (3,).
114
+ """
115
+ normal = normal / np.linalg.norm(normal)
116
+ temp = np.array([1.0, 0.0, 0.0])
117
+ if np.abs(np.dot(normal, temp)) > 0.9:
118
+ temp = np.array([0.0, 1.0, 0.0])
119
+ u = np.cross(normal, temp)
120
+ u /= np.linalg.norm(u)
121
+ v = np.cross(normal, u)
122
+
123
+ theta = np.random.uniform(0, 2 * np.pi)
124
+ return center + radius * (np.cos(theta) * u + np.sin(theta) * v)
@@ -0,0 +1,21 @@
1
+ """Generators for fractal aggregates."""
2
+
3
+ from pyFracAggregate.generators.base import BaseGenerator
4
+ from pyFracAggregate.generators.pca import PCAGenerator
5
+ from pyFracAggregate.generators.cca import CCAGenerator
6
+ from pyFracAggregate.generators.fracval import FracVALGenerator
7
+ from pyFracAggregate.generators.tdcca import ThouyJullienGenerator
8
+ from pyFracAggregate.generators.placement.base import PlacementStrategy
9
+ from pyFracAggregate.generators.placement.algebraic import AlgebraicPlacement
10
+ from pyFracAggregate.generators.placement.random_ import RandomPlacement
11
+
12
+ __all__ = [
13
+ "BaseGenerator",
14
+ "PCAGenerator",
15
+ "CCAGenerator",
16
+ "FracVALGenerator",
17
+ "ThouyJullienGenerator",
18
+ "PlacementStrategy",
19
+ "AlgebraicPlacement",
20
+ "RandomPlacement",
21
+ ]