use std::collections::{BTreeMap, BTreeSet};
use omgkit_core::{BondFlags, BondOrder, MolBuilder};
use crate::geom::{segments_cross, Point2};
use crate::label::{label_for, HSide, LabelPlace};
use crate::style::Style;
const BARE_RADIUS: f64 = 0.25;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct Report {
pub flipped: Vec<u32>,
pub spiro_flipped: Vec<u32>,
pub swapped: Vec<u32>,
pub splayed: Vec<u32>,
pub unresolved: Vec<(u32, u32)>,
pub crossings: Vec<(u32, u32)>,
}
pub(crate) fn relieve(
mol: &MolBuilder,
pos: &mut BTreeMap<u32, Point2>,
ranks: &[u32],
style: &Style,
) -> Report {
let radii = radii(mol, style);
let mut best = score(mol, pos, &radii);
let mut report = Report::default();
if best == (0, 0, 0.0) {
return report;
}
let mut cands: Vec<u32> = rotatable(mol, pos);
cands.sort_by_key(|&b| {
let bd = &mol.bonds()[b as usize];
let (x, y) = (ranks[bd.begin as usize], ranks[bd.end as usize]);
(x.min(y), x.max(y), b)
});
let spiros = spiro_flips(mol, pos, ranks);
let swaps = pair_swaps(mol, pos, ranks);
let keeps_stereo = |pos: &BTreeMap<u32, Point2>| {
let mut flat = vec![Point2::ORIGIN; mol.num_atoms()];
for (a, q) in pos.iter() {
flat[*a as usize] = *q;
}
crate::stereo::cis_trans_intact(mol, &flat)
};
let max_rounds = (cands.len() * 3 + spiros.len() + swaps.len()).max(1) * 2;
for _ in 0..max_rounds {
let mut improved = false;
for &b in &cands {
let Some(side) = far_side(mol, pos, b, ranks) else {
continue;
};
let bd = &mol.bonds()[b as usize];
let (u, v) = (pos[&bd.begin], pos[&bd.end]);
let saved: Vec<(u32, Point2)> = side.iter().map(|a| (*a, pos[a])).collect();
for a in &side {
let p = pos[a].mirrored(u, v - u);
pos.insert(*a, p);
}
let now = score(mol, pos, &radii);
if keeps_stereo(pos) && better(now, best) {
best = now;
report.flipped.push(b);
improved = true;
} else {
for (a, p) in saved {
pos.insert(a, p);
}
}
}
for sf in &spiros {
let (Some(c), Some(p1), Some(p2)) = (
pos.get(&sf.centre).copied(),
pos.get(&sf.ring_nbrs.0).copied(),
pos.get(&sf.ring_nbrs.1).copied(),
) else {
continue;
};
let axis = (p1 + p2) * 0.5 - c;
if axis.norm() < 1e-9 {
continue; }
if (c.dist(p1) - c.dist(p2)).abs() > 1e-9 {
continue;
}
let saved: Vec<(u32, Point2)> = sf.side.iter().map(|a| (*a, pos[a])).collect();
for a in &sf.side {
let p = pos[a].mirrored(c, axis);
pos.insert(*a, p);
}
let now = score(mol, pos, &radii);
if keeps_stereo(pos) && better(now, best) {
best = now;
report.spiro_flipped.push(sf.centre);
improved = true;
} else {
for (a, p) in saved {
pos.insert(a, p);
}
}
}
for sw in &swaps {
let (Some(c), Some(pa), Some(pb)) = (
pos.get(&sw.centre).copied(),
pos.get(&sw.ends.0).copied(),
pos.get(&sw.ends.1).copied(),
) else {
continue;
};
let axis = (pa + pb) * 0.5 - c;
if axis.norm() < 1e-9 {
continue; }
if (c.dist(pa) - c.dist(pb)).abs() > 1e-9 {
continue;
}
let saved: Vec<(u32, Point2)> = sw
.sides
.0
.iter()
.chain(sw.sides.1.iter())
.map(|a| (*a, pos[a]))
.collect();
for a in sw.sides.0.iter().chain(sw.sides.1.iter()) {
let p = pos[a].mirrored(c, axis);
pos.insert(*a, p);
}
let now = score(mol, pos, &radii);
if keeps_stereo(pos) && better(now, best) {
best = now;
report.swapped.push(sw.centre);
improved = true;
} else {
for (a, p) in saved {
pos.insert(a, p);
}
}
}
if !improved && best.0 > 0 {
let mut ranked: Vec<(SplayKey, Splay)> = Vec::new();
for sp in splays(mol, pos, ranks) {
let before = narrowest_at(mol, pos, sp.pivot);
let saved: Vec<(u32, Point2)> = sp.moved.iter().map(|a| (*a, pos[a])).collect();
let c = pos[&sp.pivot];
for a in &sp.moved {
let p = pos[a].rotated_about(c, sp.by);
pos.insert(*a, p);
}
let now = score(mol, pos, &radii);
let after = narrowest_at(mol, pos, sp.pivot);
let ok = now.0 < best.0 && keeps_stereo(pos) && angle_survives(before, after);
debug_assert!(!ok || better(now, best), "撑开被接受了,却不算改善");
for (a, p) in saved {
pos.insert(a, p);
}
if ok {
ranked.push((
(
now.0,
now.1,
-q6(after),
ranks[sp.pivot as usize],
sp.moved
.iter()
.map(|a| ranks[*a as usize])
.min()
.unwrap_or(u32::MAX),
sp.toward,
),
sp,
));
}
}
if let Some((_, sp)) = ranked.into_iter().min_by(|x, y| x.0.cmp(&y.0)) {
let c = pos[&sp.pivot];
for a in &sp.moved {
let p = pos[a].rotated_about(c, sp.by);
pos.insert(*a, p);
}
best = score(mol, pos, &radii);
report.splayed.push(sp.pivot);
improved = true;
}
}
if !improved {
break;
}
}
let (pairs, crossings) = remaining(mol, pos, &radii);
report.unresolved = pairs;
report.crossings = crossings;
report
}
pub(crate) fn radii(mol: &MolBuilder, style: &Style) -> Vec<f64> {
(0..mol.num_atoms())
.map(|i| {
let a = u32::try_from(i).expect("原子数超出 u32");
[
LabelPlace::Horizontal(HSide::Right),
LabelPlace::Horizontal(HSide::Left),
]
.iter()
.filter_map(|s| label_for(mol, a, style, *s))
.map(|l| l.half_w.hypot(l.half_h))
.fold(BARE_RADIUS, f64::max)
})
.collect()
}
fn better(now: Score, best: Score) -> bool {
const EPS: f64 = 1e-9;
if (now.0, now.1) != (best.0, best.1) {
return (now.0, now.1) < (best.0, best.1);
}
now.2 < best.2 - EPS
}
type Score = (usize, usize, f64);
fn score(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, radii: &[f64]) -> Score {
const SAME: f64 = 0.05;
let (pairs, crossings) = remaining(mol, pos, radii);
let same = pairs
.iter()
.filter(|(i, j)| pos[i].dist(pos[j]) < SAME)
.count();
let depth: f64 = pairs
.iter()
.map(|(i, j)| {
let want = radii[*i as usize] + radii[*j as usize];
let d = pos[i].dist(pos[j]);
(want - d).max(0.0).powi(2)
})
.sum();
(same, crossings.len(), depth)
}
type Trouble = (Vec<(u32, u32)>, Vec<(u32, u32)>);
fn remaining(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, radii: &[f64]) -> Trouble {
let bonded: BTreeSet<(u32, u32)> = mol
.bonds()
.iter()
.map(|b| (b.begin.min(b.end), b.begin.max(b.end)))
.collect();
let mut atoms: Vec<u32> = pos.keys().copied().collect();
atoms.sort_unstable();
let mut pairs = Vec::new();
for (k, &i) in atoms.iter().enumerate() {
for &j in &atoms[k + 1..] {
if bonded.contains(&(i, j)) {
continue;
}
let want = radii[i as usize] + radii[j as usize];
if pos[&i].dist(pos[&j]) < want {
pairs.push((i, j));
}
}
}
(pairs, crossings(mol, pos))
}
pub(crate) fn crossings(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>) -> Vec<(u32, u32)> {
let live: Vec<u32> = (0..mol.num_bonds())
.map(|i| u32::try_from(i).expect("键数超出 u32"))
.filter(|b| {
let bd = &mol.bonds()[*b as usize];
pos.contains_key(&bd.begin) && pos.contains_key(&bd.end)
})
.collect();
let mut out = Vec::new();
for (k, &b1) in live.iter().enumerate() {
for &b2 in &live[k + 1..] {
let (x, y) = (&mol.bonds()[b1 as usize], &mol.bonds()[b2 as usize]);
if segments_cross(pos[&x.begin], pos[&x.end], pos[&y.begin], pos[&y.end]) {
out.push((b1, b2));
}
}
}
out
}
struct SpiroFlip {
centre: u32,
side: Vec<u32>,
ring_nbrs: (u32, u32),
}
fn spiro_flips(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, ranks: &[u32]) -> Vec<SpiroFlip> {
let rings = omgkit_chem::sssr::ring_set(mol);
let mut out: Vec<(Vec<u32>, SpiroFlip)> = Vec::new();
for a in 0..u32::try_from(mol.num_atoms()).expect("原子数超出 u32") {
if !pos.contains_key(&a) {
continue;
}
let mut mine: Vec<&omgkit_chem::sssr::Ring> =
rings.iter().filter(|r| r.atoms.contains(&a)).collect();
if mine.len() < 2 {
continue;
}
mine.sort_by_key(|r| ring_key(r, ranks));
for r in &mine {
let set: BTreeSet<u32> = r.atoms.iter().copied().collect();
let spiro_here = mine.iter().any(|o| {
!std::ptr::eq(*o, *r) && o.atoms.iter().filter(|x| set.contains(x)).count() == 1
});
if !spiro_here {
continue;
}
let nbrs: Vec<u32> = r
.atoms
.iter()
.copied()
.filter(|x| mol.neighbors(a).any(|(n, _)| n == *x))
.collect();
if nbrs.len() != 2 {
continue;
}
if !pos.contains_key(&nbrs[0]) || !pos.contains_key(&nbrs[1]) {
continue;
}
let Some(side) = one_side(mol, pos, nbrs[0], a) else {
continue;
};
if !side.contains(&nbrs[1]) {
continue;
}
if side.contains(&a) {
continue;
}
let key: Vec<u32> = {
let mut k = vec![ranks[a as usize]];
k.extend(ring_key(r, ranks));
k
};
out.push((
key,
SpiroFlip {
centre: a,
side,
ring_nbrs: (nbrs[0], nbrs[1]),
},
));
}
}
out.sort_by(|x, y| x.0.cmp(&y.0));
out.into_iter().map(|x| x.1).collect()
}
fn ring_key(r: &omgkit_chem::sssr::Ring, ranks: &[u32]) -> Vec<u32> {
let mut k: Vec<u32> = r.atoms.iter().map(|a| ranks[*a as usize]).collect();
k.sort_unstable();
k
}
fn one_side(
mol: &MolBuilder,
pos: &BTreeMap<u32, Point2>,
start: u32,
blocked: u32,
) -> Option<Vec<u32>> {
if !pos.contains_key(&start) {
return None;
}
let mut seen: BTreeSet<u32> = BTreeSet::from([start]);
let mut stack = vec![start];
while let Some(x) = stack.pop() {
for (n, _) in mol.neighbors(x) {
if n == blocked || !pos.contains_key(&n) {
continue;
}
if seen.insert(n) {
stack.push(n);
}
}
}
Some(seen.into_iter().collect())
}
struct PairSwap {
centre: u32,
ends: (u32, u32),
sides: (Vec<u32>, Vec<u32>),
}
fn pair_swaps(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, ranks: &[u32]) -> Vec<PairSwap> {
let mut out: Vec<(Vec<u32>, PairSwap)> = Vec::new();
for c in 0..u32::try_from(mol.num_atoms()).expect("原子数超出 u32") {
if mol.degree(c) != 4 || !pos.contains_key(&c) {
continue;
}
if mol
.neighbors(c)
.any(|(_, bi)| mol.bonds()[bi as usize].flags.contains(BondFlags::IN_RING))
{
continue;
}
let mut nbrs: Vec<u32> = mol.neighbors(c).map(|(x, _)| x).collect();
nbrs.sort_by_key(|x| (ranks[*x as usize], *x));
nbrs.dedup();
if nbrs.len() != 4 {
continue;
}
for i in 0..4 {
for j in (i + 1)..4 {
let (a, b) = (nbrs[i], nbrs[j]);
let (Some(sa), Some(sb)) = (one_side(mol, pos, a, c), one_side(mol, pos, b, c))
else {
continue;
};
let set_a: BTreeSet<u32> = sa.iter().copied().collect();
if sa.contains(&c) || sb.contains(&c) || sb.iter().any(|x| set_a.contains(x)) {
continue;
}
out.push((
vec![
ranks[c as usize],
ranks[a as usize].min(ranks[b as usize]),
ranks[a as usize].max(ranks[b as usize]),
],
PairSwap {
centre: c,
ends: (a, b),
sides: (sa, sb),
},
));
}
}
}
out.sort_by(|x, y| x.0.cmp(&y.0));
out.into_iter().map(|x| x.1).collect()
}
type SplayKey = (usize, usize, i64, u32, u32, bool);
struct Splay {
pivot: u32,
moved: Vec<u32>,
by: f64,
toward: bool,
}
const SPLAY: f64 = std::f64::consts::FRAC_PI_6;
const ANGLE_FLOOR_DEG: f64 = 89.0;
fn q6(x: f64) -> i64 {
(x * 1e6).round() as i64
}
fn angle_survives(before: f64, after: f64) -> bool {
q6(after) > q6(ANGLE_FLOOR_DEG) || q6(after) >= q6(before)
}
fn narrowest_at(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, p: u32) -> f64 {
let nb: Vec<u32> = mol
.neighbors(p)
.map(|(n, _)| n)
.filter(|n| pos.contains_key(n))
.collect();
let Some(c) = pos.get(&p) else {
return 180.0;
};
let mut worst = 180.0f64;
for i in 0..nb.len() {
for j in (i + 1)..nb.len() {
if mol.neighbors(nb[i]).any(|(n, _)| n == nb[j]) {
continue; }
let u = (pos[&nb[i]] - *c).normalized();
let v = (pos[&nb[j]] - *c).normalized();
worst = worst.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees());
}
}
worst
}
fn is_linear_centre(mol: &MolBuilder, a: u32) -> bool {
if mol.degree(a) != 2 {
return false;
}
let mut doubles = 0;
let mut triple = false;
for (_, bi) in mol.neighbors(a) {
match mol.bonds()[bi as usize].order {
BondOrder::Triple => triple = true,
BondOrder::Double => doubles += 1,
_ => {}
}
}
triple || doubles >= 2
}
fn splays(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>, ranks: &[u32]) -> Vec<Splay> {
let mut out = Vec::new();
for b in 0..u32::try_from(mol.num_bonds()).expect("键数超出 u32") {
let bd = &mol.bonds()[b as usize];
if bd.flags.contains(BondFlags::IN_RING)
|| !pos.contains_key(&bd.begin)
|| !pos.contains_key(&bd.end)
{
continue;
}
let Some(moved) = far_side(mol, pos, b, ranks) else {
continue;
};
let (pivot, root) = if moved.contains(&bd.end) {
(bd.begin, bd.end)
} else {
(bd.end, bd.begin)
};
if mol.degree(pivot) < 2 || is_linear_centre(mol, pivot) {
continue;
}
let c = pos[&pivot];
let u = pos[&root] - c;
let mut others: Vec<u32> = mol
.neighbors(pivot)
.map(|(n, _)| n)
.filter(|n| *n != root && pos.contains_key(n))
.collect();
others.sort_by_key(|n| (ranks[*n as usize], *n));
let Some(sign) = others
.iter()
.map(|n| u.cross(pos[n] - c))
.find(|x| x.abs() > 1e-9)
.map(f64::signum)
else {
continue; };
for toward in [false, true] {
let by = if toward { sign * SPLAY } else { -sign * SPLAY };
out.push(Splay {
pivot,
moved: moved.clone(),
by,
toward,
});
}
}
out
}
fn rotatable(mol: &MolBuilder, pos: &BTreeMap<u32, Point2>) -> Vec<u32> {
(0..mol.num_bonds())
.map(|i| u32::try_from(i).expect("键数超出 u32"))
.filter(|b| {
let bd = &mol.bonds()[*b as usize];
!bd.flags.contains(BondFlags::IN_RING)
&& mol.degree(bd.begin) > 1
&& mol.degree(bd.end) > 1
&& pos.contains_key(&bd.begin)
&& pos.contains_key(&bd.end)
})
.collect()
}
fn far_side(
mol: &MolBuilder,
pos: &BTreeMap<u32, Point2>,
b: u32,
ranks: &[u32],
) -> Option<Vec<u32>> {
let bd = &mol.bonds()[b as usize];
let (start, blocked) = (bd.end, bd.begin);
let mut seen: BTreeSet<u32> = BTreeSet::from([start]);
let mut stack = vec![start];
while let Some(a) = stack.pop() {
for (n, bi) in mol.neighbors(a) {
if bi == b || !pos.contains_key(&n) {
continue;
}
if n == blocked {
return None; }
if seen.insert(n) {
stack.push(n);
}
}
}
let mut out: Vec<u32> = seen.into_iter().collect();
out.sort_unstable();
let mut other_seen: BTreeSet<u32> = BTreeSet::from([blocked]);
let mut stack = vec![blocked];
while let Some(a) = stack.pop() {
for (n, bi) in mol.neighbors(a) {
if bi == b || !pos.contains_key(&n) {
continue;
}
if other_seen.insert(n) {
stack.push(n);
}
}
}
let mut other: Vec<u32> = other_seen.into_iter().collect();
other.sort_unstable();
debug_assert!(
{
let mut seen: BTreeSet<u32> = BTreeSet::new();
out.iter()
.chain(other.iter())
.all(|a| seen.insert(ranks[*a as usize]))
},
"规范秩有重复,平局会静默退回存储序"
);
let smallest = |v: &[u32]| v.iter().map(|a| ranks[*a as usize]).min();
let take_other = match out.len().cmp(&other.len()) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Equal => smallest(&other) < smallest(&out),
std::cmp::Ordering::Less => false,
};
Some(if take_other { other } else { out })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout;
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
omgkit_io::stereo::perceive_bond_stereo(&mut m);
m
}
fn laid(smi: &str, style: &Style) -> (MolBuilder, BTreeMap<u32, Point2>, Report) {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, style, None) {
pos.extend(p.pos);
}
let rep = relieve(&m, &mut pos, &ranks, style);
(m, pos, rep)
}
fn posed_with_cis_trans(
smi: &str,
style: &Style,
) -> (MolBuilder, BTreeMap<u32, Point2>, Vec<u32>) {
let m = prep(smi);
let ranks = crate::ranks_of(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, style, None) {
pos.extend(p.pos);
}
let mut flat = vec![Point2::ORIGIN; m.num_atoms()];
for (a, q) in &pos {
flat[*a as usize] = *q;
}
crate::stereo::fix_cis_trans(&m, &mut flat, &ranks);
for (a, q) in pos.iter_mut() {
*q = flat[*a as usize];
}
(m, pos, ranks)
}
fn laid_with_cis_trans(
smi: &str,
style: &Style,
) -> (MolBuilder, BTreeMap<u32, Point2>, Report) {
let (m, mut pos, ranks) = posed_with_cis_trans(smi, style);
let rep = relieve(&m, &mut pos, &ranks, style);
(m, pos, rep)
}
#[test]
fn two_rings_that_cis_trans_forces_onto_each_other_get_pulled_apart() {
let mut splayed_any = false;
for smi in [
"C1(/C(NC2=C(N1)C=CC=C2)=N\\C3=CC=C(C(=O)OCC)C=C3)=N/C4=CC=C(C(=O)OCC)C=C4",
"C1(/C(NC2=C(N1)C=CC=C2)=N\\C3C=CC(=CC=3)OCC)=N/C4=CC=C(C=C4)OCC",
"CCn\\1ccnc(/c1=N\\c2ccc(cc2)OC)N3CCOCC3",
] {
for style in &Style::ALL {
let (m, pos, rep) = laid_with_cis_trans(smi, style);
splayed_any |= !rep.splayed.is_empty();
let atoms: Vec<u32> = pos.keys().copied().collect();
for (k, &i) in atoms.iter().enumerate() {
for &j in &atoms[k + 1..] {
assert!(
pos[&i].dist(pos[&j]) >= 0.05,
"[{}] {smi}:原子 {i} 与 {j} 还画在同一点上",
style.name
);
}
}
let mut flat = vec![Point2::ORIGIN; m.num_atoms()];
for (a, q) in &pos {
flat[*a as usize] = *q;
}
assert!(
crate::stereo::cis_trans_intact(&m, &flat),
"[{}] {smi}:撑开之后顺反不对了",
style.name
);
for a in pos.keys() {
let deg = narrowest_at(&m, &pos, *a);
assert!(
q6(deg) >= q6(ANGLE_FLOOR_DEG),
"[{}] {smi}:原子 {a} 处的键角被压到 {deg:.1}°",
style.name
);
}
}
}
assert!(splayed_any, "一次撑开都没发生,这条判据在空过");
}
#[test]
fn a_splay_only_changes_the_angle_at_its_pivot() {
let angles = |m: &MolBuilder, pos: &BTreeMap<u32, Point2>, a: u32| -> Vec<i64> {
let nb: Vec<u32> = m.neighbors(a).map(|(n, _)| n).collect();
let c = pos[&a];
let mut v = Vec::new();
for i in 0..nb.len() {
for j in (i + 1)..nb.len() {
let u = (pos[&nb[i]] - c).normalized();
let w = (pos[&nb[j]] - c).normalized();
v.push(q6(u.dot(w).clamp(-1.0, 1.0).acos()));
}
}
v.sort_unstable();
v
};
let lengths = |m: &MolBuilder, pos: &BTreeMap<u32, Point2>| -> Vec<i64> {
let mut v: Vec<i64> = m
.bonds()
.iter()
.map(|b| q6(pos[&b.begin].dist(pos[&b.end])))
.collect();
v.sort_unstable();
v
};
let mut tried = 0usize;
for smi in ["CCc1ccccc1", "CC(C)COC(C)C", "OC(=O)c1ccccc1OC(C)=O"] {
let (m, pos, _) = laid(smi, &Style::ACS_1996);
let ranks = crate::ranks_of(&m);
for sp in splays(&m, &pos, &ranks) {
tried += 1;
let mut after = pos.clone();
let c = pos[&sp.pivot];
for a in &sp.moved {
let p = pos[a].rotated_about(c, sp.by);
after.insert(*a, p);
}
assert_eq!(
lengths(&m, &pos),
lengths(&m, &after),
"{smi}:绕 {} 撑开改了键长",
sp.pivot
);
for a in pos.keys() {
if *a == sp.pivot {
continue;
}
assert_eq!(
angles(&m, &pos, *a),
angles(&m, &after, *a),
"{smi}:绕 {} 撑开,却把原子 {a} 处的键角也改了",
sp.pivot
);
}
}
}
assert!(tried > 0, "一个撑开候选都没枚举出来,这条判据在空过");
}
#[test]
fn a_terminal_bond_is_a_real_splay_candidate() {
let (m, pos, _) = laid("CCc1ccccc1", &Style::ACS_1996);
let ranks = crate::ranks_of(&m);
let cands = splays(&m, &pos, &ranks);
assert!(!cands.is_empty(), "一个候选都没有,这条判据在空过");
let terminal = cands.iter().find(|sp| {
sp.moved.len() == 1 && m.degree(sp.moved[0]) == 1 && m.degree(sp.pivot) >= 2
});
assert!(
terminal.is_some(),
"候选里没有一个是「只动一个端基」—— 端点键被整类丢掉了"
);
let sp = terminal.expect("上面已经断言过有");
let c = pos[&sp.pivot];
let moved = pos[&sp.moved[0]].rotated_about(c, sp.by);
assert!(
moved.dist(pos[&sp.moved[0]]) > 0.1,
"端基转了 30° 却几乎没动 —— 那才真是空操作"
);
}
#[test]
fn a_splay_never_pivots_on_an_atom_that_should_be_drawn_straight() {
let mut exposed = 0usize;
for smi in ["CC#CCC", "CCC#CC#CCC", "CC=C=CCC"] {
let (m, pos0, _) = laid(smi, &Style::ACS_1996);
let ranks = crate::ranks_of(&m);
let mut pos = pos0.clone();
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
if !is_linear_centre(&m, a) {
continue;
}
if let Some((n, _)) = m.neighbors(a).next() {
let c = pos[&a];
let q = pos[&n].rotated_about(c, 40f64.to_radians());
pos.insert(n, q);
}
}
for b in rotatable(&m, &pos) {
let Some(moved) = far_side(&m, &pos, b, &ranks) else {
continue;
};
let bd = &m.bonds()[b as usize];
let (pivot, root) = if moved.contains(&bd.end) {
(bd.begin, bd.end)
} else {
(bd.end, bd.begin)
};
if !is_linear_centre(&m, pivot) {
continue;
}
let c = pos[&pivot];
let u = pos[&root] - c;
if m.neighbors(pivot)
.map(|(n, _)| n)
.filter(|n| *n != root && pos.contains_key(n))
.any(|n| u.cross(pos[&n] - c).abs() > 1e-9)
{
exposed += 1;
}
}
for sp in splays(&m, &pos, &ranks) {
assert!(
!is_linear_centre(&m, sp.pivot),
"{smi}:撑开拿 sp 原子 {} 当了支点,会把它画弯",
sp.pivot
);
}
}
assert!(
exposed > 0,
"没有一个 sp 支点是共线规则拦不住的 —— 这条判据在空过"
);
}
#[test]
fn a_splay_may_not_pinch_the_pivot_below_the_floor() {
assert!(!angle_survives(120.0, 60.0), "把 120° 压成 60° 被放行了");
assert!(!angle_survives(120.0, 88.9), "压到地板下面一点也不许");
assert!(
!angle_survives(120.0, 88.999_999_6),
"88.9999996° 被量化抹平成 89° 放行了 —— 硬判据那边是违例"
);
assert!(
angle_survives(120.0, 90.0),
"120° 变 90° 该放行 —— 地板是 89°"
);
assert!(angle_survives(120.0, 150.0), "撑宽了反倒被挡下");
assert!(angle_survives(70.0, 70.0), "本来就 70°、没更窄,却被挡下");
assert!(angle_survives(70.0, 100.0), "从 70° 撑到 100° 反倒被挡下");
assert!(!angle_survives(70.0, 69.0), "本来 70° 又压窄了,该挡下");
}
#[test]
fn the_two_splay_directions_are_named_without_looking_at_the_canvas() {
let flip = |p: Point2| Point2::new(p.x, -p.y);
let mut pairs = 0usize;
for smi in ["CCc1ccccc1", "CC(C)COC(C)C", "OC(=O)c1ccccc1OC(C)=O"] {
let (m, pos, _) = laid(smi, &Style::ACS_1996);
let ranks = crate::ranks_of(&m);
let mirrored: BTreeMap<u32, Point2> = pos.iter().map(|(a, p)| (*a, flip(*p))).collect();
let here = splays(&m, &pos, &ranks);
let there = splays(&m, &mirrored, &ranks);
assert!(!here.is_empty(), "{smi} 一个候选都没有,这一档验不了");
assert_eq!(here.len(), there.len(), "{smi}:反射之后候选数变了");
for (a, b) in here.iter().zip(&there) {
assert_eq!(
(a.pivot, &a.moved, a.toward),
(b.pivot, &b.moved, b.toward),
"{smi}:反射之后候选的次序或标法变了"
);
pairs += 1;
let (ca, cb) = (pos[&a.pivot], mirrored[&b.pivot]);
for x in &a.moved {
let got = mirrored[x].rotated_about(cb, b.by);
let want = flip(pos[x].rotated_about(ca, a.by));
assert!(
got.dist(want) < 1e-9,
"{smi}:绕 {} 撑开,反射之后落点对不上 —— 转向的命名看了画布",
a.pivot
);
}
}
}
assert!(pairs > 0, "一对都没比,这条判据在空过");
}
#[test]
fn a_splay_is_only_spent_on_atoms_drawn_on_top_of_each_other() {
let mut checked = 0usize;
for smi in [
"C1(C(N2C=CC=CC(=NC=1O)2)=O)SC(=S)N(C)C",
"c1ccc(c(c1)/C=N\\[C@@H]2CONC2=O)O",
"C1CN[Ni]23(N1)(NCCN2)NCCN3",
"[O-][N+](=O)C1=CC(=C(NC2=C(C=C(C=C2[N+]([O-])=O)[N+]([O-])=O)\
[N+]([O-])=O)C(=C1)[N+]([O-])=O)[N+]([O-])=O",
] {
for style in &Style::ALL {
let (m, mut pos, ranks) = posed_with_cis_trans(smi, style);
let radii = radii(&m, style);
let entry = score(&m, &pos, &radii);
assert_eq!(
entry.0, 0,
"[{}] {smi} 进消冲突时就有原子叠着 —— 闸门本来就该开,选错例子了",
style.name
);
assert!(
entry != (0, 0, 0.0),
"[{}] {smi} 进消冲突时打分是 (0,0,0),消冲突会在入口返回 —— \
算子循环一次都不跑,这条判据是空过的",
style.name
);
checked += 1;
let rep = relieve(&m, &mut pos, &ranks, style);
assert!(
rep.splayed.is_empty(),
"[{}] {smi}:没有一对原子叠着,却撑开了 {:?}",
style.name,
rep.splayed
);
}
}
assert!(checked > 0, "一个例子都没查,这条判据在空过");
}
#[test]
fn the_aspirin_overlap_is_gone() {
let (m, pos, rep) = laid("OC(=O)c1ccccc1OC(C)=O", &Style::ACS_1996);
let n = m.num_atoms();
for i in 0..n {
for j in (i + 1)..n {
let (a, b) = (i as u32, j as u32);
assert!(pos[&a].dist(pos[&b]) > 0.3, "原子 {a} 与 {b} 仍然几乎重合");
}
}
let mut before: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(
&m,
&omgkit_io::canon::canonical_ranks(&m),
&Style::ACS_1996,
None,
) {
before.extend(p.pos);
}
for i in 0..n {
for j in (i + 1)..n {
let (a, b) = (i as u32, j as u32);
assert!(
before[&a].dist(before[&b]) > 0.3,
"布局阶段原子 {a} 与 {b} 就重合了 —— 上游那个「挑空的一侧」没起作用"
);
}
}
let _ = &rep;
}
const SPIRO: [&str; 8] = [
"c1ccc2c(c1)C3C[C@@]4(C2c5c3cccc5)C=CS4(=O)=O",
"c1ccc2c(c1)CN(C(=[NH2+])C23CCOCC3)N",
"C1COC2(O1)[C@@]3(C[C@@]3(C(=[NH+]2)N)C#N)C#N",
"Cc1cccc(c1)NC2=C(C(=O)NC3(S2)CCCC3)C#N",
"Cc1ccccc1[C@@H]2[C@@H]([C@@]23C(=NN=C3O)N)C#N",
"N1=C(SC2(CCCCC2)C3=C1CCCC3)N",
"C1CC2(CC1)CCCC2",
"C[C@@]12C(=C[C@@H](O1)C(=O)C23CC3)C(=O)OC",
];
const SWAP: [&str; 6] = [
"[O-][N+](=O)O[Cu](O[N+]([O-])=O)([N+]1=C2C=CC=CC2=CC=C1)[N+]3=C4C=CC=CC4=CC=C3",
"[O-]S([O-])(=O)=O.C1CN[Cr+3]23(N1)(NCCN2)NCCN3",
"BrC1=CC=C(NC(=N)NC(=N)NC2=CC=C(C=C2)S(=O)(=O)NC3=CN=CC=N3)C=C1",
"C([C@@H]1[C@H]([C@@H]([C@@H]([C@@H]([NH2+]1)S(=O)(=O)[O-])O)O)O)O",
"C[C](O)([CH](C(O)=O)C1=CC=CC=C1)C2=CC=CC=C2",
"C[C]1(CS(O)(=O)=O)[CH]2CC[C]1(C)C(=O)[CH]2Br",
];
#[test]
fn a_deg4_swap_is_exactly_isometric_and_actually_fires() {
let lengths = |m: &MolBuilder, pos: &BTreeMap<u32, Point2>| -> Vec<i64> {
let mut v: Vec<i64> = m
.bonds()
.iter()
.filter_map(|b| {
let (u, v) = (pos.get(&b.begin)?, pos.get(&b.end)?);
Some((u.dist(*v) * 1e9).round() as i64)
})
.collect();
v.sort_unstable();
v
};
let mut fired = 0usize;
for smi in SWAP {
for style in &Style::ALL {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, style, None) {
pos.extend(p.pos);
}
let before = lengths(&m, &pos);
let rep = relieve(&m, &mut pos, &ranks, style);
let after = lengths(&m, &pos);
if !rep.swapped.is_empty() {
fired += 1;
}
assert_eq!(
before, after,
"[{}] {smi}:消冲突改变了键长 —— 用的算子不是等距的",
style.name
);
}
}
assert!(
fired > 0,
"这批分子上度 4 置换一次都没触发 —— 那它是白加的,上面那条键长判据也是恒真的"
);
}
#[test]
fn a_deg4_swap_never_tears_the_structure_apart() {
for smi in SWAP
.iter()
.chain(["C1CC2(CC1)CCCC2", "CC(C)(C)C(C)(C)C", "C1CCC2(CC1)CCCCC2"].iter())
{
for style in &Style::ALL {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, style, None) {
pos.extend(p.pos);
}
for sw in pair_swaps(&m, &pos, &ranks) {
let a: BTreeSet<u32> = sw.sides.0.iter().copied().collect();
let b: BTreeSet<u32> = sw.sides.1.iter().copied().collect();
let both: Vec<u32> = a.intersection(&b).copied().collect();
assert!(
both.is_empty(),
"[{}] {smi}:中心 {} 的两个分支重叠在 {both:?} 上",
style.name,
sw.centre
);
assert!(
!a.contains(&sw.centre) && !b.contains(&sw.centre),
"[{}] {smi}:分支里含了中心原子 {}",
style.name,
sw.centre
);
}
}
}
}
#[test]
fn a_spiro_flip_is_exactly_isometric_and_actually_fires() {
let lengths = |m: &MolBuilder, pos: &BTreeMap<u32, Point2>| -> Vec<i64> {
let mut v: Vec<i64> = m
.bonds()
.iter()
.filter_map(|b| {
let (u, v) = (pos.get(&b.begin)?, pos.get(&b.end)?);
Some((u.dist(*v) * 1e9).round() as i64)
})
.collect();
v.sort_unstable();
v
};
let mut fired = 0usize;
for smi in SPIRO {
for style in &Style::ALL {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, style, None) {
pos.extend(p.pos);
}
let before = lengths(&m, &pos);
let rep = relieve(&m, &mut pos, &ranks, style);
let after = lengths(&m, &pos);
if !rep.spiro_flipped.is_empty() {
fired += 1;
}
assert_eq!(
before, after,
"[{}] {smi}:消冲突改变了键长 —— 用的算子不是等距的",
style.name
);
}
}
assert!(
fired > 0,
"这批螺环分子上一次都没翻过 —— 那这个算子是白加的,上面那条键长判据也就是恒真的"
);
}
#[test]
fn a_spiro_flip_does_not_depend_on_how_the_molecule_was_written() {
let mut checked = 0usize;
for smi in SPIRO {
for style in &Style::ALL {
let m = prep(smi);
let n = m.num_atoms();
let want = crate::generate(&m, style);
let priority: Vec<u32> = (0..n)
.map(|i| u32::try_from(n - 1 - i).expect("原子数超出 u32"))
.collect();
let w = omgkit_io::smiles::write_with_priority(&m, &priority);
let Some(m2) = omgkit_io::smiles::parse(&w.smiles)
.ok()
.and_then(|mut x| omgkit_chem::pipeline::sanitize(&mut x).ok().map(|()| x))
else {
continue;
};
if omgkit_io::canon::canonical_smiles(&m).smiles
!= omgkit_io::canon::canonical_smiles(&m2).smiles
{
continue; }
checked += 1;
let got = crate::generate(&m2, style);
let q = |c: &[Point2]| {
let mut v: Vec<(i64, i64)> = c
.iter()
.map(|p| ((p.x * 1e4).round() as i64, (p.y * 1e4).round() as i64))
.collect();
v.sort_unstable();
v
};
assert_eq!(
q(&want.coords),
q(&got.coords),
"[{}] {smi}:换成 {} 之后画出来不一样了",
style.name,
w.smiles
);
}
}
assert!(checked > 0, "一次都没比成,判据空过了");
}
#[test]
fn flipping_keeps_every_bond_exactly_one_unit() {
for smi in [
"OC(=O)c1ccccc1OC(C)=O",
"CC(C)(C)c1ccccc1C(C)(C)C",
"CCCCCCCC",
] {
let (m, pos, _) = laid(smi, &Style::ACS_1996);
for b in m.bonds() {
let d = pos[&b.begin].dist(pos[&b.end]);
assert!(
(d - 1.0).abs() < 1e-9,
"{smi} 键 {}–{} 长 {d}",
b.begin,
b.end
);
}
}
}
#[test]
fn which_side_gets_flipped_does_not_depend_on_how_it_was_written() {
type Table = std::collections::BTreeMap<(u32, u32), BTreeSet<u32>>;
let table = |smi: &str| -> Table {
let m = prep(smi);
let ranks = crate::ranks_of(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, &Style::ACS_1996, None) {
pos.extend(p.pos);
}
let mut t = Table::new();
for b in 0..u32::try_from(m.num_bonds()).unwrap() {
let Some(side) = far_side(&m, &pos, b, &ranks) else {
continue;
};
let bd = &m.bonds()[b as usize];
let (x, y) = (ranks[bd.begin as usize], ranks[bd.end as usize]);
t.insert(
(x.min(y), x.max(y)),
side.iter().map(|a| ranks[*a as usize]).collect(),
);
}
t
};
for ws in [
vec!["CCCC", "C(CC)C"],
vec!["CCCOS(O)(=O)=O", "OS(=O)(=O)OCCC"],
vec!["CCCCS(O)(=O)=O", "OS(=O)(=O)CCCC"],
vec!["CC(=C)CS(O)(=O)=O", "OS(=O)(=O)CC(=C)C"],
vec!["CCCCCOS(O)(=O)=O", "OS(=O)(=O)OCCCCC"],
] {
let seq = |s: &str| -> Vec<(u32, u32)> {
prep(s).bonds().iter().map(|b| (b.begin, b.end)).collect()
};
assert_ne!(seq(ws[0]), seq(ws[1]), "{} 与 {} 存储序一样", ws[0], ws[1]);
let t0 = table(ws[0]);
assert!(!t0.is_empty(), "{} 一根可翻的键都没有,验不了东西", ws[0]);
assert_eq!(
t0,
table(ws[1]),
"{} 与 {}:同一根键翻的不是同一侧",
ws[0],
ws[1]
);
}
}
#[test]
fn a_flip_never_reaches_into_another_fragment() {
let mut checked = 0usize;
for smi in [
"[Na+].[Cl-]",
"CCCCCCCC.c1ccccc1",
"CC(=O)Oc1ccccc1C(=O)O.CC(C)(C)c1ccccc1",
"[O-]S([O-])(=O)=O.CCCCCCCCCC",
"c1ccccc1.c1ccccc1.CCCC",
] {
for style in &Style::ALL {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, style, None) {
pos.extend(p.pos);
}
let n = u32::try_from(m.num_atoms()).unwrap();
let mut comp = vec![usize::MAX; n as usize];
let mut c = 0usize;
for s in 0..n {
if comp[s as usize] != usize::MAX {
continue;
}
let mut st = vec![s];
comp[s as usize] = c;
while let Some(x) = st.pop() {
for (y, _) in m.neighbors(x) {
if comp[y as usize] == usize::MAX {
comp[y as usize] = c;
st.push(y);
}
}
}
c += 1;
}
for b in 0..u32::try_from(m.num_bonds()).unwrap() {
let Some(side) = far_side(&m, &pos, b, &ranks) else {
continue;
};
checked += 1;
let want = comp[m.bonds()[b as usize].begin as usize];
let spans: BTreeSet<usize> = side.iter().map(|a| comp[*a as usize]).collect();
assert!(
spans.len() == 1 && spans.contains(&want),
"[{}] {smi}:翻键 {b} 会动到别的片段(涉及分量 {spans:?},键在 {want})",
style.name
);
}
}
}
assert!(checked > 0, "一根可翻的键都没有,判据空过了");
}
#[test]
fn a_ring_bond_is_never_flipped() {
let m = prep("c1ccccc1");
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, &Style::ACS_1996, None) {
pos.extend(p.pos);
}
for b in 0..u32::try_from(m.num_bonds()).unwrap() {
assert!(
far_side(&m, &pos, b, &ranks).is_none(),
"环上的键 {b} 不该给出可翻的一侧"
);
}
}
#[test]
fn a_phantom_ring_is_worse_than_any_number_of_crossings() {
assert!(
!better((1, 0, 0.0), (0, 5, 9.9)),
"拿一个假环换掉五处交叉被判成了改善"
);
assert!(
better((0, 5, 9.9), (1, 0, 0.0)),
"从假环换成五处交叉反倒没被判成改善"
);
assert!(better((1, 0, 0.0), (1, 1, 0.0)), "重合相同时交叉少的该赢");
assert!(
better((1, 1, 0.0), (1, 1, 1.0)),
"重合与交叉都相同时深度小的该赢"
);
}
#[test]
fn what_cannot_be_fixed_is_reported_not_hidden() {
let (_, _, rep) = laid(
"[O-][N+](=O)O[Ce](O[N+]([O-])=O)(O[N+]([O-])=O)(O[N+]([O-])=O)\
(O[N+]([O-])=O)O[N+]([O-])=O",
&Style::ACS_1996,
);
assert!(
!rep.unresolved.is_empty(),
"六个硝酸根挤在一个铈上不可能全排开,却报告说没有冲突"
);
}
#[test]
fn the_two_styles_can_disagree_about_whether_it_clashes() {
let m = prep("OC(=O)c1ccccc1OC(C)=O");
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
for p in layout::layout_all(&m, &ranks, &Style::ACS_1996, None) {
pos.extend(p.pos);
}
let acs = remaining(&m, &pos, &radii(&m, &Style::ACS_1996)).0.len();
let cd = remaining(&m, &pos, &radii(&m, &Style::CHEMDRAW_DEFAULT))
.0
.len();
assert!(
acs >= cd,
"ACS 的标签更大,判出的碰撞不该少于 ChemDraw 默认:{acs} vs {cd}"
);
assert!(
radii(&m, &Style::ACS_1996)[0] > radii(&m, &Style::CHEMDRAW_DEFAULT)[0],
"ACS 的碰撞半径应当更大"
);
}
}