use omgkit_chem::sssr::Ring;
use omgkit_core::{BondFlags, BondOrder, MolBuilder};
use crate::geom::Point2;
use crate::label::{label_for, HSide, Label, LabelDir, LabelPlace, Run};
use crate::style::Style;
use crate::Depiction;
#[derive(Debug, Clone, PartialEq)]
pub enum Primitive {
Line {
from: Point2,
to: Point2,
width: f64,
},
Wedge {
from: Point2,
to: Point2,
wide: f64,
},
Hash {
from: Point2,
to: Point2,
wide: f64,
spacing: f64,
width: f64,
},
Ball {
at: Point2,
r: f64,
color: [u8; 3],
},
Stick {
from: Point2,
to: Point2,
width: f64,
color: [u8; 3],
},
Text {
at: Point2,
runs: Vec<Run>,
size: f64,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Scene {
pub items: Vec<Primitive>,
pub width: f64,
pub height: f64,
}
pub(crate) const PAD_PT: f64 = 8.0;
#[must_use]
pub fn scene(mol: &MolBuilder, depiction: &Depiction, style: &Style) -> Scene {
let grown = depiction.drawn(mol);
let mol = &*grown;
assert_eq!(
depiction.coords.len(),
mol.num_atoms(),
"这张图不是这个分子的:坐标 {} 个,原子 {} 个",
depiction.coords.len(),
mol.num_atoms()
);
assert_eq!(
depiction.wedges.len(),
mol.num_bonds(),
"这张图不是这个分子的:楔形 {} 条,键 {} 根",
depiction.wedges.len(),
mol.num_bonds()
);
debug_assert!(
depiction.matches(style),
"这张图是按另一套规范排的(指纹 {:#x} vs {:#x})—— 坐标不是这套规范的",
depiction.style_fingerprint,
style.layout_fingerprint()
);
let orders = drawn_orders(mol);
let scale = style.bond_length_pt;
let bnd = bounds(&depiction.coords, mol, style);
let (min_x, min_y, max_x, max_y) = bnd;
let to_pt = |p: Point2| to_canvas(p, bnd, scale);
let pts: Vec<Point2> = depiction.coords.iter().map(|p| to_pt(*p)).collect();
let rings = omgkit_chem::sssr::ring_set(mol);
let labels: Vec<Option<Label>> = (0..mol.num_atoms())
.map(|i| {
let a = u32::try_from(i).expect("原子数超出 u32");
label_at(mol, a, style, &depiction.coords)
})
.collect();
let boxes: Vec<Vec<(Point2, f64, f64)>> = labels
.iter()
.enumerate()
.map(|(i, l)| {
l.as_ref()
.map_or_else(Vec::new, |l| ink_canvas(l, pts[i], scale))
})
.collect();
let margin_pt = style.margin() * scale;
let mut items = Vec::new();
for (bi, b) in mol.bonds().iter().enumerate() {
let wedge = depiction.wedges.get(bi).copied().unwrap_or_default();
let flip = wedge.narrow() == Some(b.end);
let (bg, en) = if flip {
(b.end, b.begin)
} else {
(b.begin, b.end)
};
let (pa, pb) = (depiction.coords[bg as usize], depiction.coords[en as usize]);
let (qa, qb) = trim(pa, pb, &labels[bg as usize], &labels[en as usize], style);
let (a, bb) = (to_pt(qa), to_pt(qb));
let w = style.line_width_pt;
let sidelined = |from: Point2, to: Point2| {
let (from, to) = escape_boxes(
from,
to,
&boxes[bg as usize],
&boxes[en as usize],
margin_pt,
);
Primitive::Line { from, to, width: w }
};
match orders[bi] {
BondOrder::Double => {
let spacing = style.bond_spacing() * scale;
let off = offset_dir(mol, bi, &pts, &rings, &orders, &labels, spacing);
let d = (bb - a).normalized();
let n = Point2::new(-d.y, d.x) * spacing;
let n = if n.dot(off) < 0.0 { n * -1.0 } else { n };
if off.norm() < 1e-9 {
let half = n * 0.5;
items.push(sidelined(a + half, bb + half));
items.push(sidelined(a - half, bb - half));
} else {
items.push(Primitive::Line {
from: a,
to: bb,
width: w,
});
let fallback = (bb - a) * 0.12;
let end = |e: u32, o: u32, p: Point2, back: Point2| {
if labels[e as usize].is_some() || mol.degree(e) == 1 {
p + n
} else {
mitre_end(mol, e, o, &pts, n).unwrap_or(p + n + back)
}
};
let from = end(bg, en, a, fallback);
let to = end(en, bg, bb, fallback * -1.0);
items.push(sidelined(from, to));
}
}
BondOrder::Triple => {
let d = (bb - a).normalized();
let n = Point2::new(-d.y, d.x) * (style.bond_spacing() * scale);
items.push(Primitive::Line {
from: a,
to: bb,
width: w,
});
items.push(sidelined(a + n, bb + n));
items.push(sidelined(a - n, bb - n));
}
_ => match wedge {
crate::stereo::Wedge::Up { .. } => items.push(Primitive::Wedge {
from: a,
to: bb,
wide: style.bold_width_pt,
}),
crate::stereo::Wedge::Down { .. } => items.push(Primitive::Hash {
from: a,
to: bb,
wide: style.bold_width_pt,
spacing: style.hash_spacing_pt,
width: style.line_width_pt,
}),
crate::stereo::Wedge::None => items.push(Primitive::Line {
from: a,
to: bb,
width: w,
}),
},
}
}
for (i, l) in labels.iter().enumerate() {
if let Some(l) = l {
for (off, runs) in l.lines() {
items.push(Primitive::Text {
at: to_pt(depiction.coords[i]) + Point2::new(off.x, -off.y) * scale,
runs: runs.to_vec(),
size: style.atom_label_pt,
});
}
}
}
Scene {
items,
width: (max_x - min_x) * scale + 2.0 * PAD_PT,
height: (max_y - min_y) * scale + 2.0 * PAD_PT,
}
}
#[must_use]
pub fn drawn_orders(mol: &MolBuilder) -> Vec<BondOrder> {
let plain = || mol.bonds().iter().map(|b| b.order).collect::<Vec<_>>();
let ranks = crate::ranks_of(mol);
let mut order: Vec<usize> = (0..mol.num_atoms()).collect();
order.sort_by_key(|i| (ranks[*i], *i));
let mut pos = vec![0u32; mol.num_atoms()];
for (new, old) in order.iter().enumerate() {
pos[*old] = u32::try_from(new).expect("原子数超出 u32");
}
let mut copy = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
for old in &order {
copy.add_atom_data(mol.atoms()[*old]);
}
let mut bs: Vec<(u32, u32, usize)> = mol
.bonds()
.iter()
.enumerate()
.map(|(i, b)| {
let (x, y) = (pos[b.begin as usize], pos[b.end as usize]);
(x.min(y), x.max(y), i)
})
.collect();
bs.sort_unstable();
for (x, y, i) in &bs {
let mut bd = mol.bonds()[*i];
bd.begin = *x;
bd.end = *y;
for r in &mut bd.stereo_atoms {
if *r != omgkit_core::BondData::NO_STEREO_ATOM {
*r = pos[*r as usize];
}
}
if copy.add_bond_data(bd).is_err() {
return plain();
}
}
if omgkit_chem::kekulize(&mut copy).is_err() {
return plain();
}
let mut out = plain();
for (k, (_, _, i)) in bs.iter().enumerate() {
out[*i] = copy.bonds()[k].order;
}
out
}
pub fn to_canvas(p: Point2, bnd: (f64, f64, f64, f64), scale: f64) -> Point2 {
Point2::new(
(p.x - bnd.0) * scale + PAD_PT,
(bnd.3 - p.y) * scale + PAD_PT,
)
}
pub fn bounds(coords: &[Point2], mol: &MolBuilder, style: &Style) -> (f64, f64, f64, f64) {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (i, p) in coords.iter().enumerate() {
let a = u32::try_from(i).expect("原子数超出 u32");
let (off, hw, hh) = label_at(mol, a, style, coords)
.map_or((Point2::ORIGIN, 0.0, 0.0), |l| {
(l.offset(), l.half_w, l.half_h)
});
x0 = x0.min(p.x + off.x - hw);
y0 = y0.min(p.y + off.y - hh);
x1 = x1.max(p.x + off.x + hw);
y1 = y1.max(p.y + off.y + hh);
}
if x0 > x1 {
return (0.0, 0.0, 0.0, 0.0);
}
(x0, y0, x1, y1)
}
pub fn label_at(mol: &MolBuilder, atom: u32, style: &Style, coords: &[Point2]) -> Option<Label> {
let place = label_place(mol, atom, coords);
label_for(mol, atom, style, place).or_else(|| {
is_collinear(mol, atom, coords).then(|| crate::label::label_forced(mol, atom, style, place))
})
}
pub fn label_place(mol: &MolBuilder, atom: u32, coords: &[Point2]) -> LabelPlace {
let dir = label_dir(mol, atom, coords);
if dir.is_vertical() && crate::label::can_stack(mol, atom) {
LabelPlace::Stacked {
below: dir == LabelDir::South,
}
} else {
LabelPlace::Horizontal(h_side(mol, atom, coords))
}
}
pub fn h_side(mol: &MolBuilder, atom: u32, coords: &[Point2]) -> HSide {
if mol.degree(atom) == 0 {
return match label_dir(mol, atom, coords) {
LabelDir::West => HSide::Left,
_ => HSide::Right,
};
}
if nbr_sum(mol, atom, coords).0 > TIE {
HSide::Left } else {
HSide::Right
}
}
fn nbr_sum(mol: &MolBuilder, atom: u32, coords: &[Point2]) -> (f64, f64) {
let here = coords[atom as usize];
let (mut sx, mut sy) = (0.0, 0.0);
for (n, _) in mol.neighbors(atom) {
sx += coords[n as usize].x - here.x;
sy += coords[n as usize].y - here.y;
}
(sx, sy)
}
const TIE: f64 = 1e-3;
pub const VERT_SLOPE: f64 = 2.747_477_419_454_622;
pub const DIR_TIE: f64 = 1e-9;
pub fn label_dir(mol: &MolBuilder, atom: u32, coords: &[Point2]) -> LabelDir {
if mol.degree(atom) == 0 {
return match mol.atoms()[atom as usize].atomic_num {
8 | 9 | 16 | 17 | 34 | 35 | 52 | 53 | 84 | 85 => LabelDir::West,
_ => LabelDir::East,
};
}
let (sx, sy) = nbr_sum(mol, atom, coords);
let vertical = mol.degree(atom) >= 2 && sy.abs() - VERT_SLOPE * sx.abs() > DIR_TIE;
if vertical {
if sy > 0.0 {
LabelDir::South
} else {
LabelDir::North
}
} else if sx > TIE {
LabelDir::West } else {
LabelDir::East
}
}
fn mitre_end(mol: &MolBuilder, e: u32, o: u32, pts: &[Point2], n: Point2) -> Option<Point2> {
let pe = pts[e as usize];
let po = pts[o as usize];
let to_o = (po - pe).normalized();
if to_o.norm() < 1e-9 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let t = mol
.neighbors(e)
.map(|(x, _)| x)
.filter(|x| *x != o)
.filter_map(|x| {
let d = pts[x as usize] - pe;
if d.norm() < 1e-9 {
return None;
}
let dir = d.normalized();
if dir.dot(n) <= 0.0 {
return None;
}
let p = pts[x as usize];
Some((
(dir.dot(n) * 1e9).round() as i64,
(p.x * 1e9).round() as i64,
(p.y * 1e9).round() as i64,
x,
))
})
.max()?
.3;
let to_t = (pts[t as usize] - pe).normalized();
let bis = to_o + to_t;
if bis.norm() < 1e-9 {
return None; }
let bis = bis.normalized();
let denom = bis.cross(to_o);
if denom.abs() < 1e-9 {
return None;
}
let u = n.cross(to_o) / denom;
if !u.is_finite() || u <= 0.0 || u > pe.dist(po) {
return None;
}
Some(pe + bis * u)
}
pub fn is_collinear(mol: &MolBuilder, a: u32, coords: &[Point2]) -> bool {
const COS: f64 = -0.95;
let nbrs: Vec<(u32, u32)> = mol.neighbors(a).collect();
if nbrs.len() != 2 {
return false;
}
if mol.bonds()[nbrs[0].1 as usize].order != mol.bonds()[nbrs[1].1 as usize].order {
return false;
}
let c = coords[a as usize];
let u = coords[nbrs[0].0 as usize] - c;
let v = coords[nbrs[1].0 as usize] - c;
if u.norm() < 1e-9 || v.norm() < 1e-9 {
return false;
}
u.normalized().dot(v.normalized()) < COS
}
pub fn box_reach(from: Point2, d: Point2, half_w: f64, half_h: f64) -> f64 {
let t = |o: f64, dd: f64, h: f64| {
if dd.abs() < 1e-12 {
f64::INFINITY
} else {
((if dd > 0.0 { h } else { -h }) - o) / dd
}
};
t(from.x, d.x, half_w).min(t(from.y, d.y, half_h)).max(0.0)
}
const SQUEEZE: f64 = 0.9;
pub fn label_clearance(l: Option<&Label>, dir: Point2, style: &Style) -> f64 {
l.map_or(0.0, |l| {
let pad = style.margin();
l.ink
.iter()
.fold(0.0_f64, |acc, r| acc.max(ray_exit(r, dir, pad)))
})
}
fn ray_exit(r: &crate::label::InkBox, dir: Point2, pad: f64) -> f64 {
let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY);
for (c, d, h) in [
(r.centre.x, dir.x, r.half_w + pad),
(r.centre.y, dir.y, r.half_h + pad),
] {
if d.abs() < 1e-12 {
if c.abs() > h {
return 0.0;
}
} else {
let (a, b) = ((c - h) / d, (c + h) / d);
t0 = t0.max(a.min(b));
t1 = t1.min(a.max(b));
}
}
if t0 > t1 || t1 <= 0.0 {
0.0
} else {
t1
}
}
#[must_use]
pub fn ink_canvas(l: &Label, at: Point2, scale: f64) -> Vec<(Point2, f64, f64)> {
l.ink
.iter()
.map(|r| {
(
at + Point2::new(r.centre.x, -r.centre.y) * scale,
r.half_w * scale,
r.half_h * scale,
)
})
.collect()
}
#[must_use]
pub fn touches_glyphs(l: &Label, at: Point2, p: Point2, scale: f64, slack: f64) -> bool {
ink_canvas(l, at, scale).into_iter().any(|(c, hw, hh)| {
let (hw, hh) = (hw - slack, hh - slack);
hw > 0.0 && hh > 0.0 && (p.x - c.x).abs() < hw && (p.y - c.y).abs() < hh
})
}
pub fn is_squeezed(
pa: Point2,
pb: Point2,
la: Option<&Label>,
lb: Option<&Label>,
style: &Style,
) -> bool {
let len = pa.dist(pb);
if len < 1e-9 {
return false;
}
let d = (pb - pa) * (1.0 / len);
label_clearance(la, d, style) + label_clearance(lb, d * -1.0, style) >= len * SQUEEZE
}
fn squeeze(ca: f64, cb: f64, len: f64) -> (f64, f64) {
if ca + cb >= len * SQUEEZE {
let k = len * SQUEEZE / (ca + cb).max(1e-9);
(ca * k, cb * k)
} else {
(ca, cb)
}
}
fn escape_boxes(
from: Point2,
to: Point2,
ba: &[(Point2, f64, f64)],
bb: &[(Point2, f64, f64)],
margin: f64,
) -> (Point2, Point2) {
let len = from.dist(to);
if len < 1e-9 {
return (from, to);
}
let d = (to - from) * (1.0 / len);
let out = |p: Point2, dir: Point2, bxs: &[(Point2, f64, f64)]| -> f64 {
let mut best = 0.0_f64;
for (c, hw, hh) in bxs {
let (hw, hh) = (hw + margin, hh + margin);
let r = p - *c;
if r.x.abs() >= hw || r.y.abs() >= hh {
continue;
}
best = best.max(box_reach(r, dir, hw, hh));
}
best
};
let (ca, cb) = (out(from, d, ba), out(to, d * -1.0, bb));
let (ca, cb) = squeeze(ca, cb, len);
(from + d * ca, to - d * cb)
}
fn trim(
pa: Point2,
pb: Point2,
la: &Option<Label>,
lb: &Option<Label>,
style: &Style,
) -> (Point2, Point2) {
let d = (pb - pa).normalized();
let (ca, cb) = (
label_clearance(la.as_ref(), d, style),
label_clearance(lb.as_ref(), d * -1.0, style),
);
let (ca, cb) = squeeze(ca, cb, pa.dist(pb));
(pa + d * ca, pb - d * cb)
}
fn offset_dir(
mol: &MolBuilder,
bi: usize,
pts: &[Point2],
rings: &[Ring],
orders: &[BondOrder],
labels: &[Option<Label>],
probe: f64,
) -> Point2 {
let b = &mol.bonds()[bi];
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let len = pa.dist(pb);
if len < f64::EPSILON {
return Point2::ORIGIN;
}
let mid = (pa + pb) * 0.5;
let axis = (pb - pa) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let tie = 1e-3 * len;
const TIE_DIR: f64 = 1e-9;
let bond_no = u32::try_from(bi).expect("键数超出 u32");
let mine: Vec<&Ring> = rings
.iter()
.filter(|r| r.bonds.contains(&bond_no))
.collect();
if !mine.is_empty() {
let (plus, minus) = (mid + normal * probe, mid - normal * probe);
let mut cands: Vec<(usize, usize, usize, i64, i64, f64)> = Vec::new();
for r in &mine {
let poly: Vec<Point2> = r.atoms.iter().map(|a| pts[*a as usize]).collect();
let side = match (
crate::geom::point_in_polygon(plus, &poly),
crate::geom::point_in_polygon(minus, &poly),
) {
(true, false) => 1.0,
(false, true) => -1.0,
_ => continue,
};
let doubles = r
.bonds
.iter()
.filter(|b| orders[**b as usize] == BondOrder::Double)
.count();
let aromatic = r
.bonds
.iter()
.all(|x| mol.bonds()[*x as usize].flags.contains(BondFlags::AROMATIC));
let c = poly.iter().fold(Point2::ORIGIN, |s, p| s + *p) * (1.0 / poly.len() as f64);
#[allow(clippy::cast_possible_truncation)]
cands.push((
usize::from(!aromatic), usize::MAX - doubles, r.atoms.len(), (c.x * 1e6).round() as i64, (c.y * 1e6).round() as i64,
side,
));
}
cands.sort_by_key(|a| (a.0, a.1, a.2, a.3, a.4));
if let Some(best) = cands.first() {
return normal * best.5;
}
}
if is_collinear(mol, b.begin, pts)
|| is_collinear(mol, b.end, pts)
|| (mol.degree(b.begin) == 1 && mol.degree(b.end) == 1)
{
return Point2::ORIGIN;
}
if mol.degree(b.begin) == 1 || mol.degree(b.end) == 1 {
let inner = if mol.degree(b.begin) == 1 {
b.end
} else {
b.begin
};
if mol.degree(inner) != 2 || labels[inner as usize].is_some() {
return Point2::ORIGIN;
}
}
let mut score: i32 = 0;
for end in [b.begin, b.end] {
for (n, _) in mol.neighbors(end) {
if n == b.begin || n == b.end {
continue;
}
let d = (pts[n as usize] - mid).dot(normal);
if d > tie {
score += 1;
} else if d < -tie {
score -= 1;
}
}
}
if score != 0 {
return normal * f64::from(score.signum());
}
if labels[b.begin as usize].is_some() && labels[b.end as usize].is_some() {
return Point2::ORIGIN;
}
if normal.y < -TIE_DIR {
normal
} else if normal.y > TIE_DIR {
normal * -1.0
} else if normal.x < 0.0 {
normal
} else {
normal * -1.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate;
use crate::label::label_forced;
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 lines(s: &Scene) -> usize {
s.items
.iter()
.filter(|i| matches!(i, Primitive::Line { .. }))
.count()
}
fn texts(s: &Scene) -> usize {
s.items
.iter()
.filter(|i| matches!(i, Primitive::Text { .. }))
.count()
}
#[test]
fn an_atom_whose_bonds_all_point_up_puts_its_hydrogen_straight_below() {
let m = prep("CC(=O)Nc1ccc(O)cc1");
let n = (0..u32::try_from(m.num_atoms()).unwrap())
.find(|a| m.atoms()[*a as usize].atomic_num == 7)
.expect("对乙酰氨基酚有一个氮");
for style in &Style::ALL {
let d = generate(&m, style);
let here = d.coords[n as usize];
for (nb, _) in m.neighbors(n) {
assert!(
d.coords[nb as usize].y > here.y,
"[{}] 酰胺氮的邻居 {nb} 没在它上面,这个分子摆得和判据假设的不一样",
style.name
);
}
assert_eq!(
label_dir(&m, n, &d.coords),
LabelDir::South,
"[{}] 两根键都朝上,标签该往下伸",
style.name
);
}
}
#[test]
fn a_terminal_atom_never_stacks_its_hydrogen() {
let mut vertical_terminals = 0usize;
for smi in [
"CCO",
"CC(=O)Oc1ccccc1C(=O)O",
"NCCO",
"OCC(O)C(O)C(O)C(O)C=O",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
"OC(=O)C(N)Cc1ccccc1",
"NC(=O)c1ccccc1N",
"OC(=O)c1ccccc1O",
] {
let m = prep(smi);
for style in &Style::ALL {
let d = generate(&m, style);
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
if m.degree(a) != 1 {
continue;
}
let Some(l) = label_at(&m, a, style, &d.coords) else {
continue;
};
if !l.plain().contains('H') {
continue;
}
let (nb, _) = m.neighbors(a).next().expect("度 1 必有一个邻居");
let v = d.coords[nb as usize] - d.coords[a as usize];
let deg = v.y.atan2(v.x).to_degrees().abs();
if (70.0..110.0).contains(°) {
vertical_terminals += 1;
}
assert!(
!label_dir(&m, a, &d.coords).is_vertical(),
"[{}] {smi} 的端基 {a} 被判成了竖排",
style.name
);
}
}
}
assert!(
vertical_terminals > 0,
"这批分子里没有键近乎竖直的端基,判据是空过的"
);
}
#[test]
fn whether_a_label_stacks_does_not_depend_on_how_it_was_written() {
for smi in [
"CC(=O)Nc1ccc(O)cc1",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
"NC(=O)c1ccccc1N",
"OC(=O)C(N)Cc1ccccc1",
] {
let m = prep(smi);
let ranks = omgkit_io::canon::canonical_ranks(&m);
let mut seen: Vec<u32> = ranks.clone();
seen.sort_unstable();
assert!(
seen.iter().enumerate().all(|(i, r)| *r as usize == i),
"{smi} 的规范秩不是全序,这条判据的前提不成立"
);
for style in &Style::ALL {
let d = generate(&m, style);
let g = d.drawn(&m);
let base = dirs_by_rank(&g, &d.coords, &ranks);
let mut writings: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
let mut compared = 0usize;
for seed in 0..12u64 {
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;
}
if omgkit_io::canon::canonical_smiles(&m2).smiles
!= omgkit_io::canon::canonical_smiles(&m).smiles
{
continue;
}
let r2 = omgkit_io::canon::canonical_ranks(&m2);
let d2 = generate(&m2, style);
let g2 = d2.drawn(&m2);
let got = dirs_by_rank(&g2, &d2.coords, &r2);
compared += 1;
writings.insert(w.smiles.clone());
assert_eq!(
base, got,
"[{}] {smi} 写成 {} 之后标签朝向变了",
style.name, w.smiles
);
}
assert_eq!(compared, 12, "{smi} 只比上了 {compared} 种写法");
assert!(
writings.len() >= 8,
"{smi} 12 个种子只产出 {} 种不同写法,判据太弱",
writings.len()
);
}
}
}
fn dirs_by_rank(m: &MolBuilder, coords: &[Point2], ranks: &[u32]) -> Vec<LabelPlace> {
let mut order: Vec<usize> = (0..m.num_atoms()).collect();
order.sort_by_key(|i| ranks[*i]);
order
.iter()
.map(|i| label_place(m, u32::try_from(*i).unwrap(), coords))
.collect()
}
#[test]
fn a_seventy_degree_threshold_is_what_is_written_down() {
assert!(
(VERT_SLOPE.atan().to_degrees() - 70.0).abs() < 1e-12,
"VERT_SLOPE 不是 tan 70°,反解出来是 {}°",
VERT_SLOPE.atan().to_degrees()
);
for (smi, atom, want) in [
(
r"[H]/[O+]=c/1\c(c(c1=O)NCc2cccs2)[O-]",
3u32,
LabelDir::West,
),
("[C@@H]12[C@@H](NC(=N1)O)N=C(N2)O", 2u32, LabelDir::North),
] {
let m = prep(smi);
let d = generate(&m, &Style::ACS_1996);
assert_eq!(
label_dir(&m, atom, &d.coords),
want,
"{smi} 的原子 {atom} 朝向不对 —— 阈值动了?"
);
}
}
#[test]
fn a_stacked_label_puts_the_symbol_on_the_atom_and_the_hydrogen_off_to_one_side() {
let m = prep("CC(=O)Nc1ccc(O)cc1");
let n = (0..u32::try_from(m.num_atoms()).unwrap())
.find(|a| m.atoms()[*a as usize].atomic_num == 7)
.expect("对乙酰氨基酚有一个氮");
for style in &Style::ALL {
let d = generate(&m, style);
let l = label_at(&m, n, style, &d.coords).expect("氮该有标签");
let at = l.stacked.expect("这个氮该竖排");
let lines = l.lines();
assert_eq!(lines.len(), 2, "[{}] 竖排该是两行", style.name);
assert_eq!(lines[0].1.len(), at);
assert_eq!(
lines[0].1.iter().map(Run::text).collect::<String>(),
"N",
"[{}] 第一行不是光一个符号",
style.name
);
assert!(
lines[0].0.x.abs() < 1e-12 && lines[0].0.y.abs() < 1e-12,
"[{}] 符号那一行没落在原子上,偏了 {:?}",
style.name,
lines[0].0
);
assert_eq!(
lines[1].1.iter().map(Run::text).collect::<String>(),
"H",
"[{}] 第二行不是氢",
style.name
);
assert!(
lines[1].0.y < 0.0,
"[{}] 两根键都朝上,氢那行该在下面,实际 y = {}",
style.name,
lines[1].0.y
);
let c = l.offset();
for (off, _) in &lines {
assert!(
(off.x - c.x).abs() <= l.half_w + 1e-9
&& (off.y - c.y).abs() <= l.half_h + 1e-9,
"[{}] 行心 {off:?} 跑出了盒 {c:?}±({},{})",
style.name,
l.half_w,
l.half_h
);
}
}
}
#[test]
fn a_stacked_label_leaves_less_room_towards_the_bonds_than_away_from_them() {
let m = prep("CC(=O)Nc1ccc(O)cc1");
let n = (0..u32::try_from(m.num_atoms()).unwrap())
.find(|a| m.atoms()[*a as usize].atomic_num == 7)
.expect("对乙酰氨基酚有一个氮");
for style in &Style::ALL {
let d = generate(&m, style);
let l = label_at(&m, n, style, &d.coords).expect("氮该有标签");
assert!(l.stacked.is_some(), "[{}] 这个氮该竖排", style.name);
let up = Point2::new(0.0, 1.0);
let down = Point2::new(0.0, -1.0);
let (cu, cd) = (
label_clearance(Some(&l), up, style),
label_clearance(Some(&l), down, style),
);
assert!(
cd > cu * 1.5,
"[{}] 氢在下面,朝下的净空 {cd:.4} 该明显大于朝上的 {cu:.4}",
style.name
);
}
}
#[test]
fn every_drawn_line_of_a_label_fits_inside_the_box_it_reports() {
for smi in [
"CC(=O)Nc1ccc(O)cc1",
"C12C(C3C4CC5C(C(C1C3)4)O5)O2",
"OCC(O)C(O)C(O)C(O)C=O",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
"[NH4+].[Cl-]",
"C[SiH3]",
] {
let m = prep(smi);
for style in &Style::ALL {
let em = style.label_size();
let d = generate(&m, style);
let g = d.drawn(&m);
for a in 0..u32::try_from(g.num_atoms()).unwrap() {
let Some(l) = label_at(&g, a, style, &d.coords) else {
continue;
};
let c = l.offset();
for (off, runs) in l.lines() {
assert!(!runs.is_empty(), "[{}] {smi} 原子 {a} 有空行", style.name);
let w: f64 = runs.iter().map(Run::width_em).sum::<f64>() * em;
let up = (CAP_HEIGHT_TEST / 2.0
+ if runs.iter().any(|r| matches!(r, Run::Sup(_))) {
SUP_RISE_TEST
} else {
0.0
})
* em;
let dn = (CAP_HEIGHT_TEST / 2.0
+ if runs.iter().any(|r| matches!(r, Run::Sub(_))) {
SUB_DROP_TEST
} else {
0.0
})
* em;
for (dx, dy, what) in [
(off.x - w / 2.0, off.y + up, "左上"),
(off.x + w / 2.0, off.y - dn, "右下"),
] {
assert!(
(dx - c.x).abs() <= l.half_w + 1e-9
&& (dy - c.y).abs() <= l.half_h + 1e-9,
"[{}] {smi} 原子 {a}({}) 的一行 {what}角 ({dx:.4},{dy:.4}) \
跑出了盒 ({:.4},{:.4})±({:.4},{:.4})",
style.name,
l.plain(),
c.x,
c.y,
l.half_w,
l.half_h
);
}
}
}
}
}
}
const CAP_HEIGHT_TEST: f64 = 0.718;
const SUP_RISE_TEST: f64 = 0.36;
const SUB_DROP_TEST: f64 = 0.14;
const SUB_SUP_SCALE_TEST: f64 = 0.6;
fn afm(c: char) -> (f64, f64, f64, f64, f64) {
let m: (i32, i32, i32, i32, i32) = match c {
'A' => (667, 14, 0, 654, 718),
'B' => (667, 74, 0, 627, 718),
'C' => (722, 44, -19, 681, 737),
'D' => (722, 81, 0, 674, 718),
'E' => (667, 86, 0, 616, 718),
'F' => (611, 86, 0, 583, 718),
'G' => (778, 48, -19, 704, 737),
'H' => (722, 77, 0, 646, 718),
'I' => (278, 91, 0, 188, 718),
'J' => (500, 17, -19, 428, 718),
'K' => (667, 76, 0, 663, 718),
'L' => (556, 76, 0, 537, 718),
'M' => (833, 73, 0, 761, 718),
'N' => (722, 76, 0, 646, 718),
'O' => (778, 39, -19, 739, 737),
'P' => (667, 86, 0, 622, 718),
'Q' => (778, 39, -56, 739, 737),
'R' => (722, 88, 0, 684, 718),
'S' => (667, 49, -19, 620, 737),
'T' => (611, 14, 0, 597, 718),
'U' => (722, 79, -19, 644, 718),
'V' => (667, 20, 0, 647, 718),
'W' => (944, 16, 0, 928, 718),
'X' => (667, 19, 0, 648, 718),
'Y' => (667, 14, 0, 653, 718),
'Z' => (611, 23, 0, 588, 718),
'a' => (556, 36, -15, 530, 538),
'b' => (556, 58, -15, 517, 718),
'c' => (500, 30, -15, 477, 538),
'd' => (556, 35, -15, 499, 718),
'e' => (556, 40, -15, 516, 538),
'f' => (278, 14, 0, 262, 728),
'g' => (556, 40, -220, 499, 538),
'h' => (556, 65, 0, 491, 718),
'i' => (222, 67, 0, 155, 718),
'j' => (222, -16, -210, 155, 718),
'k' => (500, 67, 0, 501, 718),
'l' => (222, 67, 0, 155, 718),
'm' => (833, 65, 0, 769, 538),
'n' => (556, 65, 0, 491, 538),
'o' => (556, 35, -14, 521, 538),
'p' => (556, 58, -207, 517, 538),
'q' => (556, 35, -207, 494, 538),
'r' => (333, 77, 0, 332, 538),
's' => (500, 32, -15, 464, 538),
't' => (278, 14, -7, 257, 669),
'u' => (556, 68, -15, 489, 523),
'v' => (500, 8, 0, 492, 523),
'w' => (722, 14, 0, 709, 523),
'x' => (500, 11, 0, 490, 523),
'y' => (500, 11, -214, 489, 523),
'z' => (500, 31, 0, 469, 523),
'+' => (584, 39, 0, 545, 505),
'-' => (333, 44, 232, 289, 322),
'*' => (389, 39, 431, 349, 718),
'0'..='9' => (556, 25, -19, 523, 703),
other => panic!("判据的字形表里没有 {other:?},补上再说"),
};
let k = |v: i32| f64::from(v) / 1000.0;
(k(m.0), k(m.1), k(m.2), k(m.3), k(m.4))
}
fn expected_ink(l: &Label, em: f64) -> Vec<(f64, f64, f64, f64)> {
let scale_of = |r: &Run| {
if matches!(r, Run::Normal(_)) {
1.0
} else {
SUB_SUP_SCALE_TEST
}
};
let mut out = Vec::new();
for (off, runs) in l.lines() {
let w: f64 = runs
.iter()
.map(|r| r.text().chars().map(|c| afm(c).0).sum::<f64>() * scale_of(r) * em)
.sum();
let mut x = off.x - w / 2.0;
for r in runs {
let s = scale_of(r);
let base = -CAP_HEIGHT_TEST * s / 2.0
+ match r {
Run::Normal(_) => 0.0,
Run::Sub(_) => -SUB_DROP_TEST,
Run::Sup(_) => SUP_RISE_TEST,
};
for ch in r.text().chars() {
let (adv, gx0, gy0, gx1, gy1) = afm(ch);
out.push((
x + (gx0 + gx1) / 2.0 * s * em,
off.y + (base + (gy0 + gy1) / 2.0 * s) * em,
(gx1 - gx0) / 2.0 * s * em,
(gy1 - gy0) / 2.0 * s * em,
));
x += adv * s * em;
}
}
}
out
}
fn same_box(r: crate::label::InkBox, want: (f64, f64, f64, f64)) -> bool {
(r.centre.x - want.0).abs() < 1e-9
&& (r.centre.y - want.1).abs() < 1e-9
&& (r.half_w - want.2).abs() < 1e-9
&& (r.half_h - want.3).abs() < 1e-9
}
#[test]
fn each_glyph_box_is_where_that_piece_of_text_actually_lands() {
let mut checked = 0usize;
for smi in [
"CC(=O)Nc1ccc(O)cc1",
"[NH4+].[Cl-]",
"C=CC(=[NH2+])N",
"C[SiH3]",
"[13CH4]",
"OCC(O)C(O)C(O)C(O)C=O",
"[O-][N+](=O)c1ccccc1S(=O)(=O)O",
] {
let m = prep(smi);
for style in &Style::ALL {
let em = style.label_size();
let d = generate(&m, style);
let g = d.drawn(&m);
for a in 0..u32::try_from(g.num_atoms()).unwrap() {
let Some(l) = label_at(&g, a, style, &d.coords) else {
continue;
};
checked += 1;
let want = expected_ink(&l, em);
assert_eq!(
want.len(),
l.ink.len(),
"[{}] {smi} 原子 {a}({}):字形盒的个数与落笔的字数对不上",
style.name,
l.plain()
);
for (i, w) in want.into_iter().enumerate() {
let r = l.ink[i];
assert!(
same_box(r, w),
"[{}] {smi} 原子 {a}({}) 第 {i} 个字:字形盒报的是 \
({:.5},{:.5})±({:.5},{:.5}),落笔在 ({:.5},{:.5})±({:.5},{:.5})",
style.name,
l.plain(),
r.centre.x,
r.centre.y,
r.half_w,
r.half_h,
w.0,
w.1,
w.2,
w.3
);
let c = l.offset();
assert!(
(r.centre.x - c.x).abs() + r.half_w <= l.half_w + 1e-9,
"[{}] {smi} 原子 {a}({}) 第 {i} 个字横向跑出了整串外接盒",
style.name,
l.plain()
);
}
}
}
}
assert!(checked > 0, "一个标签都没查到,判据空过了");
}
#[test]
fn the_glyph_boxes_land_below_the_symbol_when_the_hydrogen_does() {
let m = prep("CC(=O)Nc1ccc(O)cc1");
let n = (0..u32::try_from(m.num_atoms()).unwrap())
.find(|a| m.atoms()[*a as usize].atomic_num == 7)
.expect("对乙酰氨基酚有一个氮");
for style in &Style::ALL {
let d = generate(&m, style);
let l = label_at(&m, n, style, &d.coords).expect("氮该有标签");
let at = l.stacked.expect("这个氮该竖排");
assert!(l.gap < 0.0, "[{}] 氢该在符号下面", style.name);
let boxes = ink_canvas(&l, Point2::ORIGIN, style.bond_length_pt);
let (sym_y, h_y) = (boxes[0].0.y, boxes[at].0.y);
assert!(
h_y > sym_y + 1.0,
"[{}] 画布 y 向下,氢那一行该比符号更靠下:符号 {sym_y:.3},氢 {h_y:.3}",
style.name
);
}
}
#[test]
fn a_line_end_gets_pushed_a_full_margin_clear_of_the_glyphs() {
let margin = 0.5_f64;
let bx = vec![(Point2::ORIGIN, 1.0, 1.0)];
let far = Point2::new(10.0, 0.0);
let (q, _) = escape_boxes(Point2::new(1.2, 0.0), far, &bx, &[], margin);
assert!(
(q.x - 1.5).abs() < 1e-9,
"离盒 0.2、margin 0.5 的端点该推到 1.5,实得 {:.4}",
q.x
);
let (q, _) = escape_boxes(Point2::new(0.5, 0.0), far, &bx, &[], margin);
assert!(
(q.x - 1.5).abs() < 1e-9,
"盒里的端点该推到 1.5,实得 {:.4}",
q.x
);
let p = Point2::new(1.6, 0.0);
let (q, _) = escape_boxes(p, far, &bx, &[], margin);
assert!(
(q.x - p.x).abs() < 1e-9,
"已经够远的端点不该动,实得 {:.4}",
q.x
);
}
#[test]
fn a_glyph_the_bond_flies_past_does_not_make_it_stop() {
let bx = |x: f64, y: f64| crate::label::InkBox {
centre: Point2::new(x, y),
half_w: 0.1,
half_h: 0.1,
};
let diag = Point2::new(1.0, 1.0).normalized();
let right = Point2::new(1.0, 0.0);
assert!(
ray_exit(&bx(2.0, 0.0), diag, 0.0).abs() < 1e-12,
"斜着飞过去的射线不该被这个盒挡住"
);
assert!(
(ray_exit(&bx(2.0, 0.0), right, 0.0) - 2.1).abs() < 1e-12,
"正对着的盒该让射线停在 2.1"
);
assert!(
ray_exit(&bx(-2.0, 0.0), right, 0.0).abs() < 1e-12,
"身后的盒不该影响往前切多少"
);
assert!(
ray_exit(&bx(2.0, 5.0), right, 0.0).abs() < 1e-12,
"平行于这块板、又在板外,永远进不去"
);
}
#[test]
fn a_bond_never_starts_from_inside_a_two_letter_symbol() {
let mut worst = f64::INFINITY;
let mut worst_at = String::new();
let mut n = 0usize;
for z in 1..=118u8 {
let Some(e) = omgkit_core::element::by_atomic_num(z) else {
continue;
};
let smi = format!("[{}]", e.symbol);
let Ok(mut m) = omgkit_io::smiles::parse(&smi) else {
continue;
};
if omgkit_chem::pipeline::sanitize(&mut m).is_err() {
continue;
}
n += 1;
for style in &Style::ALL {
let em = style.label_size();
for place in [
LabelPlace::Horizontal(HSide::Right),
LabelPlace::Horizontal(HSide::Left),
LabelPlace::Stacked { below: true },
LabelPlace::Stacked { below: false },
] {
let l = label_forced(&m, 0, style, place);
let want = expected_ink(&l, em);
assert_eq!(want.len(), l.ink.len(), "{smi} 的 {} 字数对不上", l.plain());
for (i, w) in want.into_iter().enumerate() {
let r = l.ink[i];
assert!(
same_box(r, w),
"[{}] {smi} 的 {} 第 {i} 个字:字形盒报 \
({:.5},{:.5})±({:.5},{:.5}),该是 ({:.5},{:.5})±({:.5},{:.5})",
style.name,
l.plain(),
r.centre.x,
r.centre.y,
r.half_w,
r.half_h,
w.0,
w.1,
w.2,
w.3
);
}
for k in 0..360 {
let t = f64::from(k).to_radians();
let dir = Point2::new(t.cos(), t.sin());
let c = label_clearance(Some(&l), dir, style);
assert!(
c > 0.0,
"[{}] {smi} 的 {} 朝 {k}° 的净空是 0 —— 键会一路画到原子中心,\
从两个字母中间穿过去",
style.name,
l.plain()
);
let slack = c / style.margin();
if slack < worst {
worst = slack;
worst_at = format!("[{}] {} {k}°", style.name, l.plain());
}
}
}
}
}
assert!(n >= 100, "只跑到 {n} 个元素,这条判据没覆盖到该覆盖的面");
assert!(
worst > 1.2,
"最小净空只剩 {worst:.3} 个 margin({worst_at}),快穿过字母缝了"
);
}
#[test]
fn a_charged_atom_does_not_stack() {
for (smi, atom, want) in [
("CCO", 2u32, true), ("C[NH+](C)C", 1, false), ("C[13CH](C)O", 1, false), ("C[CH2]", 1, false), ("CN(C)C", 1, false), ] {
let m = prep(smi);
assert_eq!(
crate::label::can_stack(&m, atom),
want,
"{smi} 的原子 {atom}:can_stack 该是 {want}"
);
}
let mut found = 0usize;
for smi in [
"C[NH+](C)CCO",
"CC(=O)[NH2+]c1ccccc1",
"C[13CH](C)O",
"C[CH2]",
"O[CH]c1ccccc1",
] {
let m = prep(smi);
for style in &Style::ALL {
let d = generate(&m, style);
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
let at = m.atoms()[a as usize];
if at.formal_charge == 0 && at.isotope == 0 && at.num_radical_electrons == 0 {
continue;
}
let Some(l) = label_at(&m, a, style, &d.coords) else {
continue;
};
if !l.plain().contains('H') {
continue;
}
if label_dir(&m, a, &d.coords).is_vertical() {
found += 1;
}
assert!(
l.stacked.is_none(),
"[{}] {smi} 的原子 {a}({}) 竖排了",
style.name,
l.plain()
);
}
}
}
assert!(
found > 0,
"这批分子里没有几何上够竖排条件的带电荷原子,判据是空过的"
);
}
#[test]
fn a_lone_atom_writes_its_hydrogens_the_way_chemists_do() {
for (smi, want) in [
("O", "H2O"),
("Cl", "HCl"),
("Br", "HBr"),
("S", "H2S"),
("N", "NH3"),
("C", "CH4"),
] {
let m = prep(smi);
assert_eq!(m.num_atoms(), 1, "{smi} 不是单原子");
let d = generate(&m, &Style::ACS_1996);
let l = label_at(&m, 0, &Style::ACS_1996, &d.coords).expect("单原子必有标签");
assert_eq!(l.plain(), want, "{smi} 画成了 {}", l.plain());
}
}
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 = usize::try_from(next() % (i as u64 + 1)).unwrap();
v.swap(i, j);
}
v
}
#[test]
fn a_collinear_atom_is_drawn_instead_of_vanishing() {
for (smi, centre) in [("CC=C=CC", 2u32), ("CC(C)=C=O", 3), ("C=C=C", 1)] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let here = to_canvas(d.coords[centre as usize], bnd, style.bond_length_pt);
assert!(
is_collinear(&m, centre, &d.coords),
"[{}] {smi}:原子 {centre} 该被判成共线",
style.name
);
let drawn = s.items.iter().any(|it| match it {
Primitive::Text { at, .. } => at.dist(here) < 0.5,
_ => false,
});
assert!(
drawn,
"[{}] {smi}:共线的原子 {centre} 一个符号都没画,图上看不见它",
style.name
);
for (nb, bi) in m.neighbors(centre) {
if m.bonds()[bi as usize].order != BondOrder::Double {
continue;
}
let pa = to_canvas(d.coords[centre as usize], bnd, style.bond_length_pt);
let pb = to_canvas(d.coords[nb as usize], bnd, style.bond_length_pt);
let ls = lines_of_bond(&s, pa, pb);
assert_eq!(
ls.len(),
2,
"[{}] {smi}:键 {centre}–{nb} 该画两条线,实得 {}",
style.name,
ls.len()
);
let axis = (pb - pa).normalized();
let n = Point2::new(-axis.y, axis.x);
let side = |(u, v): (Point2, Point2)| ((u + v) * 0.5 - (pa + pb) * 0.5).dot(n);
let (s0, s1) = (side(ls[0]), side(ls[1]));
assert!(
s0 * s1 < 0.0 && (s0 + s1).abs() < 0.1 * s0.abs().max(1e-9),
"[{}] {smi}:键 {centre}–{nb} 的两条线没跨轴对称(偏移 {s0:.3} 与 {s1:.3})",
style.name
);
}
}
}
}
#[test]
fn a_bond_stops_at_the_glyphs_not_at_the_box_around_the_whole_string() {
let style = &Style::ACS_1996;
let m = prep("C=CC(=[NH2+])N");
let l =
label_for(&m, 3, style, LabelPlace::Horizontal(HSide::Right)).expect("[NH2+] 该有标签");
let up = Point2::new(0.0, 1.0);
let circle = l.half_w.hypot(l.half_h);
let whole = box_reach(l.offset() * -1.0, up, l.half_w, l.half_h);
assert!(
(whole - l.half_h).abs() < 1e-9,
"整串盒在竖直方向该切到 {},实得 {whole}",
l.half_h
);
let glyph = label_clearance(Some(&l), up, style) - style.margin();
assert!(
circle - whole > 0.1 && whole - glyph > 0.1,
"三级台阶没拉开:外接圆 {circle:.4} / 整串盒 {whole:.4} / 字形盒 {glyph:.4}"
);
let cap_half = CAP_HEIGHT_TEST / 2.0 * style.label_size();
assert!(
(glyph - cap_half).abs() < 1e-9,
"竖直方向该只让开 N 自己的半个字高 {cap_half:.4},实得 {glyph:.4}"
);
let centre = Point2::new(0.0, 0.0);
let other = Point2::new(0.0, 1.0);
let (q, _) = trim(centre, other, &Some(l.clone()), &None, style);
let cut = centre.dist(q);
assert!(
(cut - (glyph + style.margin())).abs() < 1e-9,
"竖直接近 [NH2+] 时该切 {},实得 {cut}",
glyph + style.margin()
);
let right = Point2::new(1.0, 0.0);
for (smi, atom) in [("CCO", 2u32), ("C=CC(=[NH2+])N", 3)] {
let mm = prep(smi);
for st in &Style::ALL {
let ll = label_for(&mm, atom, st, LabelPlace::Horizontal(HSide::Right))
.expect("该有标签");
let got = label_clearance(Some(&ll), right, st);
let reach = ll
.ink
.iter()
.filter(|r| r.centre.y.abs() <= r.half_h + st.margin())
.fold(f64::MIN, |m, r| m.max(r.centre.x + r.half_w));
assert!(
(got - (reach + st.margin())).abs() < 1e-9,
"[{}] {smi} 的 {} 横向该切到 {:.4},实得 {got:.4}",
st.name,
ll.plain(),
reach + st.margin()
);
let sym = ll.ink[0].centre.x + ll.ink[0].half_w;
assert!(
reach > sym + 0.2,
"[{}] {smi} 的 {}:整串右沿 {reach:.4} 与符号右沿 {sym:.4} \
差得太少,这条判据说明不了问题",
st.name,
ll.plain()
);
}
}
}
#[test]
fn no_drawn_line_runs_across_an_atom_label() {
let mut checked = 0usize;
for smi in [
"C=CC(=[NH2+])N",
"OC(=O)c1ccccc1OC(C)=O",
"CC(=O)Nc1ccc(O)cc1",
"[O-][N+](=O)c1ccccc1S(=O)(=O)O",
"NCCO",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
"c1cc2ccc[n+]3c2c(c1)SCC3",
] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let scale = style.bond_length_pt;
let labels: Vec<Option<Label>> = (0..u32::try_from(m.num_atoms()).unwrap())
.map(|a| label_at(&m, a, style, &d.coords))
.collect();
let squeezed: Vec<bool> = {
let mut v = vec![false; m.num_atoms()];
for b in m.bonds() {
if is_squeezed(
d.coords[b.begin as usize],
d.coords[b.end as usize],
labels[b.begin as usize].as_ref(),
labels[b.end as usize].as_ref(),
style,
) {
v[b.begin as usize] = true;
v[b.end as usize] = true;
}
}
v
};
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
let Some(l) = &labels[a as usize] else {
continue;
};
if squeezed[a as usize] {
continue;
}
let c = to_canvas(d.coords[a as usize], bnd, scale);
for it in &s.items {
let Primitive::Line { from, to, .. } = it else {
continue;
};
let on = |p: &Point2| touches_glyphs(l, c, *p, scale, style.line_width_pt);
checked += 1;
assert!(
!on(from) && !on(to),
"[{}] {smi}:原子 {a} 的标签 {} 被一条线的端点压在里面",
style.name,
l.plain()
);
}
}
}
}
assert!(checked > 0, "一条线都没查到,判据空过了");
}
#[test]
fn benzene_is_drawn_with_three_double_bonds_not_six_plain_lines() {
let m = prep("c1ccccc1");
let d = generate(&m, &Style::ACS_1996);
let s = scene(&m, &d, &Style::ACS_1996);
assert_eq!(lines(&s), 9, "六根键里三根是双键 → 6 + 3 = 9 条线");
assert_eq!(texts(&s), 0, "苯环全是骨架碳,不该有标签");
}
#[test]
fn a_triple_bond_gets_three_lines() {
let m = prep("CC#N");
let d = generate(&m, &Style::ACS_1996);
let s = scene(&m, &d, &Style::ACS_1996);
assert_eq!(lines(&s), 4, "一根单键 + 一根三键(3 条线)");
}
#[test]
fn the_y_axis_is_flipped_for_the_canvas() {
let m = prep("CCO");
let d = generate(&m, &Style::ACS_1996);
let s = scene(&m, &d, &Style::ACS_1996);
let hi = (0..d.coords.len())
.max_by(|a, b| d.coords[*a].y.partial_cmp(&d.coords[*b].y).unwrap())
.unwrap();
let lo = (0..d.coords.len())
.min_by(|a, b| d.coords[*a].y.partial_cmp(&d.coords[*b].y).unwrap())
.unwrap();
assert!(d.coords[hi].y > d.coords[lo].y, "测试样本的 y 应当有高低差");
let scale = Style::ACS_1996.bond_length_pt;
let (_, _, _, max_y) = bounds(&d.coords, &m, &Style::ACS_1996);
let y_of = |p: Point2| (max_y - p.y) * scale + PAD_PT;
assert!(
y_of(d.coords[hi]) < y_of(d.coords[lo]),
"y 轴没翻:化学上更高的原子在画布上却更靠下"
);
assert!(s.height > 0.0 && s.width > 0.0);
}
#[test]
fn bonds_stop_short_of_atom_labels() {
let m = prep("CCO");
let d = generate(&m, &Style::ACS_1996);
let style = Style::ACS_1996;
let o = 2u32; let l =
label_for(&m, o, &style, LabelPlace::Horizontal(HSide::Right)).expect("氧应当有标签");
let neighbour = m.neighbors(o).next().expect("氧连着一个碳").0;
let (_, trimmed) = trim(
d.coords[neighbour as usize],
d.coords[o as usize],
&None,
&Some(l.clone()),
&style,
);
let p = trimmed - d.coords[o as usize];
let gap = l
.ink
.iter()
.map(|r| {
((p.x - r.centre.x).abs() - r.half_w).max((p.y - r.centre.y).abs() - r.half_h)
})
.fold(f64::INFINITY, f64::min);
assert!(
(gap - style.margin()).abs() < 1e-9,
"端点离最近的那个字 {gap:.6},该正好是一个 margin {:.6}",
style.margin()
);
}
#[test]
fn the_two_styles_give_different_canvas_sizes() {
let m = prep("c1ccc2ccccc2c1");
let a = scene(&m, &generate(&m, &Style::ACS_1996), &Style::ACS_1996);
let c = scene(
&m,
&generate(&m, &Style::CHEMDRAW_DEFAULT),
&Style::CHEMDRAW_DEFAULT,
);
assert!(
c.width > a.width * 1.8,
"画布宽度比只有 {:.2}",
c.width / a.width
);
assert_eq!(lines(&a), lines(&c), "图元数量不该随规范变");
}
fn lines_of_bond(s: &Scene, pa: Point2, pb: Point2) -> Vec<(Point2, Point2)> {
let len = pa.dist(pb);
let axis = (pb - pa) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let mut out = Vec::new();
for it in &s.items {
if let Primitive::Line { from, to, .. } = it {
let inside = |p: Point2| {
let v = p - pa;
v.dot(normal).abs() < 0.30 * len
&& v.dot(axis) > -0.10 * len
&& v.dot(axis) < 1.10 * len
};
if inside(*from) && inside(*to) {
out.push((*from, *to));
}
}
}
out
}
#[test]
fn a_fused_double_bond_goes_into_the_aromatic_ring() {
for smi in [
"[H]/N=c/1\\[nH]c-2c(s1)CSc3c2cccc3",
"c1cc2ccc3c(c2nc1)N=C[C@H](C3=O)C(=O)[O-]",
"COCc1[nH]c2c(n1)-c3c(nc(o3)N)[C@H]2c4ccc(cc4)Cl",
] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let rings = omgkit_chem::sssr::ring_set(&m);
let orders = drawn_orders(&m);
let mut checked = 0usize;
for (bi, b) in m.bonds().iter().enumerate() {
if orders[bi] != BondOrder::Double {
continue;
}
let bn = u32::try_from(bi).unwrap();
let mine: Vec<&omgkit_chem::sssr::Ring> =
rings.iter().filter(|r| r.bonds.contains(&bn)).collect();
if mine.len() < 2 {
continue;
}
let arom = |r: &omgkit_chem::sssr::Ring| {
r.bonds
.iter()
.all(|x| m.bonds()[*x as usize].flags.contains(BondFlags::AROMATIC))
};
let (aro, non): (
Vec<&&omgkit_chem::sssr::Ring>,
Vec<&&omgkit_chem::sssr::Ring>,
) = mine.iter().partition(|r| arom(r));
if aro.len() != 1 || non.is_empty() {
continue;
}
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let ls = lines_of_bond(&s, pa, pb);
let Some(inner) = ls
.iter()
.find(|(u, v)| u.dist(pa) > 1e-6 || v.dist(pb) > 1e-6)
.copied()
else {
continue;
};
let mid = (inner.0 + inner.1) * 0.5;
let poly: Vec<Point2> = aro[0].atoms.iter().map(|a| pts[*a as usize]).collect();
assert!(
crate::geom::point_in_polygon(mid, &poly),
"[{}] {smi}:键 {}–{} 的第二条线没画进芳环里",
style.name,
b.begin,
b.end
);
checked += 1;
}
assert!(
checked > 0,
"[{}] {smi}:一根「一芳一不芳」的共用双键都没查到 —— 判据空过了",
style.name
);
}
}
}
#[test]
fn a_ring_double_bond_inner_line_meets_the_neighbouring_bonds() {
for style in &Style::ALL {
let m = prep("c1ccccc1");
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let centre =
pts.iter().fold(Point2::ORIGIN, |acc, p| acc + *p) * (1.0 / pts.len() as f64);
let mut checked = 0usize;
for (bi, b) in m.bonds().iter().enumerate() {
if drawn_orders(&m)[bi] != BondOrder::Double {
continue;
}
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let ls = lines_of_bond(&s, pa, pb);
assert_eq!(ls.len(), 2, "环内双键该画两条线");
let inner = ls
.iter()
.find(|(u, v)| u.dist(pa) > 1e-6 || v.dist(pb) > 1e-6)
.copied()
.expect("该有一条内侧线");
for (end, vertex) in [(inner.0, pa), (inner.1, pb)] {
let ray = (vertex - centre).normalized();
let off = end - centre;
let perp = off.x * ray.y - off.y * ray.x;
assert!(
perp.abs() < 1e-6,
"[{}] 苯环内侧线的端点偏离顶点角平分线 {perp:.4} pt —— 接头处对不上",
style.name
);
}
checked += 1;
}
assert_eq!(checked, 3, "苯环该有三根凯库勒双键");
}
}
#[test]
fn a_double_bond_inner_line_does_not_reach_past_the_main_line_into_a_label() {
for style in &Style::ALL {
let m = prep("CN1C=NC2=C1C(=O)N(C)C(=O)N2C");
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let scale = style.bond_length_pt;
let pts: Vec<Point2> = d.coords.iter().map(|p| to_canvas(*p, bnd, scale)).collect();
let mut checked = 0usize;
for (bi, b) in m.bonds().iter().enumerate() {
if drawn_orders(&m)[bi] != BondOrder::Double {
continue;
}
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let len = pa.dist(pb);
let axis = (pb - pa) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let ls = lines_of_bond(&s, pa, pb);
assert_eq!(ls.len(), 2, "[{}] 双键该画两条线", style.name);
let off = |(u, v): &(Point2, Point2)| {
(((*u - pa).dot(normal)).abs() + ((*v - pa).dot(normal)).abs()) / 2.0
};
let (main, inner) = if off(&ls[0]) < off(&ls[1]) {
(ls[0], ls[1])
} else {
(ls[1], ls[0])
};
if off(&main) > 0.05 * len {
continue;
}
for (a, t_of) in [
(b.begin, 1.0_f64), (b.end, -1.0),
] {
if label_at(&m, a, style, &d.coords).is_none() {
continue;
}
let near = |(u, v): &(Point2, Point2)| {
let (tu, tv) = ((*u - pa).dot(axis), (*v - pa).dot(axis));
if t_of > 0.0 {
tu.min(tv)
} else {
tu.max(tv)
}
};
let (tm, ti) = (near(&main), near(&inner));
assert!(
(ti - tm) * t_of >= -0.01,
"[{}] 咖啡因键 {bi} 的内侧线越过主线伸向带标签的原子 {a}:\
主线停在 {tm:.2},内侧线停在 {ti:.2}(键长 {len:.2})",
style.name
);
checked += 1;
}
}
assert!(
checked > 0,
"[{}] 咖啡因一处都没查到 —— 判据空过了",
style.name
);
}
}
#[test]
fn a_ring_double_bond_never_puts_a_line_outside_the_ring() {
for smi in [
"CC(=O)Oc1ccccc1C(=O)O", "OC1=C(O)C(=O)OC1[C@@H](O)CO", "CC1=C(C)CCCC1", "c1ccccc1", "c1ccc2ccccc2c1", "c1ccncc1", "CN1C=NC2=C1C(=O)N(C)C(=O)N2C", "C1=CC2=CC=CC3=CC=CC(=C1)C23", "C1=CC2CCC1CC2", "C[C@@]12C(=C[C@@H](O1)C(=O)C23CC3)C(=O)OC", ] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let rings = omgkit_chem::sssr::ring_set(&m);
let orders = drawn_orders(&m);
let mut checked = 0;
for (bi, b) in m.bonds().iter().enumerate() {
let mine: Vec<_> = rings
.iter()
.filter(|r| r.bonds.contains(&u32::try_from(bi).unwrap()))
.collect();
if orders[bi] != BondOrder::Double || mine.is_empty() {
continue;
}
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let len = pa.dist(pb);
let mid = (pa + pb) * 0.5;
let axis = (pb - pa) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let got = lines_of_bond(&s, pa, pb);
assert_eq!(
got.len(),
2,
"[{}] {smi}:双键 {bi} 画出了 {} 条线",
style.name,
got.len()
);
for (f, t) in got {
let side = ((f + t) * 0.5 - mid).dot(normal);
if side.abs() < 0.02 * len {
continue;
}
let ok = mine.iter().any(|r| {
let poly: Vec<Point2> =
r.atoms.iter().map(|a| pts[*a as usize]).collect();
crate::geom::point_in_polygon((f + t) * 0.5, &poly)
});
assert!(
ok,
"[{}] {smi}:双键 {bi}({}–{})有一条线画在了环外",
style.name, b.begin, b.end
);
}
checked += 1;
}
assert!(checked >= 1, "[{}] {smi}:一根环内双键都没查到", style.name);
}
}
}
#[test]
fn the_same_molecule_written_differently_draws_the_same_lines() {
let groups = [
vec!["CC(=O)Oc1ccccc1C(=O)O", "c1cc(OC(C)=O)c(cc1)C(O)=O"],
vec!["c1ccc2ccccc2c1", "c1cc2c(cc1)cccc2", "c1cc2ccccc2cc1"],
vec!["c1ccncc1", "c1cccnc1"],
vec!["Cn1cnc2c1c(=O)n(C)c(=O)n2C", "CN1C=NC2=C1C(=O)N(C)C(=O)N2C"],
vec!["OC(=O)c1ccccc1", "c1ccccc1C(O)=O"],
vec!["C/C=C/CC", "CC/C=C/C", "C(/C)=C\\CC", "C(\\CC)=C/C"],
];
let key = |smi: &str, style: &Style| -> Vec<String> {
let m = prep(smi);
let s = scene(&m, &generate(&m, style), style);
let q = |p: Point2| format!("{:.3},{:.3}", p.x, p.y);
let mut v: Vec<String> = s
.items
.iter()
.map(|it| match it {
Primitive::Line { from, to, .. } => {
let (x, y) = (q(*from), q(*to));
if x <= y {
format!("L {x} {y}")
} else {
format!("L {y} {x}")
}
}
Primitive::Wedge { from, to, .. } => format!("W {} {}", q(*from), q(*to)),
Primitive::Hash { from, to, .. } => format!("H {} {}", q(*from), q(*to)),
Primitive::Text { at, runs, .. } => format!("T {} {runs:?}", q(*at)),
Primitive::Ball { .. } | Primitive::Stick { .. } => {
unreachable!("二维那条路的场景里没有球棍 —— 收到就说明拿错了场景")
}
})
.collect();
v.sort();
v
};
for style in &Style::ALL {
for ws in &groups {
let a = key(ws[0], style);
assert!(!a.is_empty(), "{} 什么都没画出来", ws[0]);
for w in &ws[1..] {
assert_eq!(
a,
key(w, style),
"[{}] {w} 与 {} 画出来不一样",
style.name,
ws[0]
);
}
}
}
}
#[test]
fn every_recorded_wedge_actually_reaches_the_canvas() {
for smi in [
"CCO[P@@]1(=O)CCCCN1Cc2ccccc2", "CCO[P@@]1(=O)CCC[C@@H](C1)C",
"OC[C@H](O)[C@H]1OC(=O)C(O)=C1O",
"CC1(C)S[C@@H]2[C@H](NC(=O)Cc3ccccc3)C(=O)N2[C@H]1C(=O)O",
] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let recorded = d.wedges.iter().filter(|w| w.narrow().is_some()).count();
let drawn = s
.items
.iter()
.filter(|it| matches!(it, Primitive::Wedge { .. } | Primitive::Hash { .. }))
.count();
assert!(
recorded > 0,
"[{}] {smi}:一个楔形都没记,判据空过了",
style.name
);
assert_eq!(
recorded, drawn,
"[{}] {smi}:记了 {recorded} 个楔形,画出来 {drawn} 个",
style.name
);
}
}
}
#[test]
fn a_wedge_starts_at_the_stereocentre_it_describes() {
for smi in [
"OC[C@H](O)[C@H]1OC(=O)C(O)=C1O", "OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O", "CC1(C)S[C@@H]2[C@H](NC(=O)Cc3ccccc3)C(=O)N2[C@H]1C(=O)O", ] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let mut checked = 0;
for it in &s.items {
let (f, t) = match it {
Primitive::Wedge { from, to, .. } | Primitive::Hash { from, to, .. } => {
(*from, *to)
}
_ => continue,
};
let on = |b: &omgkit_core::BondData| {
let (pu, pv) = (pts[b.begin as usize], pts[b.end as usize]);
let len = pu.dist(pv);
if len < 1e-9 {
return false;
}
let axis = (pv - pu) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
[f, t].iter().all(|p| {
let v = *p - pu;
v.dot(normal).abs() < 0.02 * len
&& v.dot(axis) > -0.05 * len
&& v.dot(axis) < 1.05 * len
})
};
let hits: Vec<usize> = m
.bonds()
.iter()
.enumerate()
.filter(|(_, b)| on(b))
.map(|(i, _)| i)
.collect();
assert_eq!(
hits.len(),
1,
"[{}] {smi}:一个楔形图元落在了 {} 根键上,反查不出唯一的键",
style.name,
hits.len()
);
let bi = hits[0];
let narrow = d.wedges[bi].narrow().expect("画出了楔形的键必定记着窄端");
let b = &m.bonds()[bi];
let other = if narrow == b.begin { b.end } else { b.begin };
assert!(
f.dist(pts[narrow as usize]) < f.dist(pts[other as usize]),
"[{}] {smi}:键 {bi}({}–{})的楔形窄端画在了原子 {other} 那头,\
而它描述的是原子 {narrow} 的构型",
style.name,
b.begin,
b.end
);
checked += 1;
}
assert!(
checked >= 2,
"[{}] {smi}:只查到 {checked} 个楔形",
style.name
);
}
}
}
#[test]
fn the_recorded_narrow_end_is_what_gets_drawn() {
let m = prep("N[C@@H](C)O");
let style = Style::ACS_1996;
let mut d = generate(&m, &style);
let bi = d
.wedges
.iter()
.position(|w| w.narrow().is_some())
.expect("这个分子该有一个楔形");
let b = &m.bonds()[bi];
let bnd = bounds(&d.coords, &m, &style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let narrow_of = |s: &Scene| {
s.items
.iter()
.find_map(|it| match it {
Primitive::Wedge { from, .. } | Primitive::Hash { from, .. } => Some(*from),
_ => None,
})
.expect("场景里该有一个楔形图元")
};
let before = narrow_of(&scene(&m, &d, &style));
let (was, other) = if d.wedges[bi].narrow() == Some(b.begin) {
(b.begin, b.end)
} else {
(b.end, b.begin)
};
assert!(
before.dist(pts[was as usize]) < before.dist(pts[other as usize]),
"改之前窄端就没画在记录的那一头"
);
d.wedges[bi] = match d.wedges[bi] {
crate::stereo::Wedge::Up { .. } => crate::stereo::Wedge::Up { narrow: other },
crate::stereo::Wedge::Down { .. } => crate::stereo::Wedge::Down { narrow: other },
crate::stereo::Wedge::None => unreachable!("上面刚确认它是个楔形"),
};
let after = narrow_of(&scene(&m, &d, &style));
assert!(
after.dist(pts[other as usize]) < after.dist(pts[was as usize]),
"窄端记到原子 {other} 上了,画出来的窄端却还在原子 {was} 那头 —— \
渲染没看记录,在自己猜"
);
}
#[test]
fn a_fused_ring_double_bond_goes_inside_one_ring_not_astride_the_bond() {
let mut checked = 0usize;
for smi in [
"CC(=O)C1=CC2=C(C=C1C(C)=O)[C]3(C)CCC[C](C)(C#N)[CH]3CC2=O", "c1ccc2ccccc2c1", "C1=CC2=CC=CC3=CC=CC(=C1)C23", ] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let rings = omgkit_chem::sssr::ring_set(&m);
let orders = drawn_orders(&m);
for (bi, b) in m.bonds().iter().enumerate() {
let no = u32::try_from(bi).expect("键数超出 u32");
let mine: Vec<_> = rings.iter().filter(|r| r.bonds.contains(&no)).collect();
if orders[bi] != BondOrder::Double || mine.len() < 2 {
continue;
}
checked += 1;
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let got = lines_of_bond(&s, pa, pb);
assert_eq!(
got.len(),
2,
"[{}] {smi}:共用键 {bi} 画了 {} 条线",
style.name,
got.len()
);
let home = |p: Point2| -> Vec<usize> {
mine.iter()
.enumerate()
.filter(|(_, r)| {
let poly: Vec<Point2> =
r.atoms.iter().map(|a| pts[*a as usize]).collect();
crate::geom::point_in_polygon(p, &poly)
})
.map(|(k, _)| k)
.collect()
};
let h0 = home((got[0].0 + got[0].1) * 0.5);
let h1 = home((got[1].0 + got[1].1) * 0.5);
assert!(
h0.iter().any(|x| h1.contains(x)),
"[{}] {smi}:共用键 {bi}({}–{})的两条线分属不同的环 {h0:?} / {h1:?} —— 跨骑在键上了",
style.name,
b.begin,
b.end
);
}
}
}
assert!(checked > 0, "一根共用的双键都没查到,判据空过了");
}
#[test]
fn a_double_bond_with_no_inner_side_is_drawn_symmetric() {
for smi in [
"O=C=O",
"CC(C)=C",
"C=C",
"CC(C)=O",
"CN=O",
"Br[CH]1[CH](Br)S(=O)(=O)C2=C1C=CC=C2",
"CN=NC",
] {
let m = prep(smi);
let style = Style::ACS_1996;
let d = generate(&m, &style);
let s = scene(&m, &d, &style);
let bnd = bounds(&d.coords, &m, &style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let mut checked = 0;
for b in m.bonds() {
if b.order != BondOrder::Double {
continue;
}
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let len = pa.dist(pb);
let mid = (pa + pb) * 0.5;
let axis = (pb - pa) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let got = lines_of_bond(&s, pa, pb);
assert_eq!(got.len(), 2, "{smi}:双键画出了 {} 条线", got.len());
let sides: Vec<f64> = got
.iter()
.map(|(f, t)| ((*f + *t) * 0.5 - mid).dot(normal))
.collect();
assert!(
sides[0] * sides[1] < 0.0
&& (sides[0].abs() - sides[1].abs()).abs() < 0.02 * len,
"{smi}:两条线没有对称跨在键轴两侧,偏移分别是 {:.3}、{:.3}",
sides[0],
sides[1]
);
checked += 1;
}
assert!(checked >= 1, "{smi}:一根双键都没查到");
}
}
#[test]
fn a_terminal_double_bond_closes_the_joint_at_the_inner_atom() {
for smi in ["CC=C", "CC=O", "CCC=C", "C=CC#N"] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let mut checked = 0usize;
for (bi, b) in m.bonds().iter().enumerate() {
if drawn_orders(&m)[bi] != BondOrder::Double {
continue;
}
let (da, db) = (m.degree(b.begin), m.degree(b.end));
if (da == 1) == (db == 1) {
continue;
}
let (term, inner) = if da == 1 {
(b.begin, b.end)
} else {
(b.end, b.begin)
};
if m.degree(inner) != 2 || label_at(&m, inner, style, &d.coords).is_some() {
continue;
}
let third = m
.neighbors(inner)
.map(|(x, _)| x)
.find(|x| *x != term)
.expect("度 2 的内侧原子必有另一个邻居");
let (pi, pt) = (pts[inner as usize], pts[term as usize]);
let len = pi.dist(pt);
let axis = (pt - pi) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let ls = lines_of_bond(&s, pi, pt);
assert_eq!(ls.len(), 2, "[{}] {smi}:双键该画两条线", style.name);
let starts_at_inner =
|(u, v): &(Point2, Point2)| u.dist(pi).min(v.dist(pi)) < 0.02 * len;
assert!(
ls.iter().any(starts_at_inner),
"[{}] {smi}:键 {bi} 两条线都不从内侧原子 {inner} 出发,顶点合不拢",
style.name
);
let off = |(u, v): &(Point2, Point2)| ((*u + *v) * 0.5 - pi).dot(normal);
let side = (pts[third as usize] - pi).dot(normal);
let outer = ls
.iter()
.max_by(|x, y| off(x).abs().partial_cmp(&off(y).abs()).expect("坐标非 NaN"))
.expect("有两条线");
assert!(
off(outer) * side > 0.0,
"[{}] {smi}:键 {bi} 的第二条线偏到了内侧原子另一根键的反面",
style.name
);
let far = |(u, v): &(Point2, Point2)| {
let (tu, tv) = ((*u - pi).dot(axis), (*v - pi).dot(axis));
tu.max(tv)
};
let (f0, f1) = (far(&ls[0]), far(&ls[1]));
assert!(
(f0 - f1).abs() < 0.02 * len,
"[{}] {smi}:键 {bi} 两条线在端基那头没齐头,{f0:.2} vs {f1:.2}(键长 {len:.2})",
style.name
);
let u = (pts[third as usize] - pi).normalized();
let v = (pt - pi).normalized();
let bis = (u + v).normalized();
let half = u.dot(v).clamp(-1.0, 1.0).acos() / 2.0;
let spacing = style.bond_spacing() * style.bond_length_pt;
let want = pi + bis * (spacing / half.sin());
let inner_end = {
let (uu, vv) = *outer;
if uu.dist(pi) < vv.dist(pi) {
uu
} else {
vv
}
};
assert!(
inner_end.dist(want) < 0.02 * len,
"[{}] {smi}:键 {bi} 的第二条线没斜切到角平分线上 —— \
落在 ({:.2},{:.2}),闭式解是 ({:.2},{:.2})",
style.name,
inner_end.x,
inner_end.y,
want.x,
want.y
);
checked += 1;
}
assert!(
checked >= 1,
"[{}] {smi}:这一档一根键都没查到 —— 判据空过了",
style.name
);
}
}
}
#[test]
fn a_trans_double_bond_closes_both_joints() {
for smi in ["C/C=C/C", "C/C=C/CC", "C/C=C/C=C/C=C/C"] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
let bnd = bounds(&d.coords, &m, style);
let pts: Vec<Point2> = d
.coords
.iter()
.map(|p| to_canvas(*p, bnd, style.bond_length_pt))
.collect();
let mut checked = 0usize;
for (bi, b) in m.bonds().iter().enumerate() {
if drawn_orders(&m)[bi] != BondOrder::Double {
continue;
}
if m.degree(b.begin) == 1 || m.degree(b.end) == 1 {
continue;
}
let (pa, pb) = (pts[b.begin as usize], pts[b.end as usize]);
let len = pa.dist(pb);
let mid = (pa + pb) * 0.5;
let axis = (pb - pa) * (1.0 / len);
let normal = Point2::new(-axis.y, axis.x);
let subs = |e: u32, other: u32| -> Vec<u32> {
m.neighbors(e)
.map(|(x, _)| x)
.filter(|x| *x != other)
.collect()
};
let (sa, sb) = (subs(b.begin, b.end), subs(b.end, b.begin));
if sa.len() != 1 || sb.len() != 1 {
continue;
}
let side = |x: u32| (pts[x as usize] - mid).dot(normal);
if side(sa[0]) * side(sb[0]) >= 0.0 {
continue;
}
if label_at(&m, b.begin, style, &d.coords).is_some()
&& label_at(&m, b.end, style, &d.coords).is_some()
{
continue;
}
let ls = lines_of_bond(&s, pa, pb);
assert_eq!(ls.len(), 2, "[{}] {smi}:双键该画两条线", style.name);
assert!(
ls.iter().any(|(u, v)| {
(u.dist(pa) < 0.02 * len && v.dist(pb) < 0.02 * len)
|| (u.dist(pb) < 0.02 * len && v.dist(pa) < 0.02 * len)
}),
"[{}] {smi}:键 {bi} 没有一条线走在键轴上,两个顶点都合不拢",
style.name
);
checked += 1;
}
assert!(
checked >= 1,
"[{}] {smi}:一根都没查到 —— 判据空过了",
style.name
);
}
}
}
#[test]
fn nothing_is_drawn_outside_the_canvas() {
let mut checked = 0usize;
for smi in [
"CC(=O)Nc1ccc(O)cc1",
"c1ccccc1",
"CC(=O)Oc1ccccc1C(=O)O",
"CC#N",
"CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
"O=C=O",
"c1ccc2c(c1)[C@@H]3CC[C@H]2[n+]4c3cccc4",
] {
for style in &Style::ALL {
let m = prep(smi);
let d = generate(&m, style);
let s = scene(&m, &d, style);
let m = d.drawn(&m);
for it in &s.items {
let pts: Vec<(Point2, f64)> = match it {
Primitive::Line { from, to, width } => {
vec![(*from, *width / 2.0), (*to, *width / 2.0)]
}
Primitive::Wedge { from, to, wide }
| Primitive::Hash { from, to, wide, .. } => {
vec![(*from, *wide / 2.0), (*to, *wide / 2.0)]
}
Primitive::Text { .. } => continue,
Primitive::Ball { .. } | Primitive::Stick { .. } => {
unreachable!("二维那条路的场景里没有球棍 —— 收到就说明拿错了场景")
}
};
for (p, r) in pts {
assert!(
p.x - r >= -0.01 && p.x + r <= s.width + 0.01,
"[{}] {smi}:图元 x={:.2}(±{r:.2})超出画布宽 {:.2}",
style.name,
p.x,
s.width
);
assert!(
p.y - r >= -0.01 && p.y + r <= s.height + 0.01,
"[{}] {smi}:图元 y={:.2}(±{r:.2})超出画布高 {:.2}",
style.name,
p.y,
s.height
);
}
}
let bnd = bounds(&d.coords, &m, style);
let scale = style.bond_length_pt;
for a in 0..u32::try_from(m.num_atoms()).unwrap() {
let Some(l) = label_at(&m, a, style, &d.coords) else {
continue;
};
checked += 1;
let c = to_canvas(d.coords[a as usize], bnd, scale)
+ Point2::new(l.dx * scale, 0.0);
let (hw, hh) = (l.half_w * scale, l.half_h * scale);
assert!(
c.x - hw >= -0.01
&& c.x + hw <= s.width + 0.01
&& c.y - hh >= -0.01
&& c.y + hh <= s.height + 0.01,
"[{}] {smi}:标签 {} 在 ({:.2},{:.2})±({hw:.2},{hh:.2}),画布 {:.2}×{:.2}",
style.name,
l.plain(),
c.x,
c.y,
s.width,
s.height
);
}
}
}
assert!(checked > 0, "一个标签都没查到,判据的下半段空过了");
}
#[test]
fn a_mismatched_depiction_is_refused_loudly() {
let a = prep("CCO");
let b = prep("c1ccccc1");
let d = generate(&a, &Style::ACS_1996);
let r = std::panic::catch_unwind(|| scene(&b, &d, &Style::ACS_1996));
assert!(r.is_err(), "原子数不符却照画不误");
}
}