use omgkit_conf::linalg::symmetric_eigen;
use omgkit_core::{BondOrder, MolBuilder};
use crate::geom::Point2;
use crate::palette::cpk;
use crate::render::{Primitive, Scene, PAD_PT};
#[derive(Debug, Clone, PartialEq)]
pub struct Style3D {
pub name: &'static str,
pub ball_vdw_frac: f64,
pub stick_radius_a: f64,
pub multiple_bond_spacing_a: f64,
pub scale_pt_per_a: f64,
}
impl Style3D {
pub const SPACE_FILLING: Style3D = Style3D {
name: "space-filling",
ball_vdw_frac: 1.0,
stick_radius_a: 0.0,
multiple_bond_spacing_a: 0.0,
scale_pt_per_a: 24.0,
};
pub const BALL_AND_STICK: Style3D = Style3D {
name: "ball-and-stick",
ball_vdw_frac: 0.23,
stick_radius_a: 0.15,
multiple_bond_spacing_a: 0.35,
scale_pt_per_a: 36.0,
};
pub const STICK: Style3D = Style3D {
name: "stick",
ball_vdw_frac: 0.0,
stick_radius_a: 0.30,
multiple_bond_spacing_a: 0.0,
scale_pt_per_a: 36.0,
};
pub const WIREFRAME: Style3D = Style3D {
name: "wireframe",
ball_vdw_frac: 0.0,
stick_radius_a: 0.01,
multiple_bond_spacing_a: 0.25,
scale_pt_per_a: 36.0,
};
pub const ALL: [Style3D; 4] = [
Style3D::SPACE_FILLING,
Style3D::BALL_AND_STICK,
Style3D::STICK,
Style3D::WIREFRAME,
];
#[must_use]
fn ball_radius(&self, rvdw: f64) -> f64 {
self.ball_vdw_frac * rvdw
}
}
const UNKNOWN_RVDW: f64 = 1.7;
pub const DEPTH_SLICE: f64 = 0.25;
pub const DEGENERATE_TOL: f64 = 1e-6;
const QUANT: f64 = 1e6;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct View {
pub rot: [[f64; 3]; 3],
pub centre: [f64; 3],
pub degenerate: bool,
}
impl View {
pub const IDENTITY: View = View {
rot: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
centre: [0.0, 0.0, 0.0],
degenerate: false,
};
#[must_use]
pub fn apply(&self, p: [f64; 3]) -> [f64; 3] {
let d = [
p[0] - self.centre[0],
p[1] - self.centre[1],
p[2] - self.centre[2],
];
let mut out = [0.0; 3];
for (o, r) in out.iter_mut().zip(&self.rot) {
*o = r[0] * d[0] + r[1] * d[1] + r[2] * d[2];
}
out
}
#[must_use]
pub fn determinant(&self) -> f64 {
let m = &self.rot;
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error3D {
CoordCount {
atoms: usize,
coords: usize,
},
NotFinite {
atom: usize,
},
}
impl std::fmt::Display for Error3D {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error3D::CoordCount { atoms, coords } => {
write!(f, "分子有 {atoms} 个原子,给进来的坐标有 {coords} 组")
}
Error3D::NotFinite { atom } => write!(f, "第 {atom} 个原子的坐标不是有限数"),
}
}
}
impl std::error::Error for Error3D {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Placed {
pub at: Point2,
pub depth: f64,
pub radius: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Depiction3D {
pub scene: Scene,
pub placed: Vec<Placed>,
pub view: View,
pub style_name: &'static str,
}
impl Depiction3D {
#[must_use]
pub fn is_clean(&self) -> bool {
!self.view.degenerate
}
}
pub fn depict(
mol: &MolBuilder,
coords: &[[f64; 3]],
style: &Style3D,
) -> Result<Depiction3D, Error3D> {
if coords.len() != mol.num_atoms() {
return Err(Error3D::CoordCount {
atoms: mol.num_atoms(),
coords: coords.len(),
});
}
for (i, p) in coords.iter().enumerate() {
if !p.iter().all(|x| x.is_finite()) {
return Err(Error3D::NotFinite { atom: i });
}
}
let ranks = crate::ranks_of(mol);
let view = canonical_view(coords, &ranks);
let screen: Vec<[f64; 3]> = coords.iter().map(|p| view.apply(*p)).collect();
let mut items = build(mol, &screen, style);
items.sort_by(|a, b| a.key.cmp(&b.key));
let (scene, map) = to_scene(&items, style);
let placed = mol
.atoms()
.iter()
.zip(&screen)
.map(|(a, p)| Placed {
at: map(p[0], p[1]),
depth: p[2],
radius: style.ball_radius(rvdw_of(a.atomic_num)) * style.scale_pt_per_a,
})
.collect();
Ok(Depiction3D {
scene,
placed,
view,
style_name: style.name,
})
}
#[must_use]
pub fn canonical_view(coords: &[[f64; 3]], ranks: &[u32]) -> View {
if coords.len() < 2 {
return View::IDENTITY;
}
let mut order: Vec<usize> = (0..coords.len()).collect();
order.sort_by(|&i, &j| {
coords[i][0]
.total_cmp(&coords[j][0])
.then(coords[i][1].total_cmp(&coords[j][1]))
.then(coords[i][2].total_cmp(&coords[j][2]))
.then(ranks[i].cmp(&ranks[j]))
.then(i.cmp(&j))
});
let n = coords.len() as f64;
let mut centre = [0.0f64; 3];
for &i in &order {
for k in 0..3 {
centre[k] += coords[i][k];
}
}
for c in &mut centre {
*c /= n;
}
let mut cov = [0.0f64; 9];
for &i in &order {
let d = [
coords[i][0] - centre[0],
coords[i][1] - centre[1],
coords[i][2] - centre[2],
];
for a in 0..3 {
for b in 0..3 {
cov[a * 3 + b] += d[a] * d[b];
}
}
}
let Ok(eig) = symmetric_eigen(&cov, 3) else {
return View {
degenerate: true,
..View::IDENTITY
};
};
let mut axes = [[0.0f64; 3]; 3];
for (k, ax) in axes.iter_mut().enumerate() {
ax.copy_from_slice(eig.vector(k));
}
let base = axes;
let mut best: Option<Candidate> = None;
for s0 in [1.0f64, -1.0] {
for s1 in [1.0f64, -1.0] {
let a0 = [base[0][0] * s0, base[0][1] * s0, base[0][2] * s0];
let a1 = [base[1][0] * s1, base[1][1] * s1, base[1][2] * s1];
let a2 = [
a0[1] * a1[2] - a0[2] * a1[1],
a0[2] * a1[0] - a0[0] * a1[2],
a0[0] * a1[1] - a0[1] * a1[0],
];
let cand = [a0, a1, a2];
let mut key: Vec<[i64; 3]> = coords
.iter()
.map(|p| {
let d = [p[0] - centre[0], p[1] - centre[1], p[2] - centre[2]];
let mut out = [0i64; 3];
for (o, ax) in out.iter_mut().zip(&cand) {
*o = q(ax[0] * d[0] + ax[1] * d[1] + ax[2] * d[2]);
}
out
})
.collect();
key.sort_unstable();
#[allow(clippy::unnecessary_map_or)]
if best.as_ref().map_or(true, |(b, _)| key < *b) {
best = Some((key, cand));
}
}
}
let axes = best.expect("四种符号组合总有一个").1;
let scale = eig.values[0].abs().max(f64::MIN_POSITIVE);
let degenerate =
(0..2).any(|k| (eig.values[k] - eig.values[k + 1]).abs() <= DEGENERATE_TOL * scale);
View {
rot: axes,
centre,
degenerate,
}
}
type Candidate = (Vec<[i64; 3]>, [[f64; 3]; 3]);
struct Sortable {
prim: Primitive,
key: Key,
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct Key(i64, u8, [i64; 4], [u8; 3]);
fn q(x: f64) -> i64 {
let v = (x * QUANT).round();
if v > i64::MAX as f64 {
i64::MAX
} else if v < i64::MIN as f64 {
i64::MIN
} else {
v as i64
}
}
fn rvdw_of(atomic_num: u8) -> f64 {
let r = omgkit_core::element::by_atomic_num(atomic_num).map_or(0.0, |e| f64::from(e.rvdw));
if r > 0.0 {
r
} else {
UNKNOWN_RVDW
}
}
fn build(mol: &MolBuilder, screen: &[[f64; 3]], style: &Style3D) -> Vec<Sortable> {
let mut out = Vec::with_capacity(mol.num_atoms() + mol.num_bonds() * 4);
if style.stick_radius_a > 0.0 {
for b in mol.bonds() {
push_bond(
&mut out,
mol,
screen,
style,
b.begin as usize,
b.end as usize,
b.order,
);
}
}
for (i, a) in mol.atoms().iter().enumerate() {
let r = style.ball_radius(rvdw_of(a.atomic_num));
if r <= 0.0 {
continue;
}
let p = screen[i];
let at = Point2::new(p[0], p[1]);
let color = cpk(a.atomic_num);
out.push(Sortable {
prim: Primitive::Ball { at, r, color },
key: Key(q(p[2]), 0, [q(p[0]), q(p[1]), q(r), 0], color),
});
}
out
}
fn cylinders(order: BondOrder) -> usize {
match order {
BondOrder::Double => 2,
BondOrder::Triple | BondOrder::Quadruple => 3,
_ => 1,
}
}
#[allow(clippy::too_many_arguments)]
fn push_bond(
out: &mut Vec<Sortable>,
mol: &MolBuilder,
screen: &[[f64; 3]],
style: &Style3D,
ia: usize,
ib: usize,
order: BondOrder,
) {
let (pa, pb) = (screen[ia], screen[ib]);
let d = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
let flat = (d[0] * d[0] + d[1] * d[1]).sqrt();
let n_cyl = if style.multiple_bond_spacing_a > 0.0 && flat > f64::EPSILON {
cylinders(order)
} else {
1
};
let perp = if flat > f64::EPSILON {
[d[1] / flat, -d[0] / flat]
} else {
[0.0, 0.0]
};
let width = style.stick_radius_a * 2.0;
let ca = cpk(mol.atoms()[ia].atomic_num);
let cb = cpk(mol.atoms()[ib].atomic_num);
let ra = style.ball_radius(rvdw_of(mol.atoms()[ia].atomic_num));
let rb = style.ball_radius(rvdw_of(mol.atoms()[ib].atomic_num));
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
for k in 0..n_cyl {
#[allow(clippy::cast_precision_loss)]
let off = (k as f64 - (n_cyl as f64 - 1.0) / 2.0) * style.multiple_bond_spacing_a;
let sa = [pa[0] + perp[0] * off, pa[1] + perp[1] * off, pa[2]];
let sb = [pb[0] + perp[0] * off, pb[1] + perp[1] * off, pb[2]];
let mid = [
(sa[0] + sb[0]) / 2.0,
(sa[1] + sb[1]) / 2.0,
(sa[2] + sb[2]) / 2.0,
];
push_half(out, sa, mid, trim(ra, off, len), width, ca);
push_half(out, sb, mid, trim(rb, off, len), width, cb);
}
}
fn trim(r: f64, off: f64, len: f64) -> f64 {
let inside = (r * r - off * off).max(0.0).sqrt();
inside.min(len / 2.0)
}
fn push_half(
out: &mut Vec<Sortable>,
from: [f64; 3],
to: [f64; 3],
skip: f64,
width: f64,
color: [u8; 3],
) {
let half = dist(from, to);
if skip >= half {
return; }
let from = if skip > 0.0 {
lerp(from, to, skip / half)
} else {
from
};
let dz = (to[2] - from[2]).abs();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let n = ((dz / DEPTH_SLICE).ceil() as usize).max(1);
#[allow(clippy::cast_precision_loss)]
let nf = n as f64;
for s in 0..n {
#[allow(clippy::cast_precision_loss)]
let (t0, t1) = (s as f64 / nf, (s + 1) as f64 / nf);
let p0 = lerp(from, to, t0);
let p1 = lerp(from, to, t1);
let depth = (p0[2] + p1[2]) / 2.0;
out.push(Sortable {
prim: Primitive::Stick {
from: Point2::new(p0[0], p0[1]),
to: Point2::new(p1[0], p1[1]),
width,
color,
},
key: Key(q(depth), 1, [q(p0[0]), q(p0[1]), q(p1[0]), q(p1[1])], color),
});
}
}
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
let (x, y, z) = (b[0] - a[0], b[1] - a[1], b[2] - a[2]);
(x * x + y * y + z * z).sqrt()
}
fn lerp(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
[
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
]
}
fn to_scene(items: &[Sortable], style: &Style3D) -> (Scene, impl Fn(f64, f64) -> Point2) {
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
let mut note = |x: f64, y: f64, r: f64| {
lo[0] = lo[0].min(x - r);
lo[1] = lo[1].min(y - r);
hi[0] = hi[0].max(x + r);
hi[1] = hi[1].max(y + r);
};
for it in items {
match &it.prim {
Primitive::Ball { at, r, .. } => note(at.x, at.y, *r),
Primitive::Stick {
from, to, width, ..
} => {
note(from.x, from.y, width / 2.0);
note(to.x, to.y, width / 2.0);
}
_ => {}
}
}
if !lo[0].is_finite() {
lo = [0.0, 0.0];
hi = [0.0, 0.0];
}
let s = style.scale_pt_per_a;
let map = move |x: f64, y: f64| Point2::new((x - lo[0]) * s + PAD_PT, (hi[1] - y) * s + PAD_PT);
let out = items
.iter()
.map(|it| match &it.prim {
Primitive::Ball { at, r, color } => Primitive::Ball {
at: map(at.x, at.y),
r: r * s,
color: *color,
},
Primitive::Stick {
from,
to,
width,
color,
} => Primitive::Stick {
from: map(from.x, from.y),
to: map(to.x, to.y),
width: width * s,
color: *color,
},
other => other.clone(),
})
.collect();
(
Scene {
items: out,
width: (hi[0] - lo[0]) * s + PAD_PT * 2.0,
height: (hi[1] - lo[1]) * s + PAD_PT * 2.0,
},
map,
)
}
#[cfg(test)]
mod tests {
use super::{
canonical_view, cylinders, depict, dist, lerp, rvdw_of, trim, Error3D, Placed, Style3D,
DEPTH_SLICE, UNKNOWN_RVDW,
};
use crate::render::Primitive;
use crate::style::Style;
use crate::svg::to_svg;
use omgkit_core::MolBuilder;
fn prep(smi: &str) -> (MolBuilder, Vec<[f64; 3]>) {
let mut m = omgkit_io::smiles::parse(smi).expect("测试用的 SMILES 该能解析");
let c = omgkit_conf::pipeline::conformer_for(&mut m).expect("测试用的分子该能出构象");
(m, c.coords)
}
const CORPUS: [&str; 10] = [
"CCO",
"c1ccccc1",
"CC(=O)Oc1ccccc1C(=O)O",
"C1CC2CCC1CC2",
"C1C2CC3CC1CC(C2)C3",
"N#Cc1ccncc1",
"CC(C)(C)S(=O)(=O)N",
"[Na+].[Cl-]",
"C/C=C/C=C/C",
"OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O",
];
#[test]
fn 样式的半径与_jmol_文档一致() {
assert_eq!(Style3D::SPACE_FILLING.ball_vdw_frac, 1.0);
assert_eq!(Style3D::SPACE_FILLING.stick_radius_a, 0.0);
assert_eq!(Style3D::BALL_AND_STICK.ball_vdw_frac, 0.23);
assert_eq!(Style3D::BALL_AND_STICK.stick_radius_a, 0.15);
assert_eq!(Style3D::STICK.ball_vdw_frac, 0.0);
assert_eq!(Style3D::STICK.stick_radius_a, 0.30);
assert_eq!(Style3D::WIREFRAME.ball_vdw_frac, 0.0);
assert_eq!(Style3D::WIREFRAME.stick_radius_a, 0.01);
}
#[test]
fn 视角是真旋转不是镜像() {
for smi in CORPUS {
let (m, c) = prep(smi);
let v = canonical_view(&c, &crate::ranks_of(&m));
assert!(
(v.determinant() - 1.0).abs() < 1e-9,
"{smi}:行列式 {},不是 +1 —— 分子被镜像了",
v.determinant()
);
for i in 0..3 {
for j in 0..3 {
let dot: f64 = (0..3).map(|k| v.rot[i][k] * v.rot[j][k]).sum();
let want = f64::from(u8::from(i == j));
assert!(
(dot - want).abs() < 1e-9,
"{smi}:第 {i} 行与第 {j} 行的内积是 {dot},该是 {want}"
);
}
}
}
}
#[test]
fn 换个原子编号画出来逐字节相同() {
for smi in [
"CC(=O)Oc1ccccc1C(=O)O",
"c1ccccc1",
"C1CC2CCC1CC2",
"N#Cc1ccncc1",
"OCCOCCO",
] {
let (m, c) = prep(smi);
let (m2, c2) = renumbered(&m, &c);
assert_eq!(
omgkit_io::canon::canonical_smiles(&m).smiles,
omgkit_io::canon::canonical_smiles(&m2).smiles,
"{smi}:重新编号之后不是同一个分子,判据本身坏了"
);
for style in &Style3D::ALL {
let a = to_svg(&depict(&m, &c, style).unwrap().scene, &Style::ACS_1996);
let b = to_svg(&depict(&m2, &c2, style).unwrap().scene, &Style::ACS_1996);
assert_eq!(a, b, "{smi} / {}:重新编号之后画出来不一样", style.name);
}
}
}
fn renumbered(mol: &MolBuilder, coords: &[[f64; 3]]) -> (MolBuilder, Vec<[f64; 3]>) {
let n = mol.num_atoms();
let mut out = MolBuilder::with_capacity(n, mol.num_bonds());
for a in mol.atoms().iter().rev() {
out.add_atom_data(*a);
}
#[allow(clippy::cast_possible_truncation)]
let map = |old: u32| n as u32 - 1 - old;
for b in mol.bonds() {
let mut nb = *b;
nb.begin = map(b.begin);
nb.end = map(b.end);
out.add_bond_data(nb).unwrap();
}
let mut c = vec![[0.0; 3]; n];
for (i, p) in coords.iter().enumerate() {
c[n - 1 - i] = *p;
}
(out, c)
}
#[test]
fn 图元不出画布() {
for smi in CORPUS {
let (m, c) = prep(smi);
for style in &Style3D::ALL {
let d = depict(&m, &c, style).unwrap();
let s = &d.scene;
for it in &s.items {
let pts: Vec<(crate::geom::Point2, f64)> = match it {
Primitive::Ball { at, r, .. } => vec![(*at, *r)],
Primitive::Stick {
from, to, width, ..
} => vec![(*from, *width / 2.0), (*to, *width / 2.0)],
_ => panic!("三维图里出现了二维图元"),
};
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
);
}
}
}
}
}
#[test]
fn 重叠的球近的后画() {
for smi in CORPUS {
let (m, c) = prep(smi);
for style in [&Style3D::SPACE_FILLING, &Style3D::BALL_AND_STICK] {
let d = depict(&m, &c, style).unwrap();
let order = ball_order(&d.scene, &d.placed);
for i in 0..d.placed.len() {
for j in 0..i {
let (a, b) = (&d.placed[i], &d.placed[j]);
if a.at.dist(b.at) >= a.radius + b.radius {
continue; }
let (near, far) = if a.depth > b.depth { (i, j) } else { (j, i) };
if (a.depth - b.depth).abs() < 1e-12 {
continue; }
assert!(
order[near] > order[far],
"{smi} / {}:原子 {near}(深度 {:.3})比 {far}(深度 {:.3})靠前,\
却画在了前面",
style.name,
d.placed[near].depth,
d.placed[far].depth
);
}
}
}
}
}
fn ball_order(scene: &crate::render::Scene, placed: &[Placed]) -> Vec<usize> {
placed
.iter()
.map(|p| {
scene
.items
.iter()
.position(|it| {
matches!(it, Primitive::Ball { at, .. }
if (at.x - p.at.x).abs() < 1e-9 && (at.y - p.at.y).abs() < 1e-9)
})
.expect("每个原子都该有一个球")
})
.collect()
}
#[test]
fn 半棍从球面起步而不是从球心() {
let style = &Style3D::BALL_AND_STICK;
for smi in CORPUS {
let (m, c) = prep(smi);
let d = depict(&m, &c, style).unwrap();
let s = style.scale_pt_per_a;
for b in m.bonds() {
if cylinders(b.order) != 1 {
continue; }
for (near, far) in [(b.begin, b.end), (b.end, b.begin)] {
let p = d.view.apply(c[near as usize]);
let qv = d.view.apply(c[far as usize]);
let r = style.ball_vdw_frac
* f64::from(
omgkit_core::element::by_atomic_num(
m.atoms()[near as usize].atomic_num,
)
.unwrap()
.rvdw,
);
let len = dist(p, qv);
let start = lerp(p, qv, r.min(len / 2.0) / len);
let at = d.placed[near as usize].at;
let want = crate::geom::Point2::new(
at.x + (start[0] - p[0]) * s,
at.y - (start[1] - p[1]) * s,
);
assert!(
d.scene.items.iter().any(|it| matches!(
it,
Primitive::Stick { from, .. } if from.dist(want) < 0.01
)),
"{smi}:键 {near}-{far} 该从 ({:.2},{:.2}) 起步,图里没有这一段",
want.x,
want.y
);
}
}
}
}
#[test]
fn 输入怎么摆都画出同一张图() {
let (c1, s1) = (1.0f64.cos(), 1.0f64.sin());
let u = [
std::f64::consts::FRAC_1_SQRT_2 * 0.816_496_580_927_726,
0.577_350_269_189_626,
0.577_350_269_189_626,
];
let u = {
let n = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
[u[0] / n, u[1] / n, u[2] / n]
};
let rot = |p: [f64; 3]| {
let dot = u[0] * p[0] + u[1] * p[1] + u[2] * p[2];
let cross = [
u[1] * p[2] - u[2] * p[1],
u[2] * p[0] - u[0] * p[2],
u[0] * p[1] - u[1] * p[0],
];
let mut out = [0.0; 3];
for k in 0..3 {
out[k] = p[k] * c1 + cross[k] * s1 + u[k] * dot * (1.0 - c1) + 3.7;
}
out
};
for smi in CORPUS {
let (m, c) = prep(smi);
let moved: Vec<[f64; 3]> = c.iter().map(|p| rot(*p)).collect();
for style in &Style3D::ALL {
let a = depict(&m, &c, style).unwrap();
let b = depict(&m, &moved, style).unwrap();
assert!(
(a.scene.width - b.scene.width).abs() < 0.01
&& (a.scene.height - b.scene.height).abs() < 0.01,
"{smi} / {}:换个摆法画布尺寸都变了({:.2}×{:.2} vs {:.2}×{:.2})",
style.name,
a.scene.width,
a.scene.height,
b.scene.width,
b.scene.height
);
for (i, (x, y)) in a.placed.iter().zip(&b.placed).enumerate() {
assert!(
x.at.dist(y.at) < 0.01 && (x.depth - y.depth).abs() < 0.001,
"{smi} / {}:原子 {i} 换个摆法落到了别处 \
(({:.3},{:.3}) vs ({:.3},{:.3}))",
style.name,
x.at.x,
x.at.y,
y.at.x,
y.at.y
);
}
}
}
}
#[test]
fn 视角与样式无关() {
for smi in CORPUS {
let (m, c) = prep(smi);
let first = depict(&m, &c, &Style3D::ALL[0]).unwrap().view;
for style in &Style3D::ALL[1..] {
let v = depict(&m, &c, style).unwrap().view;
assert_eq!(
v.rot, first.rot,
"{smi}:{} 的视角与别的样式不同",
style.name
);
assert_eq!(v.centre, first.centre, "{smi}:{} 的中心不同", style.name);
}
}
}
#[test]
fn 样式关掉的东西一个都不发() {
let (m, c) = prep("CC(=O)Oc1ccccc1C(=O)O");
let sf = depict(&m, &c, &Style3D::SPACE_FILLING).unwrap();
assert!(
sf.scene
.items
.iter()
.all(|it| matches!(it, Primitive::Ball { .. })),
"空间填充不该有棍"
);
assert_eq!(
sf.scene.items.len(),
m.num_atoms(),
"空间填充该一个原子一个球"
);
for style in [&Style3D::STICK, &Style3D::WIREFRAME] {
let d = depict(&m, &c, style).unwrap();
assert!(
d.scene
.items
.iter()
.all(|it| matches!(it, Primitive::Stick { .. })),
"{} 不该有球",
style.name
);
}
}
#[test]
fn 三维图与二维规范无关() {
let (m, c) = prep("CC(=O)Oc1ccccc1C(=O)O");
for style in &Style3D::ALL {
let d = depict(&m, &c, style).unwrap();
assert_eq!(
to_svg(&d.scene, &Style::ACS_1996),
to_svg(&d.scene, &Style::CHEMDRAW_DEFAULT),
"{}:换套二维规范画出来不一样了",
style.name
);
}
}
#[test]
fn 对称强制简并的分子报视角退化() {
for smi in ["C", "C(Cl)(Cl)(Cl)Cl", "FC(F)(F)F", "N", "C#C"] {
let (m, c) = prep(smi);
let v = canonical_view(&c, &crate::ranks_of(&m));
assert!(v.degenerate, "{smi} 的主轴不唯一,该报退化");
}
for smi in [
"CC(=O)Oc1ccccc1C(=O)O",
"CCCCCCO",
"N#Cc1ccncc1",
"c1ccccc1",
"C1C2CC3CC1CC(C2)C3",
"O",
] {
let (m, c) = prep(smi);
let v = canonical_view(&c, &crate::ranks_of(&m));
assert!(!v.degenerate, "{smi} 的主轴是唯一的,不该报退化");
}
}
#[test]
fn 坏输入报错而不是画出来() {
let (m, c) = prep("CCO");
assert_eq!(
depict(&m, &c[..2], &Style3D::BALL_AND_STICK),
Err(Error3D::CoordCount {
atoms: m.num_atoms(),
coords: 2
})
);
let mut bad = c.clone();
bad[1][2] = f64::NAN;
assert_eq!(
depict(&m, &bad, &Style3D::BALL_AND_STICK),
Err(Error3D::NotFinite { atom: 1 })
);
bad[1][2] = f64::INFINITY;
assert_eq!(
depict(&m, &bad, &Style3D::BALL_AND_STICK),
Err(Error3D::NotFinite { atom: 1 })
);
}
#[test]
fn 没有范德华半径的原子仍然画得出来() {
assert_eq!(rvdw_of(0), UNKNOWN_RVDW);
assert!((rvdw_of(6) - 1.7).abs() < 1e-6, "碳该是 1.7");
let (m, c) = prep("*CC");
let d = depict(&m, &c, &Style3D::SPACE_FILLING).unwrap();
assert_eq!(d.scene.items.len(), m.num_atoms(), "通配原子也该有一个球");
assert!(d.placed[0].radius > 0.0, "通配原子的球半径不许是 0");
}
#[test]
fn 截长按球与圆柱轴的交点算() {
assert!((trim(0.4, 0.0, 1.5) - 0.4).abs() < 1e-12);
assert!((trim(0.4, 0.3, 1.5) - (0.16f64 - 0.09).sqrt()).abs() < 1e-12);
assert_eq!(trim(0.4, 0.5, 1.5), 0.0);
assert_eq!(trim(2.0, 0.0, 1.5), 0.75);
}
#[test]
fn 键按深度切得够细() {
for smi in CORPUS {
let (m, c) = prep(smi);
let d = depict(&m, &c, &Style3D::STICK).unwrap();
let want: usize = m
.bonds()
.iter()
.map(|b| {
let dz = (d.placed[b.begin as usize].depth - d.placed[b.end as usize].depth)
.abs()
/ 2.0;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let per_half = ((dz / DEPTH_SLICE).ceil() as usize).max(1);
2 * per_half
})
.sum();
assert_eq!(
d.scene.items.len(),
want,
"{smi}:{} 根键该切成 {want} 片",
m.num_bonds()
);
assert!(want >= 2 * m.num_bonds(), "期望片数算错了");
}
}
}