Skip to main content

molpack/
region.rs

1//! Geometric `Region` trait and composition combinators.
2//!
3//! A `Region` is a **geometric predicate** with a signed-distance function:
4//! - `contains(x) == true`  ⇔ x is inside the region
5//! - `signed_distance(x) < 0` inside, `> 0` outside, `== 0` on the boundary
6//!
7//! Regions compose into boolean combinations via `And` / `Or` / `Not`
8//! (pure type algebra — zero runtime cost beyond the component evaluations).
9//! Any `Region` lifts to a soft-penalty `AtomRestraint` via [`RegionRestraint`]:
10//! `penalty(x) = scale2 * max(0, signed_distance(x))²`.
11//!
12//! # Example
13//! ```
14//! use molpack::region::{InsideSphereRegion, Not, Region, RegionExt};
15//! use molpack::RegionRestraint;
16//!
17//! // Spherical shell: inside outer sphere AND NOT inside inner sphere
18//! let shell = InsideSphereRegion::new([0.0; 3], 10.0)
19//!     .and(Not(InsideSphereRegion::new([0.0; 3], 5.0)));
20//! assert!(shell.contains(&[7.0, 0.0, 0.0]));   // in the shell
21//! assert!(!shell.contains(&[3.0, 0.0, 0.0]));  // inside inner sphere
22//! assert!(!shell.contains(&[15.0, 0.0, 0.0])); // outside outer sphere
23//!
24//! // Lift to a AtomRestraint for packing
25//! let restraint = RegionRestraint(shell);
26//! ```
27//!
28//! Direction-3 rule (spec §0 bullet 9): `Region` is a **separate trait**
29//! from `AtomRestraint`; composition operators (`.and()` etc.) live on `Region`,
30//! not on `AtomRestraint`. Plugin vs built-in `Region` are type-equal via
31//! user `impl Region`.
32
33use molrs::types::F;
34
35use crate::restraint::AtomRestraint;
36
37// ============================================================================
38// Core trait
39// ============================================================================
40
41/// Axis-aligned bounding box (AABB), used as an optimization hint for
42/// cell-list setup. Optional (default `None`).
43#[derive(Debug, Clone, Copy)]
44pub struct Aabb {
45    pub min: [F; 3],
46    pub max: [F; 3],
47}
48
49/// Geometric predicate with signed-distance function.
50pub trait Region: Send + Sync + std::fmt::Debug {
51    /// Membership test. Must be consistent with `signed_distance(x) <= 0`.
52    fn contains(&self, x: &[F; 3]) -> bool;
53
54    /// Signed distance to the region boundary.
55    /// - Negative inside, positive outside, zero on the boundary.
56    /// - Not required to be the *Euclidean* signed distance for arbitrary
57    ///   regions; only the sign and gradient direction matter for packing.
58    fn signed_distance(&self, x: &[F; 3]) -> F;
59
60    /// Gradient of `signed_distance` at `x`.
61    ///
62    /// Default implementation is a 3-point central finite difference
63    /// (ε = 1e-6). Concrete `Region` types should override with an
64    /// analytic gradient for hot-path use.
65    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
66        let h: F = 1e-6;
67        let mut g = [0.0; 3];
68        for k in 0..3 {
69            let mut xp = *x;
70            xp[k] += h;
71            let mut xm = *x;
72            xm[k] -= h;
73            g[k] = (self.signed_distance(&xp) - self.signed_distance(&xm)) / (2.0 * h);
74        }
75        g
76    }
77
78    /// Axis-aligned bounding box of the region. Used as an initialization
79    /// hint; default `None` is always safe.
80    fn bounding_box(&self) -> Option<Aabb> {
81        None
82    }
83}
84
85// ============================================================================
86// Combinators
87// ============================================================================
88
89/// Intersection of two regions: inside iff BOTH are inside.
90/// Signed distance uses `max` (outside dominates — chain-rule selects the
91/// larger component's gradient).
92#[derive(Debug, Clone, Copy)]
93pub struct And<A, B>(pub A, pub B);
94
95impl<A: Region, B: Region> Region for And<A, B> {
96    fn contains(&self, x: &[F; 3]) -> bool {
97        self.0.contains(x) && self.1.contains(x)
98    }
99    fn signed_distance(&self, x: &[F; 3]) -> F {
100        self.0.signed_distance(x).max(self.1.signed_distance(x))
101    }
102    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
103        if self.0.signed_distance(x) >= self.1.signed_distance(x) {
104            self.0.signed_distance_grad(x)
105        } else {
106            self.1.signed_distance_grad(x)
107        }
108    }
109}
110
111/// Union of two regions: inside iff EITHER is inside.
112/// Signed distance uses `min` (inside dominates).
113#[derive(Debug, Clone, Copy)]
114pub struct Or<A, B>(pub A, pub B);
115
116impl<A: Region, B: Region> Region for Or<A, B> {
117    fn contains(&self, x: &[F; 3]) -> bool {
118        self.0.contains(x) || self.1.contains(x)
119    }
120    fn signed_distance(&self, x: &[F; 3]) -> F {
121        self.0.signed_distance(x).min(self.1.signed_distance(x))
122    }
123    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
124        if self.0.signed_distance(x) <= self.1.signed_distance(x) {
125            self.0.signed_distance_grad(x)
126        } else {
127            self.1.signed_distance_grad(x)
128        }
129    }
130}
131
132/// Complement: inside iff the inner region is NOT inside.
133/// Signed distance is negated.
134#[derive(Debug, Clone, Copy)]
135pub struct Not<A>(pub A);
136
137impl<A: Region> Region for Not<A> {
138    fn contains(&self, x: &[F; 3]) -> bool {
139        !self.0.contains(x)
140    }
141    fn signed_distance(&self, x: &[F; 3]) -> F {
142        -self.0.signed_distance(x)
143    }
144    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
145        let g = self.0.signed_distance_grad(x);
146        [-g[0], -g[1], -g[2]]
147    }
148}
149
150// ============================================================================
151// Extension trait for `.and() / .or() / .not()` chaining
152// ============================================================================
153
154/// Ergonomic method-chaining for `Region`.
155///
156/// ```
157/// use molpack::region::{InsideSphereRegion, RegionExt};
158/// let outer = InsideSphereRegion::new([0.0; 3], 10.0);
159/// let inner = InsideSphereRegion::new([0.0; 3], 5.0);
160/// let shell = outer.and(inner.not());
161/// # let _ = shell;
162/// ```
163pub trait RegionExt: Region + Sized {
164    fn and<B: Region>(self, other: B) -> And<Self, B> {
165        And(self, other)
166    }
167    fn or<B: Region>(self, other: B) -> Or<Self, B> {
168        Or(self, other)
169    }
170    fn not(self) -> Not<Self> {
171        Not(self)
172    }
173
174    /// Lift this region into a soft-penalty [`AtomRestraint`]. Equivalent
175    /// to wrapping in [`RegionRestraint`] manually.
176    fn into_restraint(self) -> RegionRestraint<Self> {
177        RegionRestraint(self)
178    }
179}
180
181impl<R: Region + Sized> RegionExt for R {}
182
183// ============================================================================
184// RegionRestraint — lift any Region to a quadratic-exterior-penalty AtomRestraint
185// ============================================================================
186
187/// Wraps a `Region` as a soft-penalty `AtomRestraint` with quadratic
188/// exterior penalty:
189///
190/// ```text
191/// penalty(x) = scale2 * max(0, signed_distance(x))²
192/// ```
193///
194/// Gradient uses the analytic chain rule
195/// `2 * scale2 * max(0, d) * ∂d/∂x`, where `∂d/∂x` comes from
196/// `Region::signed_distance_grad`.
197///
198/// # Example
199/// ```
200/// use molpack::region::{InsideSphereRegion, RegionExt};
201/// use molpack::{RegionRestraint, AtomRestraint};
202///
203/// let shell = InsideSphereRegion::new([0.0; 3], 10.0)
204///     .and(InsideSphereRegion::new([0.0; 3], 5.0).not());
205/// let restraint = RegionRestraint(shell);
206/// // At x=(7,0,0) the shell is satisfied → f == 0
207/// assert_eq!(restraint.f(&[7.0, 0.0, 0.0], 1.0, 1.0), 0.0);
208/// // At x=(15,0,0) we are outside the outer sphere → f > 0
209/// assert!(restraint.f(&[15.0, 0.0, 0.0], 1.0, 1.0) > 0.0);
210/// ```
211#[derive(Debug, Clone, Copy)]
212pub struct RegionRestraint<R: Region>(pub R);
213
214impl<R: Region + 'static> AtomRestraint for RegionRestraint<R> {
215    fn f(&self, x: &[F; 3], _scale: F, scale2: F) -> F {
216        let d = self.0.signed_distance(x);
217        let v = d.max(0.0);
218        scale2 * v * v
219    }
220
221    fn fg(&self, x: &[F; 3], scale: F, scale2: F, g: &mut [F; 3]) -> F {
222        let d = self.0.signed_distance(x);
223        if d > 0.0 {
224            let grad = self.0.signed_distance_grad(x);
225            let coeff = 2.0 * scale2 * d;
226            g[0] += coeff * grad[0];
227            g[1] += coeff * grad[1];
228            g[2] += coeff * grad[2];
229        }
230        self.f(x, scale, scale2)
231    }
232}
233
234// ============================================================================
235// Concrete regions (starting set — more can be added incrementally)
236// ============================================================================
237
238/// Axis-aligned box region (inside test).
239#[derive(Debug, Clone, Copy)]
240pub struct InsideBoxRegion {
241    pub min: [F; 3],
242    pub max: [F; 3],
243}
244
245impl InsideBoxRegion {
246    pub fn new(min: [F; 3], max: [F; 3]) -> Self {
247        Self { min, max }
248    }
249}
250
251impl Region for InsideBoxRegion {
252    fn contains(&self, x: &[F; 3]) -> bool {
253        (0..3).all(|k| x[k] >= self.min[k] && x[k] <= self.max[k])
254    }
255
256    fn signed_distance(&self, x: &[F; 3]) -> F {
257        // Signed distance to an axis-aligned box:
258        // d = max_k max(min_k - x_k, x_k - max_k)
259        // Negative inside (both terms negative), positive outside.
260        let mut d = F::NEG_INFINITY;
261        for ((xk, &lo_k), &hi_k) in x.iter().zip(self.min.iter()).zip(self.max.iter()) {
262            d = d.max(lo_k - xk).max(xk - hi_k);
263        }
264        d
265    }
266
267    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
268        // The max is attained on one face; gradient points outward from that face.
269        let mut best_d = F::NEG_INFINITY;
270        let mut best_axis = 0usize;
271        let mut best_sign = 0.0 as F;
272        for (k, ((xk, &lo_k), &hi_k)) in x
273            .iter()
274            .zip(self.min.iter())
275            .zip(self.max.iter())
276            .enumerate()
277        {
278            let lo = lo_k - xk; // gradient component -1 on axis k
279            let hi = xk - hi_k; // gradient component +1 on axis k
280            if lo > best_d {
281                best_d = lo;
282                best_axis = k;
283                best_sign = -1.0;
284            }
285            if hi > best_d {
286                best_d = hi;
287                best_axis = k;
288                best_sign = 1.0;
289            }
290        }
291        let mut g = [0.0; 3];
292        g[best_axis] = best_sign;
293        g
294    }
295
296    fn bounding_box(&self) -> Option<Aabb> {
297        Some(Aabb {
298            min: self.min,
299            max: self.max,
300        })
301    }
302}
303
304/// Spherical region (inside test).
305#[derive(Debug, Clone, Copy)]
306pub struct InsideSphereRegion {
307    pub center: [F; 3],
308    pub radius: F,
309}
310
311impl InsideSphereRegion {
312    pub fn new(center: [F; 3], radius: F) -> Self {
313        Self { center, radius }
314    }
315}
316
317impl Region for InsideSphereRegion {
318    fn contains(&self, x: &[F; 3]) -> bool {
319        let c = self.center;
320        let d2 = (x[0] - c[0]).powi(2) + (x[1] - c[1]).powi(2) + (x[2] - c[2]).powi(2);
321        d2 <= self.radius.powi(2)
322    }
323
324    fn signed_distance(&self, x: &[F; 3]) -> F {
325        let c = self.center;
326        let d = ((x[0] - c[0]).powi(2) + (x[1] - c[1]).powi(2) + (x[2] - c[2]).powi(2)).sqrt();
327        d - self.radius
328    }
329
330    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
331        let c = self.center;
332        let (dx, dy, dz) = (x[0] - c[0], x[1] - c[1], x[2] - c[2]);
333        let d = (dx * dx + dy * dy + dz * dz).sqrt();
334        if d < 1e-12 {
335            [0.0; 3]
336        } else {
337            [dx / d, dy / d, dz / d]
338        }
339    }
340
341    fn bounding_box(&self) -> Option<Aabb> {
342        let r = self.radius;
343        let c = self.center;
344        Some(Aabb {
345            min: [c[0] - r, c[1] - r, c[2] - r],
346            max: [c[0] + r, c[1] + r, c[2] + r],
347        })
348    }
349}
350
351/// Outside-sphere region: `!InsideSphereRegion` with a bespoke impl so
352/// `signed_distance` avoids the `Not(...)` double negation at call time.
353#[derive(Debug, Clone, Copy)]
354pub struct OutsideSphereRegion {
355    pub center: [F; 3],
356    pub radius: F,
357}
358
359impl OutsideSphereRegion {
360    pub fn new(center: [F; 3], radius: F) -> Self {
361        Self { center, radius }
362    }
363}
364
365impl Region for OutsideSphereRegion {
366    fn contains(&self, x: &[F; 3]) -> bool {
367        let c = self.center;
368        let d2 = (x[0] - c[0]).powi(2) + (x[1] - c[1]).powi(2) + (x[2] - c[2]).powi(2);
369        d2 >= self.radius.powi(2)
370    }
371
372    fn signed_distance(&self, x: &[F; 3]) -> F {
373        let c = self.center;
374        let d = ((x[0] - c[0]).powi(2) + (x[1] - c[1]).powi(2) + (x[2] - c[2]).powi(2)).sqrt();
375        self.radius - d
376    }
377
378    fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
379        let c = self.center;
380        let (dx, dy, dz) = (x[0] - c[0], x[1] - c[1], x[2] - c[2]);
381        let d = (dx * dx + dy * dy + dz * dz).sqrt();
382        if d < 1e-12 {
383            [0.0; 3]
384        } else {
385            [-dx / d, -dy / d, -dz / d]
386        }
387    }
388}
389
390// ============================================================================
391// Tests
392// ============================================================================
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    const TOL: F = 1e-6;
399
400    // ── boolean algebra laws ────────────────────────────────────────────────
401
402    #[test]
403    fn and_is_intersection() {
404        let a = InsideBoxRegion::new([0.0; 3], [10.0; 3]);
405        let b = InsideSphereRegion::new([5.0; 3], 4.0);
406        let c = And(a, b);
407        // Inside both
408        assert!(c.contains(&[5.0, 5.0, 5.0]));
409        // Inside box only
410        assert!(!c.contains(&[1.0, 1.0, 1.0]));
411        // Inside sphere only — impossible here since sphere ⊂ box
412        // Outside both
413        assert!(!c.contains(&[-1.0, -1.0, -1.0]));
414    }
415
416    #[test]
417    fn or_is_union() {
418        let a = InsideSphereRegion::new([0.0; 3], 5.0);
419        let b = InsideSphereRegion::new([20.0, 0.0, 0.0], 5.0);
420        let u = Or(a, b);
421        assert!(u.contains(&[0.0, 0.0, 0.0]));
422        assert!(u.contains(&[20.0, 0.0, 0.0]));
423        assert!(!u.contains(&[10.0, 0.0, 0.0]));
424    }
425
426    #[test]
427    fn not_is_complement() {
428        let a = InsideSphereRegion::new([0.0; 3], 5.0);
429        let n = Not(a);
430        assert!(!n.contains(&[0.0, 0.0, 0.0]));
431        assert!(n.contains(&[10.0, 0.0, 0.0]));
432    }
433
434    #[test]
435    fn de_morgan_and() {
436        // !(A ∧ B) == !A ∨ !B
437        let a = InsideSphereRegion::new([0.0; 3], 5.0);
438        let b = InsideBoxRegion::new([-3.0; 3], [3.0; 3]);
439        let lhs = Not(And(a, b));
440        let rhs = Or(Not(a), Not(b));
441        for pt in &[
442            [0.0, 0.0, 0.0],
443            [4.0, 0.0, 0.0],
444            [10.0, 0.0, 0.0],
445            [1.0, 1.0, 1.0],
446        ] {
447            assert_eq!(
448                lhs.contains(pt),
449                rhs.contains(pt),
450                "de Morgan mismatch at {pt:?}"
451            );
452        }
453    }
454
455    // ── signed_distance sign correctness ─────────────────────────────────────
456
457    #[test]
458    fn sphere_signed_distance_sign() {
459        let s = InsideSphereRegion::new([0.0; 3], 5.0);
460        assert!(s.signed_distance(&[0.0, 0.0, 0.0]) < 0.0);
461        assert!((s.signed_distance(&[5.0, 0.0, 0.0])).abs() < TOL);
462        assert!(s.signed_distance(&[10.0, 0.0, 0.0]) > 0.0);
463    }
464
465    #[test]
466    fn box_signed_distance_sign() {
467        let b = InsideBoxRegion::new([0.0; 3], [10.0; 3]);
468        assert!(b.signed_distance(&[5.0, 5.0, 5.0]) < 0.0);
469        assert!(b.signed_distance(&[15.0, 5.0, 5.0]) > 0.0);
470        assert!(b.signed_distance(&[-5.0, 5.0, 5.0]) > 0.0);
471    }
472
473    #[test]
474    fn contains_matches_signed_distance() {
475        let regions: Vec<Box<dyn Region>> = vec![
476            Box::new(InsideSphereRegion::new([0.0; 3], 3.0)),
477            Box::new(InsideBoxRegion::new([0.0; 3], [5.0; 3])),
478            Box::new(OutsideSphereRegion::new([0.0; 3], 2.0)),
479        ];
480        let pts = [
481            [0.0, 0.0, 0.0],
482            [1.0, 1.0, 1.0],
483            [3.0, 3.0, 3.0],
484            [-1.0, 0.0, 0.0],
485        ];
486        for r in &regions {
487            for pt in &pts {
488                let sd = r.signed_distance(pt);
489                // contains ⇔ sd <= 0 (within tolerance for boundary)
490                if sd < -TOL {
491                    assert!(r.contains(pt), "sd={sd}<0 but !contains at {pt:?}");
492                }
493                if sd > TOL {
494                    assert!(!r.contains(pt), "sd={sd}>0 but contains at {pt:?}");
495                }
496            }
497        }
498    }
499
500    // ── RegionRestraint lifts to AtomRestraint ───────────────────────────────────
501
502    #[test]
503    fn from_region_penalty_zero_inside() {
504        let r = RegionRestraint(InsideSphereRegion::new([0.0; 3], 5.0));
505        assert_eq!(r.f(&[0.0, 0.0, 0.0], 1.0, 1.0), 0.0);
506    }
507
508    #[test]
509    fn from_region_penalty_positive_outside() {
510        let r = RegionRestraint(InsideSphereRegion::new([0.0; 3], 5.0));
511        assert!(r.f(&[10.0, 0.0, 0.0], 1.0, 1.0) > 0.0);
512    }
513
514    #[test]
515    fn from_region_gradient_matches_finite_diff() {
516        let r = RegionRestraint(
517            InsideBoxRegion::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0])
518                .and(Not(InsideSphereRegion::new([5.0, 5.0, 5.0], 2.0))),
519        );
520        // Point outside the box in x → composite region reports "outside" via box face
521        let x = [15.0, 5.0, 5.0];
522        let mut g = [0.0; 3];
523        let _ = r.fg(&x, 1.0, 1.0, &mut g);
524
525        // central finite difference
526        let h: F = 1e-5;
527        for k in 0..3 {
528            let mut xp = x;
529            xp[k] += h;
530            let mut xm = x;
531            xm[k] -= h;
532            let fd = (r.f(&xp, 1.0, 1.0) - r.f(&xm, 1.0, 1.0)) / (2.0 * h);
533            assert!(
534                (g[k] - fd).abs() < 1e-3,
535                "gradient mismatch axis {k}: analytic={} fd={} err={}",
536                g[k],
537                fd,
538                (g[k] - fd).abs()
539            );
540        }
541    }
542
543    #[test]
544    fn region_ext_chain() {
545        // RegionExt provides ergonomic .and() / .not()
546        let shell = InsideSphereRegion::new([0.0; 3], 10.0)
547            .and(InsideSphereRegion::new([0.0; 3], 5.0).not());
548        assert!(shell.contains(&[7.0, 0.0, 0.0]));
549        assert!(!shell.contains(&[3.0, 0.0, 0.0]));
550        assert!(!shell.contains(&[15.0, 0.0, 0.0]));
551    }
552
553    #[test]
554    fn gradient_accumulates_not_overwrite() {
555        let r = RegionRestraint(InsideSphereRegion::new([0.0; 3], 1.0));
556        let mut g = [100.0; 3];
557        let _ = r.fg(&[2.0, 0.0, 0.0], 1.0, 1.0, &mut g);
558        assert!(g[0] > 100.0, "should accumulate");
559        assert!((g[1] - 100.0).abs() < TOL);
560        assert!((g[2] - 100.0).abs() < TOL);
561    }
562}