prime_voronoi/lib.rs
1//! `prime-voronoi` — Voronoi diagrams and Lloyd relaxation.
2//!
3//! All public functions are pure (LOAD + COMPUTE only). No `&mut`, no side effects,
4//! no hidden state.
5//!
6//! # Temporal Assembly Model
7//! - **LOAD** — read parameters (query point, seed points, sample points)
8//! - **COMPUTE** — pure geometry (distance comparisons, centroid sums)
9//! - **APPEND** — return new state as owned value
10//!
11//! STORE and JUMP do not exist here.
12//!
13//! # Included
14//! - `voronoi_nearest_2d` — find nearest seed index and F1 distance
15//! - `voronoi_f1_f2_2d` — F1 (nearest) and F2 (second-nearest) distances
16//! - `lloyd_relax_step_2d` — one Lloyd relaxation step (sample-based)
17//!
18//! - `delaunay_2d` — Delaunay triangulation via Bowyer-Watson
19
20// ── Voronoi nearest ───────────────────────────────────────────────────────────
21
22/// Find the nearest seed and its distance from `query`.
23///
24/// Returns `None` if `seeds` is empty.
25///
26/// # Math
27///
28/// ```text
29/// (index, dist) = argmin_i sqrt((query.x - seeds[i].x)² + (query.y - seeds[i].y)²)
30/// ```
31///
32/// Squared distances are compared during the scan to avoid unnecessary square roots.
33/// The final distance is returned as the true Euclidean distance.
34///
35/// # Arguments
36/// * `query` — point to query `(x, y)`
37/// * `seeds` — slice of `(x, y)` seed points
38///
39/// # Returns
40/// `Some((index, distance))` of the nearest seed, or `None` if `seeds` is empty.
41///
42/// # Edge cases
43/// * Empty `seeds` → `None`
44/// * Single seed → always returns `(0, dist_to_seed_0)`
45///
46/// # Example
47/// ```rust
48/// use prime_voronoi::voronoi_nearest_2d;
49/// let seeds = [(0.0_f32, 0.0), (1.0, 0.0), (0.0, 1.0)];
50/// let (idx, dist) = voronoi_nearest_2d((0.1, 0.1), &seeds).unwrap();
51/// assert_eq!(idx, 0);
52/// assert!(dist < 0.2);
53/// ```
54pub fn voronoi_nearest_2d(query: (f32, f32), seeds: &[(f32, f32)]) -> Option<(usize, f32)> {
55 seeds.iter().enumerate().fold(None, |best, (i, &(sx, sy))| {
56 let dx = query.0 - sx;
57 let dy = query.1 - sy;
58 let d2 = dx * dx + dy * dy;
59 match best {
60 None => Some((i, d2)),
61 Some((_bi, bd2)) if d2 < bd2 => Some((i, d2)),
62 other => other,
63 }
64 })
65 .map(|(i, d2)| (i, d2.sqrt()))
66}
67
68// ── Voronoi F1 + F2 ───────────────────────────────────────────────────────────
69
70/// Compute F1 (nearest) and F2 (second-nearest) Euclidean distances from `query`.
71///
72/// Returns `None` if `seeds` is empty. Returns `(f1, f1)` if only one seed.
73///
74/// F1 and F2 together are used to compute cellular noise patterns and edge
75/// detection on Voronoi diagrams.
76///
77/// # Math
78///
79/// ```text
80/// F1 = min_i dist(query, seeds[i])
81/// F2 = min_i dist(query, seeds[i]) where i ≠ argmin_j dist(query, seeds[j])
82/// ```
83///
84/// # Arguments
85/// * `query` — query point `(x, y)`
86/// * `seeds` — slice of seed points
87///
88/// # Returns
89/// `Some((f1, f2))` or `None` if empty.
90///
91/// # Example
92/// ```rust
93/// use prime_voronoi::voronoi_f1_f2_2d;
94/// let seeds = [(0.0_f32, 0.0), (1.0, 0.0)];
95/// let (f1, f2) = voronoi_f1_f2_2d((0.3, 0.0), &seeds).unwrap();
96/// assert!(f1 < f2);
97/// ```
98pub fn voronoi_f1_f2_2d(query: (f32, f32), seeds: &[(f32, f32)]) -> Option<(f32, f32)> {
99 if seeds.is_empty() {
100 return None;
101 }
102
103 let (f1_d2, f2_d2) = seeds.iter().fold(
104 (f32::MAX, f32::MAX),
105 |(f1, f2), &(sx, sy)| {
106 let dx = query.0 - sx;
107 let dy = query.1 - sy;
108 let d2 = dx * dx + dy * dy;
109 if d2 < f1 {
110 (d2, f1)
111 } else if d2 < f2 {
112 (f1, d2)
113 } else {
114 (f1, f2)
115 }
116 },
117 );
118
119 Some((f1_d2.sqrt(), f2_d2.min(f32::MAX).sqrt()))
120}
121
122// ── Lloyd relaxation ──────────────────────────────────────────────────────────
123
124/// One step of sample-based Lloyd relaxation in 2-D.
125///
126/// Moves each seed toward the centroid of its Voronoi cell, estimated by
127/// distributing a set of `samples` (e.g., a regular grid or stratified random
128/// points) among the seeds by nearest-neighbor assignment.
129///
130/// Seeds with no samples assigned to them remain at their original position
131/// (they are "empty cells" — this is the standard behaviour for boundary seeds
132/// that are out-competed by neighbours).
133///
134/// # Math
135///
136/// ```text
137/// For each sample s:
138/// assign s to nearest seed j
139///
140/// For each seed i:
141/// centroid_i = mean of all samples assigned to seed i
142/// if no samples → keep original position
143///
144/// new_seeds[i] = centroid_i
145/// ```
146///
147/// # Arguments
148/// * `seeds` — current seed positions `&[(x, y)]`
149/// * `samples` — evaluation points used to estimate Voronoi cell centroids
150///
151/// # Returns
152/// New seed positions (same length as `seeds`).
153/// Returns an empty `Vec` if `seeds` is empty.
154///
155/// # Edge cases
156/// * Empty `seeds` → `vec![]`
157/// * Empty `samples` → all seeds unchanged
158///
159/// # Example
160/// ```rust
161/// use prime_voronoi::lloyd_relax_step_2d;
162///
163/// // Two seeds, 4 samples: samples cluster around (0,0) and (1,0)
164/// let seeds = vec![(0.1_f32, 0.0), (0.9, 0.0)];
165/// let samples = vec![(0.0, 0.0), (0.2, 0.0), (0.8, 0.0), (1.0, 0.0)];
166/// let relaxed = lloyd_relax_step_2d(&seeds, &samples);
167/// assert_eq!(relaxed.len(), 2);
168/// assert!((relaxed[0].0 - 0.1).abs() < 0.5);
169/// ```
170pub fn lloyd_relax_step_2d(seeds: &[(f32, f32)], samples: &[(f32, f32)]) -> Vec<(f32, f32)> {
171 if seeds.is_empty() {
172 return vec![];
173 }
174
175 // Accumulate (sum_x, sum_y, count) per seed.
176 let init: Vec<(f32, f32, u32)> = seeds.iter().map(|_| (0.0, 0.0, 0)).collect();
177
178 let accum = samples.iter().fold(init, |mut acc, &(sx, sy)| {
179 // Find nearest seed to this sample
180 let nearest = seeds.iter().enumerate().fold(
181 (0usize, f32::MAX),
182 |(bi, bd2), (i, &(px, py))| {
183 let dx = sx - px;
184 let dy = sy - py;
185 let d2 = dx * dx + dy * dy;
186 if d2 < bd2 { (i, d2) } else { (bi, bd2) }
187 },
188 ).0;
189
190 acc[nearest].0 += sx;
191 acc[nearest].1 += sy;
192 acc[nearest].2 += 1;
193 acc
194 });
195
196 // Compute centroids; fall back to original seed if no samples assigned.
197 accum
198 .iter()
199 .enumerate()
200 .map(|(i, &(sx, sy, count))| {
201 if count == 0 {
202 seeds[i]
203 } else {
204 (sx / count as f32, sy / count as f32)
205 }
206 })
207 .collect()
208}
209
210// ── Delaunay triangulation ────────────────────────────────────────────────────
211
212/// Circumcircle test: is point `(px, py)` strictly inside the circumcircle of
213/// triangle `(ax,ay)`, `(bx,by)`, `(cx,cy)`?
214///
215/// Orientation-independent: works for both CW and CCW triangles by checking
216/// the sign of the cross product and adjusting accordingly.
217///
218/// # Math
219/// ```text
220/// | dx dy dx²+dy² |
221/// | ex ey ex²+ey² | > 0 ⟹ point inside circumcircle (CCW triangle)
222/// | fx fy fx²+fy² |
223/// ```
224/// where `(dx,dy) = a - p`, `(ex,ey) = b - p`, `(fx,fy) = c - p`.
225/// For CW triangles the sign is flipped.
226#[allow(clippy::too_many_arguments)]
227pub(crate) fn in_circumcircle(
228 px: f32, py: f32,
229 ax: f32, ay: f32,
230 bx: f32, by: f32,
231 cx: f32, cy: f32,
232) -> bool {
233 let dx = ax - px;
234 let dy = ay - py;
235 let ex = bx - px;
236 let ey = by - py;
237 let fx = cx - px;
238 let fy = cy - py;
239
240 let dx2_dy2 = dx * dx + dy * dy;
241 let ex2_ey2 = ex * ex + ey * ey;
242 let fx2_fy2 = fx * fx + fy * fy;
243
244 let det = dx * (ey * fx2_fy2 - fy * ex2_ey2)
245 - dy * (ex * fx2_fy2 - fx * ex2_ey2)
246 + dx2_dy2 * (ex * fy - ey * fx);
247
248 // Check triangle orientation (sign of cross product)
249 let orient = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
250
251 // If CCW (orient > 0): det > 0 means inside
252 // If CW (orient < 0): det < 0 means inside
253 if orient > 0.0 { det > 0.0 } else { det < 0.0 }
254}
255
256/// Delaunay triangulation via Bowyer-Watson. Returns list of triangle index triples.
257///
258/// Each triangle is `(i, j, k)` where `i`, `j`, `k` are indices into the input `points` slice.
259/// Points on the super-triangle boundary are excluded from the output.
260///
261/// # Math
262/// Bowyer-Watson incrementally inserts points, removing triangles whose circumcircle
263/// contains the new point, then re-triangulates the resulting polygonal hole.
264///
265/// # Example
266/// ```rust
267/// use prime_voronoi::delaunay_2d;
268/// let points = vec![(0.0_f32, 0.0), (1.0, 0.0), (0.5, 1.0)];
269/// let tris = delaunay_2d(&points);
270/// assert_eq!(tris.len(), 1);
271/// // Triangle indices reference the input points (order may vary)
272/// let (a, b, c) = tris[0];
273/// assert!(a < 3 && b < 3 && c < 3);
274/// assert_ne!(a, b);
275/// assert_ne!(b, c);
276/// ```
277pub fn delaunay_2d(points: &[(f32, f32)]) -> Vec<(usize, usize, usize)> {
278 if points.len() < 3 {
279 return vec![];
280 }
281
282 // ADVANCE-EXCEPTION: Bowyer-Watson requires triangle set mutation.
283 // Internal only — public API is pure: &[(f32,f32)] -> Vec<(usize,usize,usize)>
284
285 // Compute bounding box
286 let (mut min_x, mut min_y, mut max_x, mut max_y) =
287 (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
288 for &(x, y) in points {
289 if x < min_x { min_x = x; }
290 if y < min_y { min_y = y; }
291 if x > max_x { max_x = x; }
292 if y > max_y { max_y = y; }
293 }
294
295 let dx = max_x - min_x;
296 let dy = max_y - min_y;
297 let d_max = dx.max(dy).max(1e-6);
298 let mid_x = (min_x + max_x) * 0.5;
299 let mid_y = (min_y + max_y) * 0.5;
300
301 // Super-triangle vertices (indices: n, n+1, n+2)
302 let n = points.len();
303 let s0 = (mid_x - 20.0 * d_max, mid_y - d_max);
304 let s1 = (mid_x, mid_y + 20.0 * d_max);
305 let s2 = (mid_x + 20.0 * d_max, mid_y - d_max);
306
307 // Extended point list: original points + 3 super-triangle vertices
308 let mut all_points: Vec<(f32, f32)> = points.to_vec();
309 all_points.push(s0);
310 all_points.push(s1);
311 all_points.push(s2);
312
313 // Start with super-triangle
314 let mut triangles: Vec<(usize, usize, usize)> = vec![(n, n + 1, n + 2)];
315
316 for i in 0..n {
317 let (px, py) = all_points[i];
318
319 // Find bad triangles (circumcircle contains point i)
320 let mut bad: Vec<usize> = Vec::new();
321 for (t, &(a, b, c)) in triangles.iter().enumerate() {
322 let (ax, ay) = all_points[a];
323 let (bx, by) = all_points[b];
324 let (cx, cy) = all_points[c];
325 if in_circumcircle(px, py, ax, ay, bx, by, cx, cy) {
326 bad.push(t);
327 }
328 }
329
330 // Find boundary polygon (edges that appear in exactly one bad triangle)
331 let mut edges: Vec<(usize, usize)> = Vec::new();
332 for &t in &bad {
333 let (a, b, c) = triangles[t];
334 let tri_edges = [(a, b), (b, c), (c, a)];
335 for &(e0, e1) in &tri_edges {
336 // Check if this edge is shared with another bad triangle
337 let shared = bad.iter().any(|&other| {
338 other != t && {
339 let (oa, ob, oc) = triangles[other];
340 let other_edges = [(oa, ob), (ob, oc), (oc, oa)];
341 other_edges.iter().any(|&(o0, o1)| {
342 (e0 == o0 && e1 == o1) || (e0 == o1 && e1 == o0)
343 })
344 }
345 });
346 if !shared {
347 edges.push((e0, e1));
348 }
349 }
350 }
351
352 // Remove bad triangles (reverse order to preserve indices)
353 bad.sort_unstable();
354 for &t in bad.iter().rev() {
355 triangles.swap_remove(t);
356 }
357
358 // Create new triangles from boundary edges to inserted point
359 for &(e0, e1) in &edges {
360 triangles.push((i, e0, e1));
361 }
362 }
363
364 // Remove triangles that reference super-triangle vertices
365 triangles
366 .into_iter()
367 .filter(|&(a, b, c)| a < n && b < n && c < n)
368 .collect()
369}
370
371// ── Tests ─────────────────────────────────────────────────────────────────────
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 const EPSILON: f32 = 1e-4;
378
379 // ── voronoi_nearest_2d ────────────────────────────────────────────────────
380
381 #[test]
382 fn voronoi_nearest_empty_is_none() {
383 assert!(voronoi_nearest_2d((0.0, 0.0), &[]).is_none());
384 }
385
386 #[test]
387 fn voronoi_nearest_single_seed() {
388 let (idx, dist) = voronoi_nearest_2d((1.0, 0.0), &[(0.0, 0.0)]).unwrap();
389 assert_eq!(idx, 0);
390 assert!((dist - 1.0).abs() < EPSILON);
391 }
392
393 #[test]
394 fn voronoi_nearest_selects_closest() {
395 let seeds = [(0.0_f32, 0.0), (1.0, 0.0), (0.0, 1.0)];
396 let (idx, _) = voronoi_nearest_2d((0.1, 0.1), &seeds).unwrap();
397 assert_eq!(idx, 0);
398 }
399
400 #[test]
401 fn voronoi_nearest_boundary_case() {
402 // Query exactly on seed 1
403 let seeds = [(0.0_f32, 0.0), (1.0, 0.0)];
404 let (idx, dist) = voronoi_nearest_2d((1.0, 0.0), &seeds).unwrap();
405 assert_eq!(idx, 1);
406 assert!(dist.abs() < EPSILON);
407 }
408
409 #[test]
410 fn voronoi_nearest_deterministic() {
411 let seeds = [(0.0_f32, 0.0), (1.0, 0.0), (0.5, 0.5)];
412 let a = voronoi_nearest_2d((0.3, 0.3), &seeds);
413 let b = voronoi_nearest_2d((0.3, 0.3), &seeds);
414 assert_eq!(a, b);
415 }
416
417 // ── voronoi_f1_f2_2d ──────────────────────────────────────────────────────
418
419 #[test]
420 fn voronoi_f1_f2_empty_is_none() {
421 assert!(voronoi_f1_f2_2d((0.0, 0.0), &[]).is_none());
422 }
423
424 #[test]
425 fn voronoi_f1_f2_two_seeds_ordering() {
426 let seeds = [(0.0_f32, 0.0), (1.0, 0.0)];
427 let (f1, f2) = voronoi_f1_f2_2d((0.3, 0.0), &seeds).unwrap();
428 assert!(f1 < f2, "f1={f1} f2={f2}");
429 assert!((f1 - 0.3).abs() < EPSILON, "f1={f1}");
430 assert!((f2 - 0.7).abs() < EPSILON, "f2={f2}");
431 }
432
433 #[test]
434 fn voronoi_f1_f2_deterministic() {
435 let seeds = [(0.0_f32, 0.0), (1.0, 0.0), (0.5, 0.5)];
436 let a = voronoi_f1_f2_2d((0.3, 0.3), &seeds);
437 let b = voronoi_f1_f2_2d((0.3, 0.3), &seeds);
438 assert_eq!(a, b);
439 }
440
441 // ── lloyd_relax_step_2d ───────────────────────────────────────────────────
442
443 #[test]
444 fn lloyd_relax_empty_seeds() {
445 assert!(lloyd_relax_step_2d(&[], &[(0.5, 0.5)]).is_empty());
446 }
447
448 #[test]
449 fn lloyd_relax_no_samples_unchanged() {
450 let seeds = vec![(0.0_f32, 0.0), (1.0, 0.0)];
451 let relaxed = lloyd_relax_step_2d(&seeds, &[]);
452 assert_eq!(relaxed[0], seeds[0]);
453 assert_eq!(relaxed[1], seeds[1]);
454 }
455
456 #[test]
457 fn lloyd_relax_preserves_length() {
458 let seeds: Vec<(f32, f32)> = (0..5).map(|i| (i as f32 * 0.2, 0.0)).collect();
459 let samples: Vec<(f32, f32)> = (0..100).map(|i| (i as f32 * 0.01, 0.0)).collect();
460 let relaxed = lloyd_relax_step_2d(&seeds, &samples);
461 assert_eq!(relaxed.len(), seeds.len());
462 }
463
464 #[test]
465 fn lloyd_relax_two_seeds_move_toward_centroids() {
466 // Seeds at 0.1 and 0.9; symmetric samples in [0,1] on x-axis
467 let seeds = vec![(0.1_f32, 0.0), (0.9_f32, 0.0)];
468 let samples: Vec<(f32, f32)> = (0..=100).map(|i| (i as f32 / 100.0, 0.0)).collect();
469 let relaxed = lloyd_relax_step_2d(&seeds, &samples);
470 // Each seed should move toward 0.25 and 0.75 (centroids of [0,0.5] and [0.5,1])
471 assert!((relaxed[0].0 - 0.25).abs() < 0.05, "seed0={}", relaxed[0].0);
472 assert!((relaxed[1].0 - 0.75).abs() < 0.05, "seed1={}", relaxed[1].0);
473 }
474
475 #[test]
476 fn lloyd_relax_deterministic() {
477 let seeds = vec![(0.1_f32, 0.2), (0.8_f32, 0.7)];
478 let samples: Vec<(f32, f32)> = (0..5).flat_map(|i| {
479 (0..5).map(move |j| (i as f32 * 0.25, j as f32 * 0.25))
480 }).collect();
481 let a = lloyd_relax_step_2d(&seeds, &samples);
482 let b = lloyd_relax_step_2d(&seeds, &samples);
483 assert_eq!(a, b);
484 }
485
486 // ── delaunay_2d ──────────────────────────────────────────────────────────
487
488 #[test]
489 fn delaunay_single_triangle() {
490 let pts = vec![(0.0_f32, 0.0), (1.0, 0.0), (0.5, 1.0)];
491 let tris = delaunay_2d(&pts);
492 assert_eq!(tris.len(), 1);
493 }
494
495 #[test]
496 fn delaunay_four_points_two_triangles() {
497 let pts = vec![(0.0_f32, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
498 let tris = delaunay_2d(&pts);
499 assert_eq!(tris.len(), 2);
500 }
501
502 #[test]
503 fn delaunay_empty() {
504 let pts: Vec<(f32, f32)> = vec![];
505 let tris = delaunay_2d(&pts);
506 assert!(tris.is_empty());
507 }
508
509 #[test]
510 fn delaunay_two_points() {
511 let pts = vec![(0.0_f32, 0.0), (1.0, 0.0)];
512 let tris = delaunay_2d(&pts);
513 assert!(tris.is_empty());
514 }
515
516 #[test]
517 fn delaunay_circumcircle_property() {
518 // For every Delaunay triangle, no other point should be inside its circumcircle
519 let pts = vec![(0.0_f32, 0.0), (4.0, 0.0), (2.0, 3.0), (1.0, 1.0), (3.0, 1.0)];
520 let tris = delaunay_2d(&pts);
521 assert!(!tris.is_empty());
522 for &(i, j, k) in &tris {
523 let (ax, ay) = pts[i];
524 let (bx, by) = pts[j];
525 let (cx, cy) = pts[k];
526 for (m, &(px, py)) in pts.iter().enumerate() {
527 if m == i || m == j || m == k { continue; }
528 assert!(
529 !in_circumcircle(px, py, ax, ay, bx, by, cx, cy),
530 "point {m} inside circumcircle of triangle ({i},{j},{k})"
531 );
532 }
533 }
534 }
535
536 #[test]
537 fn delaunay_deterministic() {
538 let pts = vec![(0.0_f32, 0.0), (1.0, 0.0), (0.5, 1.0), (0.5, 0.5)];
539 let a = delaunay_2d(&pts);
540 let b = delaunay_2d(&pts);
541 assert_eq!(a, b);
542 }
543}