Skip to main content

chematic_3d/
constraints.rs

1//! Distance geometry constraint satisfaction via projection.
2//!
3//! This module provides constraint-based coordinate correction for molecular
4//! 3D geometry. It enforces bond distances and valence angles through iterative
5//! geometric projection, integrated with distance geometry + force field minimization.
6//!
7//! **Algorithm Strategy:**
8//! - Simpler than metric matrix (no eigendecomposition)
9//! - O(n·k) per iteration: n atoms, k constraints
10//! - Fast convergence: 5-10 iterations typical for small molecules
11//! - Directly enforces user intent (bond distances, angles)
12//!
13//! **Pipeline:**
14//! ```ignore
15//! generate_coords(mol)              // rule-based 3D placement
16//!   ↓
17//! build_constraints(mol)            // extract bond/angle targets
18//!   ↓
19//! satisfy_constraints()             // iterative projection
20//!   ↓
21//! minimize_dreiding()               // energy minimization
22//! ```
23
24use crate::coords::{Coords3D, Point3};
25use chematic_core::{AtomIdx, BondOrder, Molecule};
26use core::f64::consts::PI;
27
28// ---------------------------------------------------------------------------
29// Constraint Data Structures
30// ---------------------------------------------------------------------------
31
32/// Bond distance constraint: enforce |P_a - P_b| = target_distance ± tolerance
33#[derive(Debug, Clone)]
34pub struct BondConstraint {
35    pub atom1: AtomIdx,
36    pub atom2: AtomIdx,
37    pub target_distance: f64, // Å
38    pub tolerance: f64,       // Å
39}
40
41impl BondConstraint {
42    /// Create a new bond constraint with default tolerance (±0.05 Å).
43    pub fn new(atom1: AtomIdx, atom2: AtomIdx, target_distance: f64) -> Self {
44        Self {
45            atom1,
46            atom2,
47            target_distance,
48            tolerance: 0.05,
49        }
50    }
51
52    /// Check if current distance satisfies constraint.
53    pub fn satisfied(&self, coords: &Coords3D) -> bool {
54        let d = coords.get(self.atom1).distance(&coords.get(self.atom2));
55        let lower = self.target_distance - self.tolerance;
56        let upper = self.target_distance + self.tolerance;
57        d >= lower && d <= upper
58    }
59
60    /// Violation magnitude; 0.0 if satisfied.
61    pub fn violation(&self, coords: &Coords3D) -> f64 {
62        let d = coords.get(self.atom1).distance(&coords.get(self.atom2));
63        let lower = self.target_distance - self.tolerance;
64        let upper = self.target_distance + self.tolerance;
65        if d < lower {
66            lower - d
67        } else if d > upper {
68            d - upper
69        } else {
70            0.0
71        }
72    }
73}
74
75/// Valence angle constraint: enforce ∠(atom1—center—atom2) ≈ target_angle ± tolerance
76#[derive(Debug, Clone)]
77pub struct AngleConstraint {
78    pub atom1: AtomIdx,
79    pub center: AtomIdx,
80    pub atom2: AtomIdx,
81    pub target_angle: f64, // radians
82    pub tolerance: f64,    // radians
83}
84
85impl AngleConstraint {
86    /// Create a new angle constraint with default tolerance (±5°).
87    pub fn new(atom1: AtomIdx, center: AtomIdx, atom2: AtomIdx, target_angle: f64) -> Self {
88        Self {
89            atom1,
90            center,
91            atom2,
92            target_angle,
93            tolerance: 5.0_f64.to_radians(),
94        }
95    }
96
97    /// Check if current angle satisfies constraint.
98    pub fn satisfied(&self, coords: &Coords3D) -> bool {
99        let angle = compute_angle(coords, self.atom1, self.center, self.atom2);
100        let lower = self.target_angle - self.tolerance;
101        let upper = self.target_angle + self.tolerance;
102        angle >= lower && angle <= upper
103    }
104
105    /// Violation magnitude; 0.0 if satisfied.
106    pub fn violation(&self, coords: &Coords3D) -> f64 {
107        let angle = compute_angle(coords, self.atom1, self.center, self.atom2);
108        let lower = self.target_angle - self.tolerance;
109        let upper = self.target_angle + self.tolerance;
110        if angle < lower {
111            lower - angle
112        } else if angle > upper {
113            angle - upper
114        } else {
115            0.0
116        }
117    }
118}
119
120/// Complete set of bond and angle constraints for a molecule.
121#[derive(Debug, Clone)]
122pub struct ConstraintSet {
123    pub bonds: Vec<BondConstraint>,
124    pub angles: Vec<AngleConstraint>,
125}
126
127impl ConstraintSet {
128    /// Count of constraints currently violated (outside tolerance).
129    pub fn violated_count(&self, coords: &Coords3D) -> usize {
130        self.bonds.iter().filter(|c| !c.satisfied(coords)).count()
131            + self.angles.iter().filter(|c| !c.satisfied(coords)).count()
132    }
133
134    /// Maximum violation magnitude among all constraints.
135    pub fn max_violation(&self, coords: &Coords3D) -> f64 {
136        let bond_violations = self
137            .bonds
138            .iter()
139            .map(|c| c.violation(coords))
140            .fold(0.0, f64::max);
141        let angle_violations = self
142            .angles
143            .iter()
144            .map(|c| c.violation(coords))
145            .fold(0.0, f64::max);
146        bond_violations.max(angle_violations)
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Ideal Parameters (Reuse from dg.rs / minimize.rs)
152// ---------------------------------------------------------------------------
153
154/// Get ideal bond length from element pair and bond order.
155fn get_ideal_bond_length(mol: &Molecule, a: AtomIdx, b: AtomIdx) -> f64 {
156    let ea = mol.atom(a).element;
157    let eb = mol.atom(b).element;
158
159    let order = mol
160        .bond_between(a, b)
161        .map(|(_, bond)| bond.order)
162        .unwrap_or(BondOrder::Single);
163
164    let (lo, hi) = if ea.atomic_number() <= eb.atomic_number() {
165        (ea.atomic_number(), eb.atomic_number())
166    } else {
167        (eb.atomic_number(), ea.atomic_number())
168    };
169
170    match (lo, hi, order) {
171        // C–C
172        (6, 6, BondOrder::Single) | (6, 6, BondOrder::Up) | (6, 6, BondOrder::Down) => 1.54,
173        (6, 6, BondOrder::Double) => 1.34,
174        (6, 6, BondOrder::Triple) => 1.20,
175        (6, 6, BondOrder::Aromatic) => 1.40,
176        // C–N
177        (6, 7, BondOrder::Single) | (6, 7, BondOrder::Up) | (6, 7, BondOrder::Down) => 1.47,
178        (6, 7, BondOrder::Double) => 1.27,
179        (6, 7, BondOrder::Triple) => 1.16,
180        (6, 7, BondOrder::Aromatic) => 1.34,
181        // C–O
182        (6, 8, BondOrder::Single) | (6, 8, BondOrder::Up) | (6, 8, BondOrder::Down) => 1.43,
183        (6, 8, BondOrder::Double) => 1.22,
184        (6, 8, BondOrder::Aromatic) => 1.36,
185        // C–S
186        (6, 16, _) => 1.82,
187        // C–F
188        (6, 9, _) => 1.35,
189        // C–Cl
190        (6, 17, _) => 1.77,
191        // C–Br
192        (6, 35, _) => 1.94,
193        // C–I
194        (6, 53, _) => 2.14,
195        // C–H
196        (1, 6, _) => 1.09,
197        // N–H
198        (1, 7, _) => 1.01,
199        // O–H
200        (1, 8, _) => 0.96,
201        // Default
202        _ => 1.54,
203    }
204}
205
206/// Get ideal valence angle based on atom hybridization.
207fn get_ideal_angle(mol: &Molecule, center: AtomIdx) -> f64 {
208    let mut has_triple = false;
209    let mut has_double_or_arom = false;
210
211    for (_, bidx) in mol.neighbors(center) {
212        match mol.bond(bidx).order {
213            BondOrder::Triple => has_triple = true,
214            BondOrder::Double | BondOrder::Aromatic => has_double_or_arom = true,
215            _ => {}
216        }
217    }
218
219    if has_triple {
220        PI // 180°
221    } else if has_double_or_arom {
222        PI * 2.0 / 3.0 // 120°
223    } else {
224        109.47_f64.to_radians() // sp3
225    }
226}
227
228// ---------------------------------------------------------------------------
229// Constraint Assembly
230// ---------------------------------------------------------------------------
231
232/// Build constraint set from molecular topology.
233///
234/// Extracts:
235/// - Bond constraints from all bonds (target = ideal distance)
236/// - Angle constraints from all adjacent bond pairs (target = ideal angle)
237pub fn build_constraints(mol: &Molecule) -> ConstraintSet {
238    let mut bonds = Vec::new();
239    let mut angles = Vec::new();
240
241    // Bond constraints: one per bond
242    for (_, bond) in mol.bonds() {
243        let a1 = bond.atom1;
244        let a2 = bond.atom2;
245        let ideal_dist = get_ideal_bond_length(mol, a1, a2);
246        bonds.push(BondConstraint::new(a1, a2, ideal_dist));
247    }
248
249    // Angle constraints: for each atom with ≥2 neighbors
250    for center_idx in 0..mol.atom_count() {
251        let center = AtomIdx(center_idx as u32);
252        let neighbors: Vec<AtomIdx> = mol.neighbors(center).map(|(nb, _)| nb).collect();
253
254        if neighbors.len() < 2 {
255            continue;
256        }
257
258        let ideal_angle = get_ideal_angle(mol, center);
259
260        for i in 0..neighbors.len() {
261            for j in (i + 1)..neighbors.len() {
262                let a1 = neighbors[i];
263                let a2 = neighbors[j];
264                angles.push(AngleConstraint::new(a1, center, a2, ideal_angle));
265            }
266        }
267    }
268
269    ConstraintSet { bonds, angles }
270}
271
272// ---------------------------------------------------------------------------
273// Geometric Utilities
274// ---------------------------------------------------------------------------
275
276/// Compute angle A—Center—B in radians.
277pub(crate) fn compute_angle(coords: &Coords3D, a: AtomIdx, center: AtomIdx, b: AtomIdx) -> f64 {
278    let pa = coords.get(a);
279    let pc = coords.get(center);
280    let pb = coords.get(b);
281
282    let va = pa.sub(&pc);
283    let vb = pb.sub(&pc);
284
285    let na = va.norm();
286    let nb = vb.norm();
287
288    if na < 1e-10 || nb < 1e-10 {
289        return 0.0;
290    }
291
292    let cos_angle = (va.dot(&vb) / (na * nb)).clamp(-1.0, 1.0);
293    cos_angle.acos()
294}
295
296/// Return any unit vector perpendicular to `v`.
297#[allow(dead_code)]
298fn perpendicular_to(v: Point3) -> Point3 {
299    let candidate = if v.x.abs() < 0.9 {
300        Point3::new(1.0, 0.0, 0.0)
301    } else {
302        Point3::new(0.0, 1.0, 0.0)
303    };
304    let proj = v.scale(v.dot(&candidate));
305    candidate.sub(&proj).normalize()
306}
307
308/// Rotate vector `v` around unit axis `axis` by angle `theta` (radians).
309/// Uses Rodrigues' rotation formula.
310pub(crate) fn rotate_around_axis(v: Point3, axis: Point3, theta: f64) -> Point3 {
311    let cos_t = theta.cos();
312    let sin_t = theta.sin();
313    let dot = axis.dot(&v);
314
315    let term1 = v.scale(cos_t);
316    let term2 = axis.cross(&v).scale(sin_t);
317    let term3 = axis.scale(dot * (1.0 - cos_t));
318    term1.add(&term2).add(&term3)
319}
320
321// ---------------------------------------------------------------------------
322// Constraint Satisfaction via Projection
323// ---------------------------------------------------------------------------
324
325/// Project a single bond constraint: move atoms toward target distance.
326///
327/// Both atoms move symmetrically toward/away from their midpoint to achieve
328/// the target distance. This preserves the centroid of the pair.
329fn project_bond_constraint(coords: &mut Coords3D, constraint: &BondConstraint) {
330    let p1 = coords.get(constraint.atom1);
331    let p2 = coords.get(constraint.atom2);
332
333    let current_dist = p1.distance(&p2);
334    if current_dist < 1e-6 {
335        return; // atoms coincident, can't project
336    }
337
338    let target_dist = constraint.target_distance;
339    let lower = target_dist - constraint.tolerance;
340    let upper = target_dist + constraint.tolerance;
341
342    // Already satisfied?
343    if current_dist >= lower && current_dist <= upper {
344        return;
345    }
346
347    // Direction from p1 to p2
348    let direction = p2.sub(&p1).scale(1.0 / current_dist);
349
350    // Midpoint between atoms
351    let mid = Point3::new(
352        (p1.x + p2.x) / 2.0,
353        (p1.y + p2.y) / 2.0,
354        (p1.z + p2.z) / 2.0,
355    );
356
357    // Target distance: move to nearer boundary if out of tolerance
358    let target_effective = if current_dist < lower { lower } else { upper };
359
360    // New positions: symmetric movement from midpoint
361    let offset = direction.scale(target_effective / 2.0);
362    let new_p1 = mid.sub(&offset);
363    let new_p2 = mid.add(&offset);
364
365    coords.set(constraint.atom1, new_p1);
366    coords.set(constraint.atom2, new_p2);
367}
368
369/// Project a single angle constraint: rotate one atom around a bond axis.
370///
371/// Rotates atom1 around the axis (center—atom2) to achieve the target angle.
372/// atom2 is held fixed; atom1 moves.
373fn project_angle_constraint(coords: &mut Coords3D, constraint: &AngleConstraint) {
374    let p1 = coords.get(constraint.atom1);
375    let center = coords.get(constraint.center);
376    let p2 = coords.get(constraint.atom2);
377
378    let v1 = p1.sub(&center);
379    let v2 = p2.sub(&center);
380
381    let n1 = v1.norm();
382    let n2 = v2.norm();
383
384    if n1 < 1e-10 || n2 < 1e-10 {
385        return; // degenerate geometry
386    }
387
388    // Current angle
389    let cos_angle = (v1.dot(&v2) / (n1 * n2)).clamp(-1.0, 1.0);
390    let current_angle = cos_angle.acos();
391
392    // Check if already satisfied
393    let lower = constraint.target_angle - constraint.tolerance;
394    let upper = constraint.target_angle + constraint.tolerance;
395
396    if current_angle >= lower && current_angle <= upper {
397        return;
398    }
399
400    // Axis of rotation: the bond direction center—atom2
401    let axis = v2.normalize();
402
403    // Rotation angle: delta toward target
404    let delta_angle = constraint.target_angle - current_angle;
405
406    // Rotate v1 around axis
407    let v1_rotated = rotate_around_axis(v1, axis, delta_angle);
408    let new_p1 = center.add(&v1_rotated);
409
410    coords.set(constraint.atom1, new_p1);
411}
412
413/// Satisfy all constraints iteratively via geometric projection.
414///
415/// # Arguments
416/// * `coords` - Initial coordinates
417/// * `mol` - Molecule (for constraint context)
418/// * `constraints` - Constraint set to satisfy
419/// * `max_iterations` - Maximum projection iterations (typically 10-20)
420///
421/// # Returns
422/// Coordinates with constraints satisfied (or as many as possible).
423///
424/// # Algorithm
425/// For each iteration:
426/// 1. Project all bond constraints (move atom pairs)
427/// 2. Every other iteration: project angle constraints (avoid oscillation)
428/// 3. Stop if all constraints satisfied or no progress made
429pub fn satisfy_constraints(
430    coords: &Coords3D,
431    _mol: &Molecule,
432    constraints: &ConstraintSet,
433    max_iterations: usize,
434) -> Coords3D {
435    let mut result = coords.clone();
436
437    for iteration in 0..max_iterations {
438        let violation_before = constraints.violated_count(&result);
439
440        // Project all bond constraints
441        for constraint in &constraints.bonds {
442            project_bond_constraint(&mut result, constraint);
443        }
444
445        // Project angle constraints every other iteration to avoid oscillation
446        if iteration % 2 == 0 {
447            for constraint in &constraints.angles {
448                project_angle_constraint(&mut result, constraint);
449            }
450        }
451
452        let violation_after = constraints.violated_count(&result);
453
454        // Convergence: no constraints violated
455        if violation_after == 0 {
456            break;
457        }
458
459        // No progress: stop
460        if iteration > 3 && (violation_before as i32 - violation_after as i32).abs() < 2 {
461            break;
462        }
463    }
464
465    result
466}
467
468// ---------------------------------------------------------------------------
469// Tests
470// ---------------------------------------------------------------------------
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use chematic_smiles::parse;
476
477    #[test]
478    fn test_bond_constraint_creation() {
479        let constraint = BondConstraint::new(AtomIdx(0), AtomIdx(1), 1.54);
480        assert_eq!(constraint.target_distance, 1.54);
481        assert_eq!(constraint.tolerance, 0.05);
482    }
483
484    #[test]
485    fn test_bond_constraint_ethane_ideal_distance() {
486        let mol = parse("CC").unwrap();
487        let constraints = build_constraints(&mol);
488        assert_eq!(constraints.bonds.len(), 1, "ethane has 1 bond");
489
490        let bond = &constraints.bonds[0];
491        assert!(
492            (bond.target_distance - 1.54).abs() < 0.01,
493            "C-C ideal ~1.54 Å"
494        );
495    }
496
497    #[test]
498    fn test_angle_constraint_creation() {
499        let constraint =
500            AngleConstraint::new(AtomIdx(0), AtomIdx(1), AtomIdx(2), 109.47_f64.to_radians());
501        assert!((constraint.target_angle - 109.47_f64.to_radians()).abs() < 1e-6);
502    }
503
504    #[test]
505    fn test_constraint_set_propane_angles() {
506        let mol = parse("CCC").unwrap();
507        let constraints = build_constraints(&mol);
508
509        // Center atom (index 1) should have angle constraint with 2 neighbors
510        let center_angles: Vec<_> = constraints
511            .angles
512            .iter()
513            .filter(|a| a.center == AtomIdx(1))
514            .collect();
515
516        assert_eq!(
517            center_angles.len(),
518            1,
519            "center of propane has 1 angle constraint"
520        );
521    }
522
523    #[test]
524    fn test_project_bond_constraint_too_far() {
525        let _mol = parse("CC").unwrap();
526        let mut coords = Coords3D::new_zeroed(2);
527        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
528        coords.set(AtomIdx(1), Point3::new(5.0, 0.0, 0.0)); // too far: 5.0 Å
529
530        let constraint = BondConstraint::new(AtomIdx(0), AtomIdx(1), 1.54);
531        project_bond_constraint(&mut coords, &constraint);
532
533        let d = coords.get(AtomIdx(0)).distance(&coords.get(AtomIdx(1)));
534        // Should be near 1.54 (within tolerance + some projection error)
535        assert!(
536            d > 1.4 && d < 1.7,
537            "projected distance {:.3}, expected ~1.54",
538            d
539        );
540    }
541
542    #[test]
543    fn test_project_bond_constraint_too_close() {
544        let _mol = parse("CC").unwrap();
545        let mut coords = Coords3D::new_zeroed(2);
546        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
547        coords.set(AtomIdx(1), Point3::new(0.5, 0.0, 0.0)); // too close: 0.5 Å
548
549        let constraint = BondConstraint::new(AtomIdx(0), AtomIdx(1), 1.54);
550        project_bond_constraint(&mut coords, &constraint);
551
552        let d = coords.get(AtomIdx(0)).distance(&coords.get(AtomIdx(1)));
553        assert!(
554            d > 1.4 && d < 1.7,
555            "projected distance {:.3}, expected ~1.54",
556            d
557        );
558    }
559
560    #[test]
561    fn test_constraint_satisfaction_ethane() {
562        let mol = parse("CC").unwrap();
563        let mut coords = Coords3D::new_zeroed(2);
564        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
565        coords.set(AtomIdx(1), Point3::new(3.0, 0.0, 0.0));
566
567        let constraints = build_constraints(&mol);
568        let before_violations = constraints.violated_count(&coords);
569
570        let satisfied = satisfy_constraints(&coords, &mol, &constraints, 10);
571        let after_violations = constraints.violated_count(&satisfied);
572
573        assert!(
574            after_violations <= before_violations,
575            "should reduce violations"
576        );
577    }
578
579    #[test]
580    fn test_constraint_set_benzene() {
581        let mol = parse("c1ccccc1").unwrap();
582        let constraints = build_constraints(&mol);
583
584        assert_eq!(constraints.bonds.len(), 6, "benzene has 6 C-C bonds");
585        assert_eq!(constraints.angles.len(), 6, "benzene has 6 C-C-C angles");
586
587        // All aromatic bonds should have target ~1.40 Å
588        for bond in &constraints.bonds {
589            assert!((bond.target_distance - 1.40).abs() < 0.01);
590        }
591    }
592
593    #[test]
594    fn test_no_clashes_after_projection() {
595        let mol = parse("CCCC").unwrap();
596        let mut coords = Coords3D::new_zeroed(4);
597        // Place in a line, too close
598        for i in 0..4 {
599            coords.set(AtomIdx(i as u32), Point3::new(i as f64 * 0.5, 0.0, 0.0));
600        }
601
602        let constraints = build_constraints(&mol);
603        let satisfied = satisfy_constraints(&coords, &mol, &constraints, 20);
604
605        // Check minimum distance
606        let mut min_d = f64::MAX;
607        for i in 0..4 {
608            for j in (i + 1)..4 {
609                let d = satisfied
610                    .get(AtomIdx(i as u32))
611                    .distance(&satisfied.get(AtomIdx(j as u32)));
612                min_d = min_d.min(d);
613            }
614        }
615
616        assert!(
617            min_d > 0.1,
618            "minimum distance {:.3}, atoms may clash",
619            min_d
620        );
621    }
622
623    #[test]
624    fn test_compute_angle_90_degrees() {
625        let coords = Coords3D::new_zeroed(3);
626        let mut coords_mut = coords;
627        coords_mut.set(AtomIdx(0), Point3::new(1.0, 0.0, 0.0));
628        coords_mut.set(AtomIdx(1), Point3::new(0.0, 0.0, 0.0));
629        coords_mut.set(AtomIdx(2), Point3::new(0.0, 1.0, 0.0));
630
631        let angle = compute_angle(&coords_mut, AtomIdx(0), AtomIdx(1), AtomIdx(2));
632        let expected = 90.0_f64.to_radians();
633        assert!((angle - expected).abs() < 0.01);
634    }
635
636    #[test]
637    fn test_bond_constraint_satisfied_true() {
638        let mut coords = Coords3D::new_zeroed(2);
639        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
640        coords.set(AtomIdx(1), Point3::new(1.54, 0.0, 0.0));
641
642        let constraint = BondConstraint::new(AtomIdx(0), AtomIdx(1), 1.54);
643        assert!(constraint.satisfied(&coords));
644    }
645
646    #[test]
647    fn test_bond_constraint_satisfied_false() {
648        let mut coords = Coords3D::new_zeroed(2);
649        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
650        coords.set(AtomIdx(1), Point3::new(3.0, 0.0, 0.0));
651
652        let constraint = BondConstraint::new(AtomIdx(0), AtomIdx(1), 1.54);
653        assert!(!constraint.satisfied(&coords));
654    }
655
656    #[test]
657    fn test_satisfy_constraints_single_bond() {
658        use chematic_core::{Element, MoleculeBuilder};
659
660        let mut builder = MoleculeBuilder::new();
661        builder.add_atom(chematic_core::Atom::new(Element::C));
662        builder.add_atom(chematic_core::Atom::new(Element::C));
663        let _ = builder.add_bond(AtomIdx(0), AtomIdx(1), chematic_core::BondOrder::Single);
664        let mol = builder.build();
665
666        let mut coords = Coords3D::new_zeroed(2);
667        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
668        coords.set(AtomIdx(1), Point3::new(3.0, 0.0, 0.0)); // Far from ideal
669
670        let target_dist = 1.54;
671        let constraint = BondConstraint::new(AtomIdx(0), AtomIdx(1), target_dist);
672        let constraints = ConstraintSet {
673            bonds: vec![constraint],
674            angles: Vec::new(),
675        };
676
677        let result = satisfy_constraints(&coords, &mol, &constraints, 20);
678
679        let dist = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
680        assert!(
681            (dist - target_dist).abs() < 0.1,
682            "Bond constraint should converge to target distance"
683        );
684    }
685
686    #[test]
687    fn test_satisfy_constraints_convergence() {
688        use chematic_core::{Element, MoleculeBuilder};
689
690        let mut builder = MoleculeBuilder::new();
691        for _ in 0..3 {
692            builder.add_atom(chematic_core::Atom::new(Element::C));
693        }
694        let _ = builder.add_bond(AtomIdx(0), AtomIdx(1), chematic_core::BondOrder::Single);
695        let _ = builder.add_bond(AtomIdx(1), AtomIdx(2), chematic_core::BondOrder::Single);
696        let mol = builder.build();
697
698        let mut coords = Coords3D::new_zeroed(3);
699        coords.set(AtomIdx(0), Point3::new(0.0, 0.0, 0.0));
700        coords.set(AtomIdx(1), Point3::new(5.0, 0.0, 0.0));
701        coords.set(AtomIdx(2), Point3::new(5.0, 5.0, 0.0));
702
703        let constraints = ConstraintSet {
704            bonds: vec![
705                BondConstraint::new(AtomIdx(0), AtomIdx(1), 1.54),
706                BondConstraint::new(AtomIdx(1), AtomIdx(2), 1.54),
707            ],
708            angles: Vec::new(),
709        };
710
711        let result = satisfy_constraints(&coords, &mol, &constraints, 20);
712
713        let dist01 = result.get(AtomIdx(0)).distance(&result.get(AtomIdx(1)));
714        let dist12 = result.get(AtomIdx(1)).distance(&result.get(AtomIdx(2)));
715        assert!((dist01 - 1.54).abs() < 0.15);
716        assert!((dist12 - 1.54).abs() < 0.15);
717    }
718}