Skip to main content

exg_source/
source_space.rs

1//! Source space generation.
2//!
3//! Defines where the dipole sources live inside the head volume.
4//! Two methods are provided:
5//!
6//! - **Icosahedron subdivision**: recursively subdivided icosahedron
7//!   projected onto a sphere, then scaled to a given radius. This
8//!   approximates a cortical surface with roughly uniform spacing.
9//!
10//! - **Regular grid**: axis-aligned 3-D grid inside a sphere, useful
11//!   for volume source spaces.
12//!
13//! ## Example
14//!
15//! ```
16//! use exg_source::source_space::{ico_source_space, grid_source_space};
17//!
18//! // ~162 sources on a sphere of radius 0.07 m (cortex)
19//! let (pos, nn) = ico_source_space(2, 0.07, [0.0, 0.0, 0.04]);
20//! assert_eq!(pos.nrows(), nn.nrows());
21//!
22//! // Volume grid with 0.01 m spacing inside a 0.08 m sphere
23//! let (pos, nn) = grid_source_space(0.01, 0.08, [0.0, 0.0, 0.04]);
24//! ```
25
26use ndarray::Array2;
27use std::collections::HashMap;
28
29/// Generate a source space by subdividing an icosahedron.
30///
31/// # Arguments
32///
33/// * `n_subdivisions` — Number of recursive subdivisions (0 → 12 vertices,
34///   1 → 42, 2 → 162, 3 → 642, 4 → 2562, 5 → 10242).
35/// * `radius` — Radius of the sphere in metres (e.g., 0.07 for cortex).
36/// * `center` — Centre of the sphere `[x, y, z]` in metres.
37///
38/// # Returns
39///
40/// `(positions, normals)` where both have shape `[n_sources, 3]`.
41/// Normals point radially outward from the centre.
42pub fn ico_source_space(
43    n_subdivisions: usize,
44    radius: f64,
45    center: [f64; 3],
46) -> (Array2<f64>, Array2<f64>) {
47    let (verts, faces) = make_icosahedron();
48    let (verts, _faces) = subdivide_ico(verts, faces, n_subdivisions);
49
50    let n = verts.len();
51    let mut positions = Array2::zeros((n, 3));
52    let mut normals = Array2::zeros((n, 3));
53
54    for (i, v) in verts.iter().enumerate() {
55        let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
56        let nx = v[0] / len;
57        let ny = v[1] / len;
58        let nz = v[2] / len;
59
60        positions[[i, 0]] = nx * radius + center[0];
61        positions[[i, 1]] = ny * radius + center[1];
62        positions[[i, 2]] = nz * radius + center[2];
63
64        normals[[i, 0]] = nx;
65        normals[[i, 1]] = ny;
66        normals[[i, 2]] = nz;
67    }
68
69    (positions, normals)
70}
71
72/// Generate a volume source space on a regular 3-D grid.
73///
74/// # Arguments
75///
76/// * `spacing` — Grid spacing in metres (e.g., 0.01 for 1 cm).
77/// * `radius`  — Only include points inside a sphere of this radius.
78/// * `center`  — Centre of the sphere `[x, y, z]` in metres.
79///
80/// # Returns
81///
82/// `(positions, normals)` where both have shape `[n_sources, 3]`.
83/// For a volume source space, normals are set to `[0, 0, 1]` (arbitrary).
84pub fn grid_source_space(
85    spacing: f64,
86    radius: f64,
87    center: [f64; 3],
88) -> (Array2<f64>, Array2<f64>) {
89    assert!(spacing > 0.0, "Spacing must be positive");
90    assert!(radius > 0.0, "Radius must be positive");
91
92    let n_half = (radius / spacing).ceil() as i32;
93    let mut points = Vec::new();
94
95    for ix in -n_half..=n_half {
96        for iy in -n_half..=n_half {
97            for iz in -n_half..=n_half {
98                let x = ix as f64 * spacing;
99                let y = iy as f64 * spacing;
100                let z = iz as f64 * spacing;
101                if x * x + y * y + z * z <= radius * radius {
102                    points.push([x + center[0], y + center[1], z + center[2]]);
103                }
104            }
105        }
106    }
107
108    let n = points.len();
109    let mut positions = Array2::zeros((n, 3));
110    let mut normals = Array2::zeros((n, 3));
111
112    for (i, p) in points.iter().enumerate() {
113        positions[[i, 0]] = p[0];
114        positions[[i, 1]] = p[1];
115        positions[[i, 2]] = p[2];
116        // Volume sources: arbitrary upward normal
117        normals[[i, 2]] = 1.0;
118    }
119
120    (positions, normals)
121}
122
123/// Expected vertex count for a given icosahedron subdivision level.
124pub fn ico_n_vertices(n_subdivisions: usize) -> usize {
125    // V = 10 * 4^n + 2
126    10 * 4_usize.pow(n_subdivisions as u32) + 2
127}
128
129// ── Icosahedron geometry ───────────────────────────────────────────────────
130
131/// Create the base icosahedron (12 vertices, 20 faces).
132fn make_icosahedron() -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
133    let phi = (1.0 + 5.0_f64.sqrt()) / 2.0; // golden ratio
134    let a = 1.0;
135    let b = 1.0 / phi;
136
137    // 12 vertices of the icosahedron (normalised to unit sphere)
138    #[rustfmt::skip]
139    let raw_verts: Vec<[f64; 3]> = vec![
140        [ 0.0,  b, -a], [ b,  a,  0.0], [-b,  a,  0.0],
141        [ 0.0,  b,  a], [-a,  0.0,  b], [ 0.0, -b,  a],
142        [ a,  0.0,  b], [ a,  0.0, -b], [ 0.0, -b, -a],
143        [-a,  0.0, -b], [-b, -a,  0.0], [ b, -a,  0.0],
144    ];
145
146    let verts: Vec<[f64; 3]> = raw_verts
147        .iter()
148        .map(|v| {
149            let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
150            [v[0] / len, v[1] / len, v[2] / len]
151        })
152        .collect();
153
154    #[rustfmt::skip]
155    let faces: Vec<[usize; 3]> = vec![
156        [0, 1, 2], [3, 2, 1], [3, 4, 5], [3, 5, 6],
157        [0, 7, 1], [0, 8, 7], [0, 9, 8], [0, 2, 9],
158        [3, 1, 6], [3, 4, 2], [5, 4, 10], [5, 10, 11],
159        [5, 11, 6], [7, 6, 11], [7, 11, 8], [8, 11, 10],
160        [8, 10, 9], [9, 10, 4], [9, 4, 2], [6, 7, 1],
161    ];
162
163    (verts, faces)
164}
165
166/// Subdivide an icosahedral mesh `n` times.
167///
168/// Each subdivision splits every triangle into 4 by inserting midpoints
169/// on each edge and projecting them onto the unit sphere.
170fn subdivide_ico(
171    mut verts: Vec<[f64; 3]>,
172    mut faces: Vec<[usize; 3]>,
173    n: usize,
174) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
175    for _ in 0..n {
176        let mut edge_midpoint: HashMap<(usize, usize), usize> = HashMap::new();
177        let mut new_faces = Vec::with_capacity(faces.len() * 4);
178
179        for face in &faces {
180            let [a, b, c] = *face;
181            let ab = get_or_insert_midpoint(a, b, &mut verts, &mut edge_midpoint);
182            let bc = get_or_insert_midpoint(b, c, &mut verts, &mut edge_midpoint);
183            let ca = get_or_insert_midpoint(c, a, &mut verts, &mut edge_midpoint);
184
185            new_faces.push([a, ab, ca]);
186            new_faces.push([b, bc, ab]);
187            new_faces.push([c, ca, bc]);
188            new_faces.push([ab, bc, ca]);
189        }
190
191        faces = new_faces;
192    }
193
194    (verts, faces)
195}
196
197/// Get or create the midpoint vertex between two vertices, projected
198/// onto the unit sphere.
199fn get_or_insert_midpoint(
200    i: usize,
201    j: usize,
202    verts: &mut Vec<[f64; 3]>,
203    cache: &mut HashMap<(usize, usize), usize>,
204) -> usize {
205    let key = if i < j { (i, j) } else { (j, i) };
206    if let Some(&idx) = cache.get(&key) {
207        return idx;
208    }
209
210    let a = verts[i];
211    let b = verts[j];
212    let mx = (a[0] + b[0]) / 2.0;
213    let my = (a[1] + b[1]) / 2.0;
214    let mz = (a[2] + b[2]) / 2.0;
215    let len = (mx * mx + my * my + mz * mz).sqrt();
216
217    let idx = verts.len();
218    verts.push([mx / len, my / len, mz / len]);
219    cache.insert(key, idx);
220    idx
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_ico_vertex_counts() {
229        assert_eq!(ico_n_vertices(0), 12);
230        assert_eq!(ico_n_vertices(1), 42);
231        assert_eq!(ico_n_vertices(2), 162);
232        assert_eq!(ico_n_vertices(3), 642);
233        assert_eq!(ico_n_vertices(4), 2562);
234    }
235
236    #[test]
237    fn test_ico_source_space_shape() {
238        for subdiv in 0..=3 {
239            let (pos, nn) = ico_source_space(subdiv, 0.07, [0.0, 0.0, 0.04]);
240            let expected = ico_n_vertices(subdiv);
241            assert_eq!(
242                pos.nrows(),
243                expected,
244                "subdiv={subdiv}: expected {expected} vertices, got {}",
245                pos.nrows()
246            );
247            assert_eq!(pos.ncols(), 3);
248            assert_eq!(nn.dim(), pos.dim());
249        }
250    }
251
252    #[test]
253    fn test_ico_source_space_radius() {
254        let radius = 0.07;
255        let center = [0.0, 0.0, 0.04];
256        let (pos, _) = ico_source_space(2, radius, center);
257
258        for i in 0..pos.nrows() {
259            let dx = pos[[i, 0]] - center[0];
260            let dy = pos[[i, 1]] - center[1];
261            let dz = pos[[i, 2]] - center[2];
262            let r = (dx * dx + dy * dy + dz * dz).sqrt();
263            approx::assert_abs_diff_eq!(r, radius, epsilon = 1e-10);
264        }
265    }
266
267    #[test]
268    fn test_ico_normals_unit_length() {
269        let (_, nn) = ico_source_space(2, 0.07, [0.0, 0.0, 0.0]);
270        for i in 0..nn.nrows() {
271            let len = (nn[[i, 0]].powi(2) + nn[[i, 1]].powi(2) + nn[[i, 2]].powi(2)).sqrt();
272            approx::assert_abs_diff_eq!(len, 1.0, epsilon = 1e-10);
273        }
274    }
275
276    #[test]
277    fn test_grid_source_space_inside_sphere() {
278        let radius = 0.05;
279        let center = [0.0, 0.0, 0.03];
280        let (pos, _) = grid_source_space(0.01, radius, center);
281
282        assert!(pos.nrows() > 0, "Should have some sources");
283        for i in 0..pos.nrows() {
284            let dx = pos[[i, 0]] - center[0];
285            let dy = pos[[i, 1]] - center[1];
286            let dz = pos[[i, 2]] - center[2];
287            let r = (dx * dx + dy * dy + dz * dz).sqrt();
288            assert!(
289                r <= radius + 1e-10,
290                "Source at r={r} exceeds radius={radius}"
291            );
292        }
293    }
294
295    #[test]
296    fn test_grid_source_space_includes_center() {
297        let center = [0.01, 0.02, 0.03];
298        let (pos, _) = grid_source_space(0.01, 0.05, center);
299
300        // The center (or very close to it) should be included
301        let mut min_dist = f64::MAX;
302        for i in 0..pos.nrows() {
303            let dx = pos[[i, 0]] - center[0];
304            let dy = pos[[i, 1]] - center[1];
305            let dz = pos[[i, 2]] - center[2];
306            let r = (dx * dx + dy * dy + dz * dz).sqrt();
307            if r < min_dist {
308                min_dist = r;
309            }
310        }
311        assert!(min_dist < 0.015, "Closest point to center is {min_dist} m away");
312    }
313
314    #[test]
315    fn test_grid_spacing_smaller_gives_more_sources() {
316        let (p1, _) = grid_source_space(0.02, 0.05, [0.0, 0.0, 0.0]);
317        let (p2, _) = grid_source_space(0.01, 0.05, [0.0, 0.0, 0.0]);
318        assert!(
319            p2.nrows() > p1.nrows(),
320            "Finer grid should have more sources: {} vs {}",
321            p2.nrows(),
322            p1.nrows()
323        );
324    }
325}