use std::collections::BTreeMap;
use omgkit_core::{BondOrder, MolBuilder};
use crate::geom::{segments_cross, Point2, BOND_LEN};
use crate::style::Style;
const SEED_ANGLE: f64 = std::f64::consts::FRAC_PI_6;
type Cost = (usize, u8, u8, i64);
pub(crate) type Block = BTreeMap<u32, Point2>;
pub(crate) struct Placed {
pub atom: u32,
pub at: Point2,
pub zig: i8,
pub block: Option<Block>,
}
pub(crate) struct Env<'a> {
pub mol: &'a MolBuilder,
pub ranks: &'a [u32],
pub style: &'a Style,
pub radii: &'a [f64],
pub bonded: &'a std::collections::BTreeSet<(u32, u32)>,
pub off_grid: &'a std::collections::BTreeSet<u32>,
pub blocks: &'a BTreeMap<u32, Block>,
}
pub(crate) fn place_neighbours(
env: &Env<'_>,
a: u32,
pos: &BTreeMap<u32, Point2>,
todo: &[u32],
zig: i8,
) -> Vec<Placed> {
let (mol, ranks, style) = (env.mol, env.ranks, env.style);
if todo.is_empty() {
return Vec::new();
}
let center = pos[&a];
const QUANT: f64 = 1e9;
let mut occ: Vec<((i64, u32), f64)> = mol
.neighbors(a)
.filter_map(|(n, _)| {
pos.get(&n).map(|p| {
let t = (*p - center).angle().rem_euclid(std::f64::consts::TAU);
let t = if std::f64::consts::TAU - t < 1e-9 {
0.0
} else {
t
};
#[allow(clippy::cast_possible_truncation)]
(((t * QUANT).round() as i64, ranks[n as usize]), t)
})
})
.collect();
occ.sort_unstable_by_key(|x| x.0);
let occupied: Vec<f64> = occ.iter().map(|x| x.1).collect();
let ideal = ideal_angle(mol, a, style);
let mut dirs = allocate(&occupied, todo.len(), ideal, zig);
let mut taken: Vec<(u32, Point2)> = pos.iter().map(|(k, v)| (*k, *v)).collect();
let mut drawn: Vec<(Point2, Point2)> = mol
.bonds()
.iter()
.filter_map(|b| Some((*pos.get(&b.begin)?, *pos.get(&b.end)?)))
.collect();
if occupied.len() == 1 && !todo.is_empty() {
#[allow(clippy::cast_possible_truncation)]
let crowd = |ds: &[f64]| -> i64 {
let mut sum = 0.0_f64;
for t in ds {
let p = center + Point2::new(BOND_LEN, 0.0).rotated(*t);
for q in pos.values() {
sum += 1.0 / (p.dist(*q).powi(2) + 1e-6);
}
}
(sum * 1e6).round() as i64
};
let mirror: Vec<f64> = dirs.iter().map(|t| 2.0 * occupied[0] - t).collect();
if crowd(&mirror) < crowd(&dirs) {
dirs = mirror;
}
}
debug_assert_eq!(dirs.len(), todo.len(), "方向数必须与待放邻居数相等");
let mut out = Vec::with_capacity(todo.len());
let mut occ_live = occupied.clone();
let pinch_floor =
(mol.degree(a) <= 3 && !env.off_grid.contains(&a)).then(|| 89f64.to_radians());
for (&atom, theta) in todo.iter().zip(dirs) {
let look = Lookahead {
env,
atom,
local: env.blocks.get(&atom),
};
let (theta, block) =
free_direction(center, theta, &occ_live, &taken, &drawn, &look, pinch_floor);
occ_live.push(theta);
let at = center + Point2::new(BOND_LEN, 0.0).rotated(theta);
taken.push((atom, at));
drawn.push((center, at));
if let Some(b) = &block {
for (k, p) in b {
if *k != atom {
taken.push((*k, *p));
}
}
for bd in mol.bonds() {
if let (Some(u), Some(v)) = (b.get(&bd.begin), b.get(&bd.end)) {
drawn.push((*u, *v));
}
}
}
out.push(Placed {
atom,
at,
zig: -zig,
block,
});
}
out
}
struct Lookahead<'a> {
env: &'a Env<'a>,
atom: u32,
local: Option<&'a Block>,
}
impl Lookahead<'_> {
fn cost(
&self,
center: Point2,
at: Point2,
taken: &[(u32, Point2)],
) -> ((usize, i64), Option<Block>) {
let Some(local) = self.local else {
let one: Block = [(self.atom, at)].into_iter().collect();
return (block_cost(self.env, &one, taken), None);
};
let dir = (at - center).normalized();
let mut best: Option<((usize, i64), Block)> = None;
for cand in crate::rings::place_candidates(self.env.mol, local, self.atom, at, dir) {
let c = block_cost(self.env, &cand, taken);
let better = match &best {
None => true,
Some((old, _)) => c < *old,
};
if better {
best = Some((c, cand));
}
}
let (c, cand) = best.expect("`place_candidates` 恒返回两个候选");
(c, Some(cand))
}
}
fn block_cost(env: &Env<'_>, cand: &Block, taken: &[(u32, Point2)]) -> (usize, i64) {
const SAME: f64 = 0.05;
let mut same = 0usize;
let mut parts: Vec<f64> = Vec::new();
for (i, p) in cand {
for (j, q) in taken {
if i == j || env.bonded.contains(&((*i).min(*j), (*i).max(*j))) {
continue;
}
let d = p.dist(*q);
if d < SAME {
same += 1;
}
let want = env.radii[*i as usize] + env.radii[*j as usize];
if d < want {
parts.push((want - d).powi(2));
}
}
}
parts.sort_by(f64::total_cmp);
let depth: f64 = parts.iter().sum();
#[allow(clippy::cast_possible_truncation)]
let q = (depth * 1e9).round() as i64;
(same, q)
}
fn free_direction(
center: Point2,
ideal: f64,
occupied: &[f64],
taken: &[(u32, Point2)],
drawn: &[(Point2, Point2)],
look: &Lookahead<'_>,
pinch_floor: Option<f64>,
) -> (f64, Option<Block>) {
const STEP: f64 = std::f64::consts::FRAC_PI_6;
const TOL: f64 = 0.1;
let at = |t: f64| center + Point2::new(BOND_LEN, 0.0).rotated(t);
let clear = |t: f64| {
let p = at(t);
!taken.iter().any(|(_, q)| p.dist(*q) < TOL)
};
let narrowest = |t: f64| {
occupied
.iter()
.map(|o| {
let d = (t - o).rem_euclid(std::f64::consts::TAU);
d.min(std::f64::consts::TAU - d)
})
.fold(std::f64::consts::PI, f64::min)
};
let uncrossed = |t: f64| {
let p = at(t);
!drawn.iter().any(|(u, v)| segments_cross(center, p, *u, *v))
};
let mut ranked: Vec<(u32, i64, f64)> = vec![(0, 0, ideal)];
for k in 1..=5u32 {
for sign in [1.0, -1.0] {
let t = ideal + STEP * f64::from(k) * sign;
#[allow(clippy::cast_possible_truncation)]
let wide = -(narrowest(t) * 1e6).round() as i64; ranked.push((k, wide, t));
}
}
ranked.sort_by_key(|c| (c.0, c.1));
let cands: Vec<f64> = ranked.into_iter().map(|c| c.2).collect();
let pick = |pred: &dyn Fn(f64) -> bool| -> Option<(f64, Option<Block>)> {
let mut best: Option<(Cost, f64, Option<Block>)> = None;
for &t in cands.iter().filter(|t| pred(**t)) {
let ((same, depth), block) = look.cost(center, at(t), taken);
#[allow(clippy::cast_possible_truncation)]
let q = |x: f64| (x * 1e6).round() as i64;
let pinch = u8::from(pinch_floor.is_some_and(|f| q(narrowest(t)) < q(f)));
let key = (same, pinch, u8::from(!uncrossed(t)), depth);
let better = match &best {
None => true,
Some((old, _, _)) => key < *old,
};
if better {
let done = key == (0, 0, 0, 0);
best = Some((key, t, block));
if done {
break; }
}
}
best.map(|(_, t, block)| (t, block))
};
if let Some(hit) = pick(&|t| clear(t)) {
return hit;
}
let (_, block) = look.cost(center, at(ideal), taken);
(ideal, block)
}
fn ideal_angle(mol: &MolBuilder, a: u32, style: &Style) -> f64 {
let mut doubles = 0usize;
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,
_ => {}
}
}
if triple || doubles >= 2 {
return std::f64::consts::PI;
}
let degree = mol.degree(a);
if degree <= 3 {
style.chain_angle_deg.to_radians()
} else {
std::f64::consts::TAU / degree as f64
}
}
fn allocate(occupied: &[f64], n: usize, ideal: f64, zig: i8) -> Vec<f64> {
let sign = if zig >= 0 { 1.0 } else { -1.0 };
match occupied.len() {
0 => (0..n).map(|k| SEED_ANGLE + ideal * k as f64).collect(),
1 => {
let base = occupied[0];
(0..n)
.map(|k| {
let step = (k as f64 / 2.0).floor() + 1.0;
let s = if k % 2 == 0 { sign } else { -sign };
base + s * ideal * step
})
.collect()
}
_ => {
let (start, gap) = largest_gap(occupied);
(0..n)
.map(|k| start + gap * (k as f64 + 1.0) / (n as f64 + 1.0))
.collect()
}
}
}
fn largest_gap(sorted: &[f64]) -> (f64, f64) {
const QUANT: f64 = 1e9;
#[allow(clippy::cast_possible_truncation)]
let q = |x: f64| (x * QUANT).round() as i64;
let n = sorted.len();
debug_assert!(n >= 2);
let mut cands: Vec<(i64, i64, f64, f64)> = Vec::with_capacity(n);
let wrap_start = sorted[n - 1];
let wrap = sorted[0] + std::f64::consts::TAU - wrap_start;
cands.push((q(wrap), q(wrap_start), wrap_start, wrap));
for i in 0..n - 1 {
let g = sorted[i + 1] - sorted[i];
cands.push((q(g), q(sorted[i]), sorted[i], g));
}
cands.sort_by_key(|c| (std::cmp::Reverse(c.0), c.1));
let c = cands[0];
(c.2, c.3)
}
#[cfg(test)]
mod tests_ordering {
use crate::style::Style;
fn prep(smi: &str) -> omgkit_core::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 narrowest(smi: &str, style: &Style) -> (f64, usize) {
let m = prep(smi);
let d = crate::generate(&m, style);
let g = d.drawn(&m);
let mut worst = 180.0f64;
for a in 0..u32::try_from(g.num_atoms()).expect("原子数超出 u32") {
let nb: Vec<u32> = g.neighbors(a).map(|(n, _)| n).collect();
if !(2..=3).contains(&nb.len()) {
continue;
}
for i in 0..nb.len() {
for j in (i + 1)..nb.len() {
if g.neighbors(nb[i]).any(|(n, _)| n == nb[j]) {
continue;
}
let u = (d.coords[nb[i] as usize] - d.coords[a as usize]).normalized();
let v = (d.coords[nb[j] as usize] - d.coords[a as usize]).normalized();
worst = worst.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees());
}
}
}
(worst, d.crossings.len())
}
#[test]
fn a_pinched_angle_outranks_a_bond_crossing() {
for style in &Style::ALL {
let (worst, cross) = narrowest("CCC(CC)C(O)(C(CC)CC)C(O)=O", style);
assert!(
worst >= 89.0,
"[{}] 键角被压到 {worst:.1}° —— 60° 的拐角看着像个三元环",
style.name
);
assert_eq!(cross, 0, "[{}] 这张图本不该有交叉", style.name);
}
}
#[test]
fn a_bond_crossing_still_counts_it_is_only_ranked_lower() {
for style in &Style::ALL {
let (_, cross) = narrowest(
"c1cc(oc1)C2=[N+]([C@@H]3CCCC[C@@H]3[N+](=C2)[O-])[O-]",
style,
);
assert_eq!(
cross, 0,
"[{}] 交叉那一位从排序键里掉了 —— 这张图多出了交叉",
style.name
);
}
}
#[test]
fn a_relaxed_ring_system_does_not_get_the_ninety_degree_floor() {
for style in &Style::ALL {
let smi = "C[C@@H]1[C@H]2[C@H]3C[C@@H](O1)O[C@@H]2OC=C3C(=O)OC";
let m = prep(smi);
let d = crate::generate(&m, style);
assert!(
!d.degraded.is_empty(),
"[{}] 这个分子的布局没退化 —— 选错例子了",
style.name
);
assert_eq!(
d.crossings.len(),
0,
"[{}] 松弛出来的坐标上守了 89° 地板,换来一处交叉",
style.name
);
}
}
}
#[cfg(test)]
mod tests {
use super::largest_gap;
fn rings_hanging_off_one_atom(m: &omgkit_core::MolBuilder) -> usize {
let systems = omgkit_chem::rings::fused_ring_systems(m);
(0..u32::try_from(m.num_atoms()).expect("原子数超出 u32"))
.filter(|a| !systems.iter().any(|s| s.contains(a)))
.map(|a| {
systems
.iter()
.filter(|s| m.neighbors(a).any(|(n, _)| s.contains(&n)))
.count()
})
.max()
.unwrap_or(0)
}
fn drawn_without_overlap(smi: &str, want_rings: usize) {
let mut m = omgkit_io::smiles::parse(smi).expect("SMILES 该能解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("该能 sanitize");
let n = rings_hanging_off_one_atom(&m);
assert!(
n >= want_rings,
"{smi} 只有一个原子外接 {n} 个环系,少于 {want_rings} —— 这条判据空过了"
);
for style in &crate::style::Style::ALL {
let d = crate::generate(&m, style);
for i in 0..d.coords.len() {
for j in (i + 1)..d.coords.len() {
let dist = d.coords[i].dist(d.coords[j]);
assert!(
dist >= 0.05,
"{}:原子 {i} 与 {j} 相距 {dist:.4} 个键长",
style.name
);
}
}
}
}
#[test]
fn two_labels_on_the_same_atom_do_not_get_stacked_on_each_other() {
for smi in [
"Cc1ccc(cc1)S(=O)(=O)N(Cl)Cl",
"CCOC(=O)CNS(=O)(=O)C1=CC=CC=C1",
"CS(=O)(=O)OCCCCCCCCCOS(C)(=O)=O",
] {
labels_do_not_overlap(smi);
}
}
#[test]
fn dodging_a_label_must_not_pinch_the_bond_angle() {
let smi = "[O-][N+](=O)C1=CC(=C(S[CH]2CCCC[CH]2Cl)C=C1)[N+]([O-])=O";
let mut m = omgkit_io::smiles::parse(smi).expect("SMILES 该能解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("该能 sanitize");
for style in &crate::style::Style::ALL {
let d = crate::generate(&m, style);
let grown = d.drawn(&m);
let mut looked = 0usize;
for a in 0..u32::try_from(grown.num_atoms()).expect("原子数超出 u32") {
let nbrs: Vec<u32> = grown.neighbors(a).map(|(n, _)| n).collect();
if nbrs.len() < 2 || nbrs.len() > 3 {
continue;
}
let c = d.coords[a as usize];
for i in 0..nbrs.len() {
for j in (i + 1)..nbrs.len() {
if grown.neighbors(nbrs[i]).any(|(n, _)| n == nbrs[j]) {
continue;
}
looked += 1;
let u = (d.coords[nbrs[i] as usize] - c).normalized();
let v = (d.coords[nbrs[j] as usize] - c).normalized();
let deg = u.dot(v).clamp(-1.0, 1.0).acos().to_degrees();
assert!(
deg >= 89.0,
"{}:原子 {a} 处 {}–{a}–{} 的夹角只有 {deg:.1}°",
style.name,
nbrs[i],
nbrs[j]
);
}
}
}
assert!(looked > 0, "{}:一对键角都没查到,判据空过了", style.name);
}
}
fn labels_do_not_overlap(smi: &str) {
let mut m = omgkit_io::smiles::parse(smi).expect("SMILES 该能解析");
omgkit_chem::pipeline::sanitize(&mut m).expect("该能 sanitize");
for style in &crate::style::Style::ALL {
let d = crate::generate(&m, style);
let grown = d.drawn(&m);
let mut geminal = 0usize;
for a in 0..u32::try_from(grown.num_atoms()).expect("原子数超出 u32") {
let labelled: Vec<u32> = grown
.neighbors(a)
.map(|(n, _)| n)
.filter(|n| crate::render::label_at(&grown, *n, style, &d.coords).is_some())
.collect();
geminal += labelled.len() * labelled.len().saturating_sub(1) / 2;
for i in 0..labelled.len() {
for j in (i + 1)..labelled.len() {
let (x, y) = (labelled[i], labelled[j]);
let la =
crate::render::label_at(&grown, x, style, &d.coords).expect("刚才筛过");
let lb =
crate::render::label_at(&grown, y, style, &d.coords).expect("刚才筛过");
let dv = d.coords[x as usize] - d.coords[y as usize];
assert!(
dv.x.abs() >= la.half_w + lb.half_w
|| dv.y.abs() >= la.half_h + lb.half_h,
"{}:{smi} 的原子 {x} 与 {y} 挂在 {a} 上,两个标签盒叠着",
style.name
);
}
}
}
assert!(
geminal >= 2,
"{smi} 只有 {geminal} 对同枢纽的带标签原子,判据空过"
);
}
}
#[test]
fn a_ring_hanging_off_a_bond_is_not_dropped_onto_its_neighbour() {
drawn_without_overlap(
"N#CS[Co](SC#N)([N+]1=CC=CC=C1)([N+]2=CC=CC=C2)([N+]3=CC=CC=C3)[N+]4=CC=CC=C4",
4,
);
}
#[test]
fn how_many_atoms_land_on_top_of_each_other_outranks_how_deep_they_press() {
drawn_without_overlap(
"CC1=[N+](C=CC=C1)[Co](SC#N)(SC#N)([N+]2=C(C)C=CC=C2)([N+]3=C(C)C=CC=C3)\
[N+]4=C(C)C=CC=C4",
4,
);
}
#[test]
fn when_nothing_is_clean_the_least_bad_direction_wins_instead_of_the_ideal_one() {
drawn_without_overlap("O=C(C1=CC=CC=C1)C(C2=CC=CC=C2)(C3=CC=CC=C3)C4=CC=CC=C4", 3);
}
fn shuffled(n: usize, seed: u64) -> Vec<u32> {
let mut s = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut next = || {
s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = s;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
let mut v: Vec<u32> = (0..u32::try_from(n).unwrap()).collect();
for i in (1..n).rev() {
let j = (next() % (i as u64 + 1)) as usize;
v.swap(i, j);
}
v
}
#[test]
fn the_same_direction_always_gets_the_same_angle() {
for smi in [
"C[C]1(CCC[C]2(C)[CH]1CCC3=C2C=C(O)C=C3)C(O)=O",
"C[C]1(CC[CH]2C(=C1)CC[CH]3[C]2(C)CCC[C]3(C)C(O)=O)C=C",
"CC(C)C1=CC[CH]2C(=C1)CC[CH]3[C]2(C)CCC[C]3(C)C(O)=O",
] {
let mut m = crate::tests_prep(smi);
omgkit_io::stereo::perceive_bond_stereo(&mut m);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let fp = |x: &MolBuilder, r: &[u32]| {
let c = crate::generate(x, &crate::style::Style::ACS_1996).coords;
let mut v: Vec<(u32, i64, i64)> = (0..c.len())
.map(|i| {
(
r[i],
(c[i].x * 1e4).round() as i64,
(c[i].y * 1e4).round() as i64,
)
})
.collect();
v.sort_unstable();
v
};
let want = fp(&m, &ranks);
let mut compared = 0usize;
for seed in 0..16u64 {
let w = omgkit_io::smiles::write_with_priority(&m, &shuffled(m.num_atoms(), seed));
let Ok(mut m2) = omgkit_io::smiles::parse(&w.smiles) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m2).is_err() {
continue;
}
omgkit_io::stereo::perceive_bond_stereo(&mut m2);
if omgkit_io::canon::canonical_smiles(&m2).smiles
!= omgkit_io::canon::canonical_smiles(&m).smiles
{
continue;
}
let r2 = omgkit_io::canon::canonical_ranks(&m2);
assert_eq!(
fp(&m2, &r2),
want,
"{smi}:换成 {} 之后摆得不一样了",
w.smiles
);
compared += 1;
}
assert!(compared > 0, "{smi}:一次都没比成 —— 判据空过了");
}
}
#[test]
fn a_three_way_tie_of_gaps_is_not_broken_by_the_last_bit() {
let base = [
-2.094_395_102_393_195_7_f64,
-0.000_000_000_000_000_67,
2.094_395_102_393_195_3,
];
let want = largest_gap(&base);
for i in 0..3 {
for eps in [-4.4e-16, 4.4e-16, -1e-15, 1e-15] {
let mut v = base;
v[i] += eps;
v.sort_by(|a, b| a.partial_cmp(b).expect("非 NaN"));
let got = largest_gap(&v);
assert!(
(got.0 - want.0).abs() < 1e-9,
"第 {i} 个方向抖动 {eps:e} 之后挑了另一个扇区:{:.6} → {:.6}",
want.0,
got.0
);
}
}
}
#[test]
fn a_genuinely_larger_gap_still_wins() {
let v = [0.0_f64, 1.0, 1.2];
let (start, gap) = largest_gap(&v);
assert!(
(start - 1.2).abs() < 1e-9,
"该取 1.2 起那个最大的空隙,实得起点 {start:.4}"
);
assert!((gap - (std::f64::consts::TAU - 1.2)).abs() < 1e-9);
}
#[test]
fn an_arm_hanging_off_a_ring_keeps_its_ideal_angles() {
let mut checked = 0usize;
for smi in [
"CC(=O)Oc1ccccc1C(=O)O", "CC(=O)Nc1ccc(O)cc1", "CC(=O)Oc1ccccc1", "COc1ccccc1OC(C)=O", ] {
for style in &Style::ALL {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
omgkit_io::stereo::perceive_bond_stereo(&mut m);
let d = crate::generate(&m, style);
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
let n: Vec<u32> = m.neighbors(a).map(|(x, _)| x).collect();
if n.len() < 2 {
continue;
}
let c = d.coords[a as usize];
for i in 0..n.len() {
for j in (i + 1)..n.len() {
let u = (d.coords[n[i] as usize] - c).normalized();
let v = (d.coords[n[j] as usize] - c).normalized();
checked += 1;
let deg = u.dot(v).clamp(-1.0, 1.0).acos().to_degrees();
let ideal = ideal_angle(&m, a, style).to_degrees();
let ok = (1..=6)
.map(|k| ideal * f64::from(k))
.take_while(|t| *t <= 180.5)
.any(|t| (deg - t).abs() < 1.0);
assert!(
ok,
"[{}] {smi}:{}-{a}-{} 的夹角是 {deg:.1}°,不是标准角 —— \
理想位置被占时该先试对侧,而不是按 30° 一档歪",
style.name, n[i], n[j]
);
}
}
}
}
}
assert!(checked > 0, "一个键角都没查到,判据空过了");
}
#[test]
fn avoiding_a_taken_spot_does_not_pinch_the_angle_to_sixty_degrees() {
for smi in [
"CC(CCCN(CCCl)CCCl)NC1=C2C=CC(=CC2=NC=C1)Cl",
"ClCCN(CCCl)CCCl",
"CC(C)(C)CC(C)(C)C",
] {
for style in &Style::ALL {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
let d = crate::generate(&m, style);
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
let nbrs: Vec<u32> = m.neighbors(a).map(|(n, _)| n).collect();
if nbrs.len() != 2 {
continue;
}
let c = d.coords[a as usize];
let u = (d.coords[nbrs[0] as usize] - c).normalized();
let v = (d.coords[nbrs[1] as usize] - c).normalized();
let deg = u.dot(v).clamp(-1.0, 1.0).acos().to_degrees();
assert!(
deg > 89.0,
"[{}] {smi}:{}–{a}–{} 的夹角只有 {deg:.1}°",
style.name,
nbrs[0],
nbrs[1]
);
}
}
}
}
#[test]
fn no_two_atoms_are_drawn_on_the_same_point() {
for smi in [
"CC([CH]1CC[C]2(CC[C]3(C)[C]4(C)[CH](CC[CH]3[CH]12)[C]1(C)[CH](CC4)C([CH](CC1)O)(C)C)CO)=C",
"[O-][N+](=O)C1=CC(=CC=C1Cl)S(=O)(=O)C2=CC=C(Cl)C(=C2)[N+]([O-])=O",
"CC(C)(C)c1ccccc1",
"CC(=O)Oc1ccccc1C(=O)O",
] {
for style in &Style::ALL {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
let d = crate::generate(&m, style);
for i in 0..d.coords.len() {
for j in (i + 1)..d.coords.len() {
let dist = d.coords[i].dist(d.coords[j]);
assert!(
dist > 0.1,
"[{}] {smi}:原子 {i} 与 {j} 相距 {dist:.4} 个键长 —— 画在同一点上了",
style.name
);
}
}
}
}
}
#[test]
fn an_sp_atom_is_drawn_straight() {
for (smi, centre) in [
("CC#N", 1u32), ("CC#CC", 1), ("CC=C=CC", 2), ("N#CC(C)(C)C#N", 1),
] {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
let d = crate::generate(&m, &Style::ACS_1996);
let nbrs: Vec<u32> = m.neighbors(centre).map(|(n, _)| n).collect();
assert!(nbrs.len() >= 2, "{smi}:中心该有两个邻居");
let (p, q) = (d.coords[nbrs[0] as usize], d.coords[nbrs[1] as usize]);
let c = d.coords[centre as usize];
let (u, v) = ((p - c).normalized(), (q - c).normalized());
let deg = u.dot(v).clamp(-1.0, 1.0).acos().to_degrees();
assert!(
(deg - 180.0).abs() < 1e-6,
"{smi}:原子 {centre} 是 sp,键角却画成了 {deg:.1}°"
);
}
}
fn canonical(m: &MolBuilder) -> Vec<u32> {
omgkit_io::canon::canonical_ranks(m)
}
use super::*;
use crate::style::Style;
const TOL: f64 = 1e-9;
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
m
}
fn between(u: Point2, v: Point2) -> f64 {
let c = u.normalized().dot(v.normalized()).clamp(-1.0, 1.0);
c.acos()
}
fn env<'a>(
m: &'a omgkit_core::MolBuilder,
ranks: &'a [u32],
style: &'a Style,
radii: &'a [f64],
bonded: &'a std::collections::BTreeSet<(u32, u32)>,
blocks: &'a BTreeMap<u32, super::Block>,
) -> super::Env<'a> {
static NONE: std::sync::OnceLock<std::collections::BTreeSet<u32>> =
std::sync::OnceLock::new();
super::Env {
mol: m,
ranks,
style,
radii,
bonded,
off_grid: NONE.get_or_init(Default::default),
blocks,
}
}
#[test]
fn a_chain_zigzags_instead_of_running_straight() {
let m = prep("CCCCC");
let style = Style::ACS_1996;
let radii = crate::refine::radii(&m, &style);
let bonded: std::collections::BTreeSet<(u32, u32)> = m
.bonds()
.iter()
.map(|b| (b.begin.min(b.end), b.begin.max(b.end)))
.collect();
let blocks: BTreeMap<u32, super::Block> = BTreeMap::new();
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
pos.insert(0, Point2::ORIGIN);
let mut zig = 1i8;
for a in 0..4u32 {
let out = place_neighbours(
&env(&m, &canonical(&m), &style, &radii, &bonded, &blocks),
a,
&pos,
&[a + 1],
zig,
);
pos.insert(out[0].atom, out[0].at);
zig = out[0].zig;
}
assert_eq!(pos.len(), 5);
for i in 1..4u32 {
let ang = between(pos[&(i - 1)] - pos[&i], pos[&(i + 1)] - pos[&i]);
assert!(
(ang - 120f64.to_radians()).abs() < TOL,
"第 {i} 个原子处的键角是 {:.1}°,应当是 120°",
ang.to_degrees()
);
}
let turn = |i: u32| (pos[&i] - pos[&(i - 1)]).cross(pos[&(i + 1)] - pos[&i]);
assert!(turn(1) * turn(2) < 0.0, "第 1、2 步没有交替转向");
assert!(turn(2) * turn(3) < 0.0, "第 2、3 步没有交替转向");
}
#[test]
fn every_bond_is_one_unit_long() {
let m = prep("CC(C)(C)C");
let style = Style::ACS_1996;
let radii = crate::refine::radii(&m, &style);
let bonded: std::collections::BTreeSet<(u32, u32)> = m
.bonds()
.iter()
.map(|b| (b.begin.min(b.end), b.begin.max(b.end)))
.collect();
let blocks: BTreeMap<u32, super::Block> = BTreeMap::new();
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
pos.insert(1, Point2::ORIGIN);
let mut todo: Vec<u32> = m.neighbors(1).map(|(n, _)| n).collect();
todo.sort_unstable();
for p in place_neighbours(
&env(&m, &canonical(&m), &style, &radii, &bonded, &blocks),
1,
&pos,
&todo,
1,
) {
pos.insert(p.atom, p.at);
}
for n in todo {
let d = pos[&n].dist(pos[&1]);
assert!((d - BOND_LEN).abs() < TOL, "键长 {d}");
}
}
#[test]
fn four_substituents_are_spread_not_stacked() {
let m = prep("CC(C)(C)C");
let style = Style::ACS_1996;
let radii = crate::refine::radii(&m, &style);
let bonded: std::collections::BTreeSet<(u32, u32)> = m
.bonds()
.iter()
.map(|b| (b.begin.min(b.end), b.begin.max(b.end)))
.collect();
let blocks: BTreeMap<u32, super::Block> = BTreeMap::new();
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
pos.insert(1, Point2::ORIGIN);
let mut todo: Vec<u32> = m.neighbors(1).map(|(n, _)| n).collect();
todo.sort_unstable();
assert_eq!(todo.len(), 4, "季碳应当有四个邻居");
let out = place_neighbours(
&env(&m, &canonical(&m), &style, &radii, &bonded, &blocks),
1,
&pos,
&todo,
1,
);
for i in 0..out.len() {
for j in (i + 1)..out.len() {
let ang = between(out[i].at, out[j].at);
assert!(
ang > 45f64.to_radians(),
"第 {i}、{j} 个取代基只差 {:.1}°,挤在一起了",
ang.to_degrees()
);
}
}
}
#[test]
fn a_new_branch_goes_into_the_largest_free_sector() {
let m = prep("CC(C)C");
let style = Style::ACS_1996;
let radii = crate::refine::radii(&m, &style);
let bonded: std::collections::BTreeSet<(u32, u32)> = m
.bonds()
.iter()
.map(|b| (b.begin.min(b.end), b.begin.max(b.end)))
.collect();
let blocks: BTreeMap<u32, super::Block> = BTreeMap::new();
let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
pos.insert(1, Point2::ORIGIN);
pos.insert(0, Point2::new(1.0, 0.0));
pos.insert(2, Point2::new(0.5, 3f64.sqrt() / 2.0));
let out = place_neighbours(
&env(&m, &canonical(&m), &style, &radii, &bonded, &blocks),
1,
&pos,
&[3],
1,
);
let ang = out[0].at.angle().rem_euclid(std::f64::consts::TAU);
assert!(
(ang - 210f64.to_radians()).abs() < 1e-6,
"新支链落在 {:.1}°,应当落在最大空隙的中点 210°",
ang.to_degrees()
);
}
#[test]
fn the_largest_gap_wraps_around_the_seam() {
let sorted = vec![-0.1, 0.0, 0.1];
let (start, gap) = largest_gap(&sorted);
assert!(
(start - 0.1).abs() < TOL,
"空隙应当从最后一个方向 0.1 起,实得 {start}"
);
let want = -0.1 + std::f64::consts::TAU - 0.1;
assert!((gap - want).abs() < TOL, "空隙大小应当是 {want},实得 {gap}");
let inner = vec![-3.0, -2.9, 3.0];
let (s2, g2) = largest_gap(&inner);
assert!(
(s2 - (-2.9)).abs() < TOL && (g2 - 5.9).abs() < TOL,
"内部空隙没选对"
);
}
}