1use crate::coords::{Coords3D, Point3};
25use chematic_core::{AtomIdx, BondOrder, Molecule};
26use core::f64::consts::PI;
27
28#[derive(Debug, Clone)]
34pub struct BondConstraint {
35 pub atom1: AtomIdx,
36 pub atom2: AtomIdx,
37 pub target_distance: f64, pub tolerance: f64, }
40
41impl BondConstraint {
42 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 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 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#[derive(Debug, Clone)]
77pub struct AngleConstraint {
78 pub atom1: AtomIdx,
79 pub center: AtomIdx,
80 pub atom2: AtomIdx,
81 pub target_angle: f64, pub tolerance: f64, }
84
85impl AngleConstraint {
86 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 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 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#[derive(Debug, Clone)]
122pub struct ConstraintSet {
123 pub bonds: Vec<BondConstraint>,
124 pub angles: Vec<AngleConstraint>,
125}
126
127impl ConstraintSet {
128 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 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
150fn 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 (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 (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 (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 (6, 16, _) => 1.82,
187 (6, 9, _) => 1.35,
189 (6, 17, _) => 1.77,
191 (6, 35, _) => 1.94,
193 (6, 53, _) => 2.14,
195 (1, 6, _) => 1.09,
197 (1, 7, _) => 1.01,
199 (1, 8, _) => 0.96,
201 _ => 1.54,
203 }
204}
205
206fn 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 } else if has_double_or_arom {
222 PI * 2.0 / 3.0 } else {
224 109.47_f64.to_radians() }
226}
227
228pub fn build_constraints(mol: &Molecule) -> ConstraintSet {
238 let mut bonds = Vec::new();
239 let mut angles = Vec::new();
240
241 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 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
272pub(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#[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
308pub(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
321fn 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; }
337
338 let target_dist = constraint.target_distance;
339 let lower = target_dist - constraint.tolerance;
340 let upper = target_dist + constraint.tolerance;
341
342 if current_dist >= lower && current_dist <= upper {
344 return;
345 }
346
347 let direction = p2.sub(&p1).scale(1.0 / current_dist);
349
350 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 let target_effective = if current_dist < lower { lower } else { upper };
359
360 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
369fn 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(¢er);
379 let v2 = p2.sub(¢er);
380
381 let n1 = v1.norm();
382 let n2 = v2.norm();
383
384 if n1 < 1e-10 || n2 < 1e-10 {
385 return; }
387
388 let cos_angle = (v1.dot(&v2) / (n1 * n2)).clamp(-1.0, 1.0);
390 let current_angle = cos_angle.acos();
391
392 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 let axis = v2.normalize();
402
403 let delta_angle = constraint.target_angle - current_angle;
405
406 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
413pub 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 for constraint in &constraints.bonds {
442 project_bond_constraint(&mut result, constraint);
443 }
444
445 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 if violation_after == 0 {
456 break;
457 }
458
459 if iteration > 3 && (violation_before as i32 - violation_after as i32).abs() < 2 {
461 break;
462 }
463 }
464
465 result
466}
467
468#[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 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)); 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 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)); 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 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 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 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)); 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}