omgkit_depict/lib.rs
1//! 2D 坐标生成与分子结构绘图。
2//!
3//! ```
4//! use omgkit_depict::{generate, style::Style};
5//!
6//! let mut m = omgkit_io::smiles::parse("OC(=O)c1ccccc1").unwrap();
7//! omgkit_chem::pipeline::sanitize(&mut m).unwrap();
8//!
9//! let d = generate(&m, &Style::ACS_1996);
10//! assert_eq!(d.coords.len(), m.num_atoms());
11//! ```
12//!
13//! # 布局与绘制**不是**两件独立的事
14//!
15//! 几何骨架确实与规范无关:苯环是正六边形,跟用什么字号没关系。但**"算不算挤
16//! 在一起"是规范相关的** —— 原子标签要占地方,而标签尺寸与键长的比例随规范变:
17//!
18//! | | ACS 1996 | ChemDraw 默认 |
19//! |---|---|---|
20//! | 键长 | 14.4 pt | 30 pt |
21//! | 原子标签 | 10 pt | 10 pt |
22//! | **标签占一个键长的** | **69%** | **33%** |
23//!
24//! 所以 [`Style`] 同时喂给布局和绘制,而 [`Depiction`] 记下产生它的规范指纹 ——
25//! 拿另一套规范去渲染时可以被查出来,不会静默地挤成一团。
26//!
27//! # 一张图由它自己决定,不由它被写成什么样决定
28//!
29//! 同一个分子的任何 SMILES 写法给出**全等**的图。布局的每一处平局都按
30//! [`ranks_of`] 打破,不看原子的存储下标。这一条有判据守着。
31//!
32//! # 画不好的地方会说出来
33//!
34//! 桥环、笼状体系在平面上没有好解,拥挤到一定程度的取代基也排不开。这些如实记在
35//! [`Depiction::degraded`] 与 [`Depiction::unresolved`] 里,不假装成功。
36
37/// 布局用的原子秩:**先按对称等价类,类内再按规范 SMILES 的输出次序**。
38///
39/// 不是 [`canonical_ranks`](omgkit_io::canon::canonical_ranks) —— 那一个的
40/// 深层平局是任取的。
41///
42/// # `canonical_ranks` 在哪一步失守
43///
44/// 它自己的模块文档写着「**更深层次的并列仍是任取**」:`break_all_ties` 把每个
45/// 还没分开的格里**存储序最靠前**的那个原子劈出去。对大多数分子这不要紧(细化
46/// 早就分完了),可一旦剩下真正的对称,秩就跟着写法走。
47///
48/// 这不是理论上的。**内消旋分子最吃亏**:1-乙炔基-4-苯基环己醇(语料第 574 行)
49/// 有一个穿过 C1、C4 的镜面,两条环支路**构造上等价、构型上相反**。1-WL 细化
50/// 分不开它们 —— 立体宇称是相对邻居的**等价类**算的,镜面下不变。于是任取一头,
51/// 两种写法把楔形画到了相反的方向;两张图都对,但不是同一张。
52///
53/// **它们并不真的等价** —— 这一点是修法能成立的前提,值得说死。把两种写法各按
54/// 自己的 `canonical_ranks` 写成规范风格的串:
55///
56/// ```text
57/// 写法 1: C#C[C@@]1(CC[C@@H](CC1)c1ccccc1)O
58/// 写法 2: C#C[C@] 1(CC[C@H] (CC1)c1ccccc1)O
59/// ```
60///
61/// 骨架逐字相同,**每一个立体标记都翻了** —— 两套标号差的是一个**反自同构**
62/// (镜面),不是自同构。若真是自同构下等价,两串会完全一样,那么"遍历起点取
63/// 字典序最小"也同样分不开,本函数就白写了。正因为不等价,取最小串把这个自由度
64/// 消掉了。
65///
66/// # 为什么是"类在前、序在后",不是直接用输出次序
67///
68/// 两种秩的**语义不同**,这一点是实测撞出来的:
69///
70/// - [`symmetry_classes`](omgkit_io::canon::symmetry_classes) 是 1-WL 细化的
71/// 结果 —— 类编号反映**结构角色**,布局的启发式吃的正是这个。
72/// - [`atom_order`](omgkit_io::smiles::Written::atom_order) 是规范 SMILES 的
73/// **DFS 输出序** —— 唯一、含立体,但结构上是任意的。
74///
75/// 直接拿输出次序当秩(试过,全量实测):头号契约降到 2,可**键交叉从 50 涨到
76/// 78**,重跑模板生成器也只收回到 70。布局把"秩小"当"重要",而 DFS 序不是那个
77/// 意思。
78///
79/// 分两级就两头都拿到了:主键仍是细化出来的类(结构语义原样保留),**只有类内
80/// 那点任取被换成规范次序**。
81///
82/// # 三个方案的全量对照
83///
84/// 8831 分子 × 2 规范 × 30 种写法,**三列都用同一张(旧)模板表**,好把"换秩"
85/// 这一件事单独看清:
86///
87/// | | `canonical_ranks` | 纯输出次序 | **类 + 输出次序** |
88/// |---|---:|---:|---:|
89/// | **写法无关(头号契约)** | 9 | 2 | **3** |
90/// | 其中有键交叉 | 50 | 78 | **48** |
91/// | 干净 | 16191 | 16169 | **16192** |
92/// | 有未解冲突 | 1131 | 1150 | **1130** |
93/// | 标签塞不下 | 859 | 846 | **858** |
94/// | 键角不过窄 | 180 / 16191 | — | **181 / 16192** |
95/// | 硬性质其余八条 | 基准 | 基准 | **一处没动** |
96/// | 外部判官 | 496 / 0 | 496 / 0 | **496 / 0** |
97///
98/// `键角不过窄` 那一格不是新缺陷,是判据的适用范围变大了(一个分子变干净了,
99/// 首次进入这条判据)—— 分母也同步 +1,细节见 `harness/README.md`。
100///
101/// 取第三列:**头号契约降三分之二,而质量指标全部持平或略好**,不是拿别处换的。
102///
103/// 换秩之后模板表跟着重生成了(否则「重跑逐字节相同」这条验收作废),那一步
104/// 另有代价 —— 见 `harness/README.md`。
105///
106/// # 一定要与 [`hydrogens::with_stereo_hs`] 用同一个
107///
108/// 补出来的氢按秩排序追加,它若与这里的秩不同源,同一个分子换种写法补出来的
109/// 氢就会拿到不同的原子号 —— 后面整条管线跟着变。
110/// # 实现搬到 `omgkit-io` 去了,论证留在这儿
111///
112/// 三维构象生成(`omgkit-conf`,已随 0.0.5 发布)也要
113/// 这个秩,而它**不该依赖绘图 crate**。所以函数体挪到了
114/// [`omgkit_io::canon::classed_ranks`],这里只剩一层转发。
115///
116/// 上面那些数(键交叉、标签塞不下、外部判官)全是**绘图指标**,放在解析/
117/// 规范化那一层讲不通,所以留在这里 —— 权威说明是本函数,`classed_ranks`
118/// 那边只写契约。
119#[must_use]
120pub fn ranks_of(mol: &omgkit_core::MolBuilder) -> Vec<u32> {
121 omgkit_io::canon::classed_ranks(mol)
122}
123
124pub mod chains;
125pub mod geom;
126pub mod hydrogens;
127pub mod label;
128pub mod layout;
129pub mod orient;
130/// Jmol 的 CPK 元素配色 —— 三维图按元素上色用的那张表。
131pub mod palette;
132mod palette_data;
133// 位图输出(PNG / JPEG)。模块自己的 `//!` 已经写清楚了 —— 这里再挂一层 `///`
134// 的话,两段文档会合并,而合并后整段的链接是按**外层**(crate 根)的作用域解析的,
135// 于是 `[`to_png`]` 这类同模块内的链接全部解析不了,`cargo doc` 直接报错。
136/// 桥环的几何摆法。**离线的模板生成器与运行时都用它**;运行时排在查表之后、
137/// [`rings::relax`] 之前 —— 见模块文档「什么时候轮到它」。
138mod arcs;
139#[cfg(feature = "raster")]
140pub mod raster;
141pub mod refine;
142pub mod render;
143pub mod rings;
144pub mod stereo;
145
146pub mod style;
147pub mod svg;
148/// 桥环骨架的预存坐标表。见模块文档。
149pub mod templates;
150pub mod three;
151
152use std::collections::BTreeMap;
153
154use omgkit_core::MolBuilder;
155
156use geom::Point2;
157use rings::Degradation;
158use style::Style;
159
160/// 一张 2D 图。
161///
162/// # 下标是相对**被画的那个分子**的
163///
164/// 为了画出构型,某些立体中心要补一个显式氢(见 [`hydrogens`])。那时被画的
165/// 分子比传进来的多几个原子,而 `coords`、`wedges` 这些逐原子/逐键的向量是按
166/// **补完之后**的编号排的 —— 拿 [`Depiction::drawn`] 取回那个分子。
167///
168/// **前 `mol.num_atoms()` 个原子、前 `mol.num_bonds()` 根键与传入的分子逐项
169/// 对应**,所以按原下标索引仍然是对的;多出来的排在后面。
170#[derive(Debug, Clone, PartialEq)]
171pub struct Depiction {
172 /// 逐原子坐标,下标与**被画的那个分子**([`Depiction::drawn`])一致。
173 ///
174 /// 单位是**键长**,不是埃 —— 2D 结构图不是比例模型。换算成 pt/px 由
175 /// [`Style::bond_length_pt`] 负责。
176 pub coords: Vec<Point2>,
177 /// 布局中不得不退化的地方(桥环等)。
178 pub degraded: Vec<Degradation>,
179 /// 消冲突之后**仍然挤着**的原子对。
180 pub unresolved: Vec<(u32, u32)>,
181 /// 仍然交叉的键对。
182 pub crossings: Vec<(u32, u32)>,
183 /// 逐键的楔形指派,下标与 [`MolBuilder`] 的键下标一致。
184 pub wedges: Vec<stereo::Wedge>,
185 /// **没能画出构型的立体中心**。如实报出来,不假装画好了。
186 pub unwedged: Vec<u32>,
187 /// **画出来的几何与记录的顺反不符的双键。**
188 ///
189 /// 掰顺反靠的是"把双键一侧整个镜像过去",而环上的
190 /// 键两侧是同一片原子,镜像动不了它 —— 环内的双键画成什么样由环的画法决定,
191 /// 而环按凸多边形画,环内双键一律画成顺式。八元以上的环里记着反式的双键
192 /// 因此**画出来是反的**,读这张图的人拿到的是另一个分子。
193 ///
194 /// 先前这一档谁都不报:`fix_cis_trans` 的返回值被丢掉,四个诊断字段一个也
195 /// 装不下它,`is_clean()` 照样为真。README 说"画不好会说出来",这里没说。
196 pub misdrawn_stereo: Vec<u32>,
197 /// 产生这张图的规范名。
198 pub style_name: &'static str,
199 /// 规范中**影响布局**那部分的指纹。
200 ///
201 /// 拿另一套规范渲染时,用它可以查出错配 —— 见 [`Style::layout_fingerprint`]。
202 pub style_fingerprint: u64,
203 /// 为了画出构型补出来的原子/键。空的话画的就是传进来的分子。
204 pub added: hydrogens::Augmented,
205}
206
207impl Depiction {
208 /// 这张图是不是按 `style` 排的。
209 ///
210 /// 只比布局相关的那部分:换个线宽、换个字体不会让已有坐标失效。
211 #[must_use]
212 pub fn matches(&self, style: &Style) -> bool {
213 self.style_fingerprint == style.layout_fingerprint()
214 }
215
216 /// **真正被画的那个分子。** 没补东西时就是传进来的那个,不复制。
217 ///
218 /// `coords`、`wedges`、`unresolved`、`crossings`、`unwedged`、`degraded`、
219 /// `misdrawn_stereo` 的下标全部相对它。渲染与判据都该拿它,而不是拿传进来的分子 —— 否则
220 /// 补出来的氢会被静默丢掉,而诊断全绿。
221 ///
222 /// 返回 [`Cow`](std::borrow::Cow),所以 `&d.drawn(&m)` 在要 `&MolBuilder`
223 /// 的地方直接能用(靠 `Deref`)。
224 #[must_use]
225 pub fn drawn<'a>(&self, mol: &'a MolBuilder) -> std::borrow::Cow<'a, MolBuilder> {
226 if self.added.is_empty() {
227 std::borrow::Cow::Borrowed(mol)
228 } else {
229 std::borrow::Cow::Owned(self.added.apply(mol))
230 }
231 }
232
233 /// 有没有任何一处没画好(退化、仍在碰撞、仍有交叉)。
234 #[must_use]
235 pub fn is_clean(&self) -> bool {
236 self.degraded.is_empty()
237 && self.unresolved.is_empty()
238 && self.crossings.is_empty()
239 && self.unwedged.is_empty()
240 && self.misdrawn_stereo.is_empty()
241 }
242}
243
244/// 判据共用的解析 + 净化。
245#[cfg(test)]
246pub(crate) fn tests_prep(smi: &str) -> MolBuilder {
247 let mut m = omgkit_io::smiles::parse(smi).expect("测试用的 SMILES 该能解析");
248 omgkit_chem::pipeline::sanitize(&mut m).expect("测试用的分子该能净化");
249 m
250}
251
252/// 给分子生成 2D 坐标。
253///
254/// 分子**应当先净化** —— 环感知的结果决定环系统怎么划分。没净化过也能跑,
255/// 但环会被当成链画出来。
256///
257/// # 顺反还要单独感知一次
258///
259/// 净化那 12 步里**没有**双键顺反感知(它要用对称等价类,那在净化的上一层,
260/// 调不到)。只跑了净化的分子,每根双键的
261/// [`stereo`](omgkit_core::BondData::stereo) 都是 `None`,于是顺反校正
262/// (`stereo::fix_cis_trans`)整个空转 —— **E/Z 可能画反,而线条本身看着一点
263/// 毛病没有**。这是"画错了",不是"没画好",所以 debug 构建下当场拦住:
264///
265/// ```no_run
266/// # use omgkit_core::MolBuilder;
267/// # fn demo(mut mol: MolBuilder) {
268/// omgkit_chem::pipeline::sanitize(&mut mol).unwrap();
269/// omgkit_io::stereo::perceive_bond_stereo(&mut mol); // ← 别漏
270/// let d = omgkit_depict::generate(&mol, &omgkit_depict::style::Style::ACS_1996);
271/// # let _ = d;
272/// # }
273/// ```
274///
275/// 判法见
276/// [`directions_not_perceived`](omgkit_io::stereo::directions_not_perceived) ——
277/// 只有"双键两端的方向键成对写着、而它自己没有顺反"才报,没写方向的分子一律
278/// 放行。release 下不做这个检查。
279///
280/// (Python 绑定的 `Mol.sanitize()` 把两步合在一起,从 Python 看不出这个区别。)
281#[must_use]
282pub fn generate(mol: &MolBuilder, style: &Style) -> Depiction {
283 debug_assert!(
284 !omgkit_io::stereo::directions_not_perceived(mol),
285 "这个分子的双键几何**方向键已经写明**、却没有感知过顺反 —— \
286 漏了 omgkit_io::stereo::perceive_bond_stereo。这样画不会报错,\
287 但顺反校正整个空转,E/Z 可能画反"
288 );
289 generate_with(mol, style, None)
290}
291
292/// 同 [`generate`],但可以临时顶替模板表里某一条。**只给离线的模板生成器用。**
293///
294/// 生成器要问"把这组坐标装进去之后,真实分子画出来好不好",而 `generate` 会查
295/// 那张表 —— 表正是它在生成的东西。这个参数把那层循环拆开,见
296/// [`templates::Override`]。
297pub(crate) fn generate_with(
298 mol: &MolBuilder,
299 style: &Style,
300 over: templates::Override<'_>,
301) -> Depiction {
302 // **先补显式氢,再做别的。** 有些立体中心三根键全在环上,唯一合法的楔形是
303 // C–H —— 那个氢不画出来,构型就只能画到环键上(见 [`hydrogens`])。
304 //
305 // 补出来的原子接在**末尾**,原有编号一概不变,所以下面整条管线原样跑在补完
306 // 的分子上就行:布局、消冲突、规范朝向、楔形指派全都自动把那个氢算进去。
307 let added = hydrogens::with_stereo_hs(mol).unwrap_or_default();
308 let grown = (!added.is_empty()).then(|| added.apply(mol));
309 let mol = grown.as_ref().unwrap_or(mol);
310
311 let ranks = ranks_of(mol);
312
313 // **配位键在几何上就是一根线。** 环感知按化学口径把配位键排除在环外
314 // (`omgkit_chem::sssr` 的约定),而布局是几何,照那个口径走的话
315 // `N->1CCCCC1` 就被当成一条链,闭环的那根键被拉到 4 个键长长。
316 //
317 // 所以布局用一份把配位键当单键的副本。拓扑、原子编号都不变,坐标直接对得上;
318 // 画的时候仍按原分子的键级走。
319 let laid = as_plain_bonds(mol);
320 let mol = laid.as_ref().unwrap_or(mol);
321
322 // **η5 配位的那 5 根 σ 键会把环感知搅成一团。** 布局只留一根代表键,
323 // 见 [`hapto_extras`]。渲染仍拿原分子,一根键不少。
324 let hapto = hapto_extras(mol, &ranks);
325 let thinned = hapto.as_ref().and_then(|(extras, _)| {
326 let mut copy = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
327 for a in mol.atoms() {
328 copy.add_atom_data(*a);
329 }
330 for (bi, b) in mol.bonds().iter().enumerate() {
331 if !extras.contains(&bi) {
332 copy.add_bond_data(*b).ok()?;
333 }
334 }
335 omgkit_chem::pipeline::sanitize(&mut copy).ok()?;
336 Some(copy)
337 });
338 // 摘细之前的那份 —— 交叉要拿它再算一遍,见下面
339 let whole = mol;
340 let mol = thinned.as_ref().unwrap_or(mol);
341
342 let mut pieces = layout::layout_all(mol, &ranks, style, over);
343 // **分量从左到右的次序也要与写法无关。** 分量本身是按连通性收集的,次序跟着
344 // 原子的存储下标走 —— 同一个盐换个写法,两个离子就左右对调,于是整张图的
345 // 每一个图元都挪了位。实测:语料里 4 个盐/配合物正是这么差出来的。
346 pieces.sort_by_key(|p| {
347 p.pos
348 .keys()
349 .map(|a| ranks[*a as usize])
350 .min()
351 .unwrap_or(u32::MAX)
352 });
353 let mut degraded: Vec<Degradation> = pieces.iter().flat_map(|p| p.degraded.clone()).collect();
354 // η 配位那几根键在平面上不可能等长,如实记一笔 ——
355 // 见 [`Degradation::HaptoCoordination`](rings::Degradation::HaptoCoordination)。
356 if let Some((_, told)) = &hapto {
357 degraded.extend(told.iter().cloned());
358 }
359
360 // 分量并排摆开,再一起消冲突 —— 分量之间也可能撞上。
361 //
362 // **宽度要按包围盒算,不能只按原子中心。** 原子标签向两侧伸出去,只按中心
363 // 排会让相邻分量的标签叠在一起,而消冲突动不了分量(它只翻可旋转键,单原子
364 // 分量连键都没有)。实测:NaCl 的两个离子中心间距正好 1.0,而两个标签半径
365 // 各约 0.5 —— 正好贴上。
366 // **逐原子/逐键的东西一律拿 `whole`。** 摘细是删键,键下标会整体前移
367 // —— 拿摘细副本算出来的逐键向量与被画的分子对不上号。
368 let radii = refine::radii(whole, style);
369 let mut pos: BTreeMap<u32, Point2> = BTreeMap::new();
370 let mut shift = 0.0f64;
371 for p in &pieces {
372 let (lo, hi) = extent(p.pos.iter().map(|(a, q)| (*q, radii[*a as usize])));
373 for (a, q) in &p.pos {
374 pos.insert(*a, Point2::new(q.x - lo + shift, q.y));
375 }
376 shift += hi - lo + PIECE_GAP;
377 }
378
379 // **顺反先摆对,再消冲突。** 反过来的话,消冲突翻的那几根键会把顺反弄反 ——
380 // 见 `stereo::fix_cis_trans` 的文档。
381 let mut flat = vec![Point2::ORIGIN; mol.num_atoms()];
382 for (a, q) in &pos {
383 flat[*a as usize] = *q;
384 }
385 stereo::fix_cis_trans(whole, &mut flat, &ranks);
386 for (a, q) in pos.iter_mut() {
387 *q = flat[*a as usize];
388 }
389
390 let mut report = refine::relieve(mol, &mut pos, &ranks, style);
391
392 // **摘细过的话,交叉要拿原分子再算一遍。** 消冲突跑在摘细过的副本上,
393 // 看不见被摘掉的那些 η5 键 —— 而它们照样会画出来。二茂铁实测:不补这一步
394 // 报的是 0 处交叉,而图上明明有。**画不好就要说出来。**
395 //
396 // 只在真摘过的时候算(全量语料 2 个分子),所以不给别人添成本。
397 if thinned.is_some() {
398 report.crossings = refine::crossings(whole, &pos);
399 }
400
401 let mut coords = vec![Point2::ORIGIN; mol.num_atoms()];
402 for (a, q) in pos {
403 coords[a as usize] = q;
404 }
405
406 // **摆正要排在楔形指派之前。** 规范朝向里可能含一次镜像,而镜像会把手性
407 // 画反;楔形是照最终坐标算的,先摆正再指派,构型自然是对的。反过来做会把
408 // 已经画好的楔形悬空 —— 而且线条本身看不出毛病。
409 orient::canonicalise(&mut coords, &ranks);
410
411 // 同上:`Depiction::wedges` 的下标必须与**被画的那个分子**一致。
412 // 拿摘细副本的话 `wedges.len()` 会比键数少,`dump_molblock` 那种按下标
413 // 取的调用方直接越界 —— 实测二茂铁 wedges.len()=12 而键数 20。
414 let w = stereo::assign_wedges(whole, &coords, &ranks);
415
416 // **顺反的诊断要放在坐标全部定稿之后。** 掰顺反(`fix_cis_trans`)之后还有
417 // 消冲突与摆正两步,而消冲突挪原子时会把已经摆对的顺反再弄反 —— 那正是
418 // "顺反先摆对再消冲突"那条注释里说的隐患。在这里照最终坐标量一遍,量的
419 // 就是读图的人真正看到的东西。
420 let misdrawn_stereo = stereo::stereo_mismatches(whole, &coords);
421
422 Depiction {
423 coords,
424 wedges: w.bonds,
425 unwedged: w.unwedged,
426 misdrawn_stereo,
427 degraded,
428 unresolved: report.unresolved,
429 crossings: report.crossings,
430 style_name: style.name,
431 style_fingerprint: style.layout_fingerprint(),
432 added,
433 }
434}
435
436/// 环上被 `picked` 选中的那些原子,是不是**首尾相接的一段**(整圈也算)。
437///
438/// `ring` 是 [`omgkit_chem::sssr::Ring::atoms`],已经按环序排好。
439///
440/// # 这一条把大环螯合物挡在外面
441///
442/// 只数"金属打进这个环几根键"是不够的:卟啉、酞菁、环多胺那一类,摘掉金属之后
443/// 照样是个环,而金属对它有 4 根键。它们与 η 配位的差别在**位置**——
444/// η5 的 Cp 是 5 个**首尾相接**的碳,而 cyclam 的 4 个 N 之间隔着 2~3 个碳。
445fn contiguous_on_ring(ring: &[u32], picked: &std::collections::BTreeSet<u32>) -> bool {
446 let n = ring.len();
447 let k = ring.iter().filter(|a| picked.contains(a)).count();
448 if k < 2 {
449 return k == 1;
450 }
451 if k == n {
452 return true; // 整圈都选上了
453 }
454 // 选中的原子之间"断开"了几次;连续的一段只断一次
455 let breaks = (0..n)
456 .filter(|i| picked.contains(&ring[*i]) && !picked.contains(&ring[(i + 1) % n]))
457 .count();
458 breaks == 1
459}
460
461/// η<sup>n</sup> 配位里**多余的那些键**,给布局用。
462///
463/// # 二茂铁的 Fe 度数是 10
464///
465/// SMILES 把 η5 配位写成 5 根独立的 σ 键(`[Fe]23456789` 这样),于是环感知
466/// 吐出 9 到 10 个**三元环**(Fe + 环上相邻两个碳),它们全是这个建模方式的
467/// 假象。整个体系被当成一个巨大的桥环系统,画出来两个 Cp 环叠在一起 ——
468/// 实测 ChemDraw 规范下 8 处键交叉。
469///
470/// # 认法:摘掉金属的键再看配体自己的环
471///
472/// 金属 M 的键全摘掉之后做环感知,得到的才是**配体自己的环**。M 与某个这样的
473/// 环之间有 ≥3 根键,就是 η<sup>n</sup> 配位。
474///
475/// 全量语料实测:8831 个分子里**只有 2 个**(都是二茂铁,都是 η5 × 2)。
476/// 范围小,所以这里做的也小。
477///
478/// # 只留一根代表键
479///
480/// 布局要的是"Cp 是个普通五元环、金属挂在它旁边",所以每个 (金属, 环) 只留
481/// **一根**键、其余摘掉。留哪一根按**规范秩**定,不看存储下标 —— 头号契约。
482///
483/// 摘掉之后二茂铁的 Fe 只剩两根键(每个 Cp 一根),自然成了两个五边形中间的
484/// 那个连接原子,也就是夹心式的画法。
485///
486/// **只动布局。** 渲染仍拿原分子,10 根键一根不少地画出来。
487fn hapto_extras(
488 mol: &MolBuilder,
489 ranks: &[u32],
490) -> Option<(std::collections::BTreeSet<usize>, Vec<Degradation>)> {
491 // 会做 π 配位的元素从宽收 —— 这里只是找候选,`>=3 根键进同一个环`
492 // 那一条才是判据。
493 //
494 // **"什么算金属"只有一处实现。** 先前这里自己写了一份区间表,与
495 // `omgkit_chem::organometallics::is_metal` 的元素集合当前恰好相同 ——
496 // 而"恰好相同"不是性质,是巧合:两份表迟早分岔,分岔的表现是一半的
497 // 有机金属分子摘了 η 键、另一半没摘,而图看着都正常。
498 let metals: Vec<u32> = (0..u32::try_from(mol.num_atoms()).ok()?)
499 .filter(|a| {
500 omgkit_chem::organometallics::is_metal(mol.atoms()[*a as usize].atomic_num)
501 && mol.degree(*a) >= 3
502 })
503 .collect();
504 if metals.is_empty() {
505 return None;
506 }
507
508 // 配体自己的环:把金属的键全摘掉再感知
509 let mut lig = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
510 for a in mol.atoms() {
511 lig.add_atom_data(*a);
512 }
513 for b in mol.bonds() {
514 if !metals.contains(&b.begin) && !metals.contains(&b.end) {
515 lig.add_bond_data(*b).ok()?;
516 }
517 }
518 omgkit_chem::pipeline::sanitize(&mut lig).ok()?;
519 let rings = omgkit_chem::sssr::ring_set(&lig);
520
521 let mut extras = std::collections::BTreeSet::new();
522 let mut told = Vec::new();
523 for m in &metals {
524 for r in &rings {
525 // 这个金属打进这个环的那些键
526 let mut into: Vec<(u32, usize)> = mol
527 .neighbors(*m)
528 .filter(|(nb, _)| r.atoms.contains(nb))
529 .map(|(nb, bi)| (ranks[nb as usize], bi as usize))
530 .collect();
531 if into.len() < 3 {
532 continue;
533 }
534 // **必须是环上连续的一段。** 只数键数会把**大环螯合物**一起收进来:
535 // 卟啉、酞菁、环多胺那一类,摘掉金属之后照样是个环,而金属对它有
536 // 4 根键 —— 全部满足"≥3 根"。
537 //
538 // 实测 Ni(环四胺) `[Ni]123N4CCN1CCCN2CCN3CCC4`:只数键数的话
539 // 键交叉 **0 → 5**,Ni 到四个 N 的距离从 0.95…1.24 变成
540 // 1.00 / 3.51 / 4.34 / 5.49 —— **一张本来画对的图被改坏了**,而且
541 // 报的退化写着 η 配位,化学上根本不成立(cyclam 是 σ 给体)。
542 //
543 // 语料里一个大环配合物都没有,所以全量指标看不见这一条 ——
544 // 这是**审核拿真实化合物试出来的**,不是量出来的。
545 //
546 // 真正的 η 配位是金属压在**一整段连续的 π 体系**上(Cp 的 5 个碳
547 // 首尾相接),而 cyclam 的 4 个 N 之间隔着 2~3 个碳。
548 let bonded: std::collections::BTreeSet<u32> =
549 mol.neighbors(*m).map(|(nb, _)| nb).collect();
550 if !contiguous_on_ring(&r.atoms, &bonded) {
551 continue;
552 }
553 // 规范秩最小的那个邻居留下,其余摘掉
554 into.sort_unstable();
555 extras.extend(into.into_iter().skip(1).map(|(_, bi)| bi));
556 // **如实报退化。** 那 n 根键在平面上不可能等长,见
557 // [`Degradation::HaptoCoordination`]。次序按规范秩,写法无关。
558 let mut ring: Vec<u32> = r.atoms.clone();
559 ring.sort_by_key(|a| (ranks[*a as usize], *a));
560 told.push(Degradation::HaptoCoordination { metal: *m, ring });
561 }
562 }
563 (!extras.is_empty()).then_some((extras, told))
564}
565
566/// 把配位键换成单键的副本;没有配位键就返回 `None`(不必复制)。
567///
568/// 只动键级,拓扑与原子编号一概不变 —— 坐标因此可以直接用在原分子上。
569fn as_plain_bonds(mol: &MolBuilder) -> Option<MolBuilder> {
570 if !mol
571 .bonds()
572 .iter()
573 .any(|b| b.order == omgkit_core::BondOrder::Dative)
574 {
575 return None;
576 }
577 let mut copy = MolBuilder::with_capacity(mol.num_atoms(), mol.num_bonds());
578 for a in mol.atoms() {
579 copy.add_atom_data(*a);
580 }
581 for b in mol.bonds() {
582 let mut bd = *b;
583 if bd.order == omgkit_core::BondOrder::Dative {
584 bd.order = omgkit_core::BondOrder::Single;
585 }
586 copy.add_bond_data(bd).ok()?;
587 }
588 // 换了键级就要重新做环感知,否则新成的环没人知道
589 omgkit_chem::pipeline::sanitize(&mut copy).ok()?;
590 Some(copy)
591}
592
593/// 相邻两个分量之间留的空当,单位是键长。半个键长足以看出是两块东西。
594const PIECE_GAP: f64 = 0.5;
595
596/// 一组带半径的点在 x 方向上的范围(含半径)。空集给 (0, 0)。
597fn extent(pts: impl Iterator<Item = (Point2, f64)>) -> (f64, f64) {
598 let mut lo = f64::INFINITY;
599 let mut hi = f64::NEG_INFINITY;
600 for (p, r) in pts {
601 lo = lo.min(p.x - r);
602 hi = hi.max(p.x + r);
603 }
604 if lo.is_finite() {
605 (lo, hi)
606 } else {
607 (0.0, 0.0)
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 /// 解析 + 净化 + **顺反感知**。第三步不在净化的 12 步里,漏了的话每根双键的
616 /// `stereo` 都是 `None`,顺反校正整个空转 —— 顺式反式画成同一张图而判据照样
617 /// 绿。[`generate`] 的 `debug_assert!` 现在会当场拦住这种输入。
618 fn prep(smi: &str) -> MolBuilder {
619 let mut m = omgkit_io::smiles::parse(smi).unwrap();
620 omgkit_chem::pipeline::sanitize(&mut m).unwrap();
621 omgkit_io::stereo::perceive_bond_stereo(&mut m);
622 m
623 }
624
625 /// 形状指纹:两两距离排序后的多重集。与原子编号、平移、旋转、镜像都无关。
626 fn shape_key(smi: &str, style: &Style) -> Vec<i64> {
627 let d = generate(&prep(smi), style);
628 let mut ds: Vec<i64> = (0..d.coords.len())
629 .flat_map(|i| ((i + 1)..d.coords.len()).map(move |j| (i, j)))
630 .map(|(i, j)| (d.coords[i].dist(d.coords[j]) * 1e4).round() as i64)
631 .collect();
632 ds.sort_unstable();
633 ds
634 }
635
636 #[test]
637 fn the_same_molecule_written_differently_gets_the_same_picture() {
638 // 整个库的核心不变量。**它属于最终输出,不属于中间阶段** —— 布局本身
639 // 会因 SSSR 给出的环原子顺序而有差异,消冲突之后才收敛。所以这条判据
640 // 放在 `generate` 这一层;放在 `layout_all` 上是测错了对象。
641 let groups = [
642 vec![
643 "CC(=O)Oc1ccccc1C(=O)O",
644 "O=C(C)Oc1ccccc1C(O)=O",
645 "OC(=O)c1ccccc1OC(C)=O",
646 ],
647 vec!["CC(C)(C)c1ccccc1", "c1ccccc1C(C)(C)C", "CC(c1ccccc1)(C)C"],
648 vec!["C1CC2(CC1)CCCC2", "C1CCC2(C1)CCCC2"],
649 vec!["c1ccc2ccccc2c1", "c1ccc2c(c1)cccc2"],
650 vec!["CCCCO", "OCCCC"],
651 ];
652 for style in &Style::ALL {
653 for ws in &groups {
654 let keys: Vec<Vec<i64>> = ws.iter().map(|s| shape_key(s, style)).collect();
655 for (w, k) in ws.iter().zip(&keys).skip(1) {
656 assert_eq!(&keys[0], k, "[{}] {w} 与 {} 形状不同", style.name, ws[0]);
657 }
658 }
659 }
660 }
661
662 /// 完整的图元指纹 —— 连楔形的方向都比。`shape_key` 只比两两距离,
663 /// 坐标一模一样而楔形互换的那一类它看不见。
664 fn scene_key(smi: &str, style: &Style) -> Vec<String> {
665 let m = prep(smi);
666 let d = generate(&m, style);
667 let q = |p: Point2| format!("{:.3},{:.3}", p.x, p.y);
668 let mut v: Vec<String> = render::scene(&m, &d, style)
669 .items
670 .iter()
671 .map(|it| match it {
672 render::Primitive::Line { from, to, .. } => {
673 let (x, y) = (q(*from), q(*to));
674 // 线不分方向 —— 谁是起点取决于键的 begin/end
675 if x <= y {
676 format!("L {x} {y}")
677 } else {
678 format!("L {y} {x}")
679 }
680 }
681 // 楔形分方向:窄端宽端不是一回事
682 render::Primitive::Wedge { from, to, .. } => format!("W {} {}", q(*from), q(*to)),
683 render::Primitive::Hash { from, to, .. } => format!("H {} {}", q(*from), q(*to)),
684 render::Primitive::Ball { .. } | render::Primitive::Stick { .. } => {
685 unreachable!("二维那条路的场景里没有球棍 —— 收到就说明拿错了场景")
686 }
687 // **文本连内容一起比。** 只比落点的话,`OH` 变成 `HO`、
688 // 竖排翻成横排这类退化看不见 —— 而那正是坐标全同、图元不同的
689 // 另一大类。
690 render::Primitive::Text { at, runs, .. } => format!("T {} {runs:?}", q(*at)),
691 })
692 .collect();
693 v.sort();
694 v
695 }
696
697 #[test]
698 fn a_mirror_symmetric_molecule_gets_the_same_wedges_whichever_way_it_is_written() {
699 // **头号契约里最隐蔽的一档。** 这些分子换种写法之后**坐标逐字节相同**,
700 // 差的只是楔形与虚楔互换 —— `shape_key` 那条判据完全看不见,得比整份
701 // 图元。
702 //
703 // 根子在 `canonical_ranks` 的深层平局是任取的,而**内消旋分子最吃亏**:
704 // 1-乙炔基-4-苯基环己醇有一个穿过 C1、C4 的镜面,两条环支路构造上等价、
705 // 构型上相反,1-WL 细化分不开,于是任取一头 —— 两张图都对,但不是同
706 // 一张。修法见 [`ranks_of`]。
707 //
708 // 变异验证:把 `ranks_of` 换回 `canonical_ranks`,这条当场红。
709 let groups = [
710 // 573:非手性,楔形/虚楔互换
711 vec![
712 "C(#C)[C@@]1(CC[C@H](C2=CC=CC=C2)CC1)O",
713 "C1C[C@H](CC[C@@]1(O)C#C)c1ccccc1",
714 ],
715 // 2553:笼状胺,坐标多重集相同而线连在不同的点对之间
716 vec!["C1CN2CN1CN3CCN(C2)C3", "C1N2CN(CN3CCN(C2)C3)C1"],
717 // 6457:**形状全等而姿态差 15.03°**。布局阶段两种写法完全一致
718 // (都是 −14.6099°),分岔出在消冲突 —— `far_side` 在"两侧一样多"
719 // 时靠 `begin`/`end` 挑边,而那是书写痕迹。绕同一根轴翻这侧还是
720 // 那侧,结果差一次整体反射(`orient` 归得掉);可**两次绕不同轴的
721 // 反射合成就是任意角度的旋转**,30° 网格归不掉。
722 //
723 // 只在 ACS 下犯:ChemDraw 的标签小、消冲突根本没动手。
724 vec![
725 "N1(C(=C(C(=O)OCC)N=N1)CSC2=NC3N(C4C=CC=CC(C(N=N2)=3)=4)C)C5C(=NON=5)N",
726 "n1c2c3ccccc3n(c2nc(n1)SCc1n(-c2nonc2N)nnc1C(OCC)=O)C",
727 ],
728 // 7879:Ni 的四齿配合物,五个环共用同一个金属。**分岔在布局**
729 // (前面几个都在消冲突或摆位):拼环时"共用的那两个原子"取自
730 // SSSR 的输出序,而 `fuse_on_bond` 选环心是"取远离已放置质心的
731 // 那个" —— 质心落在这根键的中垂线上时两个候选等距,`u`/`v` 一
732 // 交换就拼到了相反的一侧。这个分子对称度高,四次拼环的 `(u,v)`
733 // **每一次都正好反过来**,40/40 个图元全不同。
734 vec![
735 "O=C1C[N+]23CC[N+]45CC(=O)O[Ni]24(O1)(OC(=O)C3)OC(=O)C5",
736 "O1C(=O)C[N+]23CC(=O)O[Ni]1143OC(C[N+]1(CC2)CC(O4)=O)=O",
737 ],
738 // 1068:唯一一批走**撑开**(`refine` 的第四个算子)的分子。撑开会
739 // 改一个键角,是流水线里最晚、也最容易把姿态带偏的一步,所以这一组
740 // 守的是"走完这一步之后整张图仍然与写法无关"。
741 //
742 // **它守不住的那一件事要说清楚:转向怎么命名。** 实测:把撑开的转向
743 // 从内蕴的「朝 n / 背 n」改回固定的 `+30° 在先`,这一组照样绿 ——
744 // 因为消冲突之前的坐标系今天本来就逐写法一致(起手环落在起始角为
745 // **常数**的正多边形上)。那一条由
746 // `refine::tests::the_two_splay_directions_are_named_without_looking_at_the_canvas`
747 // 钉着:它把坐标整体反射一次直接验等变性,不经过 `orient::canonicalise`。
748 vec![
749 "C1(/C(NC2=C(N1)C=CC=C2)=N\\C3=CC=C(C(=O)OCC)C=C3)=N/C4=CC=C(C(=O)OCC)C=C4",
750 "c1ccc2c(c1)[nH]c(=N/c1ccc(C(OCC)=O)cc1)/c([nH]2)=N\\c1ccc(cc1)C(=O)OCC",
751 "c1c(\\N=c2/c([nH]c3ccccc3[nH]2)=N\\c2ccc(cc2)C(=O)OCC)ccc(c1)C(=O)OCC",
752 ],
753 ];
754 // **先证明这几对写法真的换了存储序。** 不然改写退化成恒等,这条判据就
755 // 静悄悄地空过了 —— `audit.rs` 的搅拌器为这个失效模式专门立过案(旧那个
756 // 乘法哈希有 10.85% 的改写原样返回)。
757 for ws in &groups {
758 let seqs: Vec<Vec<(u8, u8)>> = ws
759 .iter()
760 .map(|s| {
761 let m = prep(s);
762 (0..m.num_atoms())
763 .map(|i| {
764 let a = m.atoms()[i];
765 (a.atomic_num, a.num_explicit_hs + a.num_implicit_hs)
766 })
767 .collect()
768 })
769 .collect();
770 assert!(
771 seqs[1..].iter().any(|s| *s != seqs[0]),
772 "{} 与 {} 的存储序一模一样,这一组验不了写法无关",
773 ws[0],
774 ws[1]
775 );
776 }
777 for style in &Style::ALL {
778 for ws in &groups {
779 let keys: Vec<Vec<String>> = ws.iter().map(|s| scene_key(s, style)).collect();
780 for (w, k) in ws.iter().zip(&keys).skip(1) {
781 assert_eq!(
782 &keys[0], k,
783 "[{}] {w} 与 {} 画出来的图元不同",
784 style.name, ws[0]
785 );
786 }
787 }
788 }
789 }
790
791 #[test]
792 fn a_sandwich_complex_gets_two_proper_rings_and_says_it_is_degraded() {
793 // **η5 配位在 SMILES 里是 5 根独立的 σ 键**,于是环感知吐出一堆
794 // 三元环(Fe + 环上相邻两个碳),整个体系被当成一个巨大的桥环系统。
795 // 实测二茂铁因此画成两个叠在一起的环、8 处交叉,而且骨架指纹算不出来。
796 //
797 // 布局每个 (金属, 环) 只留一根代表键之后,Cp 成了普通五元环、金属成了
798 // 两个五边形之间的连接原子。这条判据钉住三件事:
799 //
800 // 1. **两个环真的是正五边形** —— 环内每根键都是单位长;
801 // 2. **金属在两个环中间** —— 它到两个环心的方向大致相反;
802 // 3. **如实报退化** —— 那 10 根 Fe–C 键在平面上不可能等长。
803 //
804 // 第 3 条最容易漏:少了它,二茂铁会被算成"干净",于是硬性质
805 // `键长全等` 开始查它并**当场破 4 处**(实测)。
806 for smi in [
807 "C12C3=C4C5=C1[Fe]23456789C%10C6=C7C8=C9%10",
808 "CN(C)C[C-]12C3=C4C5=C1[Fe++]23456789[C-]%10C6=C7C8=C9%10",
809 ] {
810 let m = prep(smi);
811 for style in &Style::ALL {
812 let d = generate(&m, style);
813 let hapto: Vec<&rings::Degradation> = d
814 .degraded
815 .iter()
816 .filter(|x| matches!(x, rings::Degradation::HaptoCoordination { .. }))
817 .collect();
818 assert_eq!(
819 hapto.len(),
820 2,
821 "[{}] {smi}:该报两处 η 配位退化,实得 {}",
822 style.name,
823 hapto.len()
824 );
825 // **交叉也要如实报。** 消冲突跑在摘细过的副本上,看不见被摘掉
826 // 的那些 η5 键 —— 而它们照样会画出来(金属摆在环外,连到远端
827 // 那几个环原子的线必然穿过环)。不补那一步的话报的是 0 处交叉,
828 // 而图上明明有 4 处。变异:去掉 `refine::crossings(whole, …)`
829 // 那一句 → 这条红。
830 assert!(
831 !d.crossings.is_empty(),
832 "[{}] {smi}:扇出去的 η5 键必然穿过环,交叉不能报 0",
833 style.name
834 );
835 let mut mid = Vec::new();
836 for h in &hapto {
837 let rings::Degradation::HaptoCoordination { metal, ring } = h else {
838 unreachable!("上面已经筛过")
839 };
840 assert_eq!(ring.len(), 5, "[{}] {smi}:Cp 该是五元环", style.name);
841 // 一、环内每根键都是单位长(是个正五边形)
842 for w in ring
843 .windows(2)
844 .chain(std::iter::once(&[ring[0], ring[ring.len() - 1]][..]))
845 {
846 let (p, q) = (d.coords[w[0] as usize], d.coords[w[1] as usize]);
847 // `ring` 是按规范秩排的,不是绕环的次序,所以只查
848 // **成键的**那几对
849 if m.neighbors(w[0]).any(|(x, _)| x == w[1]) {
850 let len = p.dist(q);
851 assert!(
852 (len - 1.0).abs() < 1e-6,
853 "[{}] {smi}:Cp 环上的键长 {len:.4},该是 1",
854 style.name
855 );
856 }
857 }
858 let c = ring
859 .iter()
860 .fold(Point2::ORIGIN, |s, a| s + d.coords[*a as usize])
861 * (1.0 / ring.len() as f64);
862 mid.push((d.coords[*metal as usize], c));
863 }
864 // 二、金属在两个环中间 —— 它指向两个环心的方向大致相反
865 let (fe, c0) = mid[0];
866 let c1 = mid[1].1;
867 let (u, v) = ((c0 - fe).normalized(), (c1 - fe).normalized());
868 assert!(
869 u.dot(v) < -0.3,
870 "[{}] {smi}:两个环该分列金属两侧,实得夹角余弦 {:.3}",
871 style.name,
872 u.dot(v)
873 );
874 }
875 }
876 }
877
878 #[test]
879 fn a_macrocyclic_chelate_is_not_mistaken_for_a_sandwich() {
880 // **只数"金属打进这个环几根键"会把大环螯合物一起收进来。** 卟啉、酞菁、
881 // 环多胺那一类,摘掉金属之后照样是个环,而金属对它有 4 根键 —— 全部
882 // 满足 `>= 3`。
883 //
884 // Ni(环四胺)是这一类里最干净的例子(式 C₁₀H₂₀N₄Ni,Ni 四配位全接 N,
885 // 配体自己是一个含 4 个 N 的 14 元环)。**只数键数的话实测**:
886 // 键交叉 0 → 5,Ni 到四个 N 的距离从 0.95…1.24 变成 1.00/3.51/4.34/5.49
887 // —— 一张本来画对的图被改坏了。
888 //
889 // 区别在**位置**:η5 的 Cp 是 5 个首尾相接的碳,而 cyclam 的 4 个 N
890 // 之间隔着 2~3 个碳。判据因此要求"环上连续的一段"。
891 //
892 // **这个反例是代码审核拿真实化合物试出来的,不是从语料量出来的** ——
893 // 语料里一个大环配合物都没有(实测 147 个含金属且度 ≥3 的分子里,
894 // "金属打进配体环的最大键数"只有 0、1、5 三档)。
895 let smi = "[Ni]123N4CCN1CCCN2CCN3CCC4";
896 let m = prep(smi);
897 let ranks = ranks_of(&m);
898 assert!(
899 hapto_extras(&m, &ranks).is_none(),
900 "{smi} 是 σ 给体的大环螯合,不是 η 配位,一根键都不该摘"
901 );
902 // 前提要自己成立:金属确实对这个环有 ≥3 根键,只是**不连续**。
903 // 少了这一句,把判据换成"从来不摘"也照样绿。
904 let ni = (0..u32::try_from(m.num_atoms()).unwrap())
905 .find(|a| m.atoms()[*a as usize].atomic_num == 28)
906 .expect("该有一个镍");
907 assert_eq!(m.degree(ni), 4, "镍该是四配位");
908 for style in &Style::ALL {
909 let d = generate(&m, style);
910 assert!(
911 d.crossings.is_empty(),
912 "[{}] {smi} 该画得出 0 交叉,实得 {} 处",
913 style.name,
914 d.crossings.len()
915 );
916 }
917 }
918
919 #[test]
920 fn contiguity_is_what_separates_a_sandwich_from_a_chelate() {
921 // 纯函数,直接钉住。**这是上一条判据背后的那把尺子。**
922 let s = |v: &[u32]| {
923 v.iter()
924 .copied()
925 .collect::<std::collections::BTreeSet<u32>>()
926 };
927 let ring: Vec<u32> = (0..6).collect();
928 assert!(contiguous_on_ring(&ring, &s(&[0, 1, 2])), "连续的三个");
929 assert!(
930 contiguous_on_ring(&ring, &s(&[4, 5, 0])),
931 "跨过接头也算连续"
932 );
933 assert!(contiguous_on_ring(&ring, &s(&[0, 1, 2, 3, 4, 5])), "整圈");
934 assert!(
935 !contiguous_on_ring(&ring, &s(&[0, 2, 4])),
936 "隔一个的三个不算"
937 );
938 assert!(!contiguous_on_ring(&ring, &s(&[0, 1, 3])), "两段不算");
939 // 五元环整圈 —— 二茂铁就是这一种
940 let cp: Vec<u32> = (0..5).collect();
941 assert!(contiguous_on_ring(&cp, &s(&[0, 1, 2, 3, 4])));
942 }
943
944 #[test]
945 fn a_chelate_ring_is_left_alone_because_it_only_exists_through_the_metal() {
946 // **这一刀不许伤及无辜。** 螯合物(三乙二胺合钴之类)的环是**经过金属**
947 // 才闭合的 —— 把金属的键摘掉之后配体只剩几条链,一个环都没有,于是
948 // `hapto_extras` 自然什么也找不到。
949 //
950 // **这一条守的是"摘键前先摘金属"这个做法本身**,不是那个 `>= 3` 的阈值
951 // —— 阈值降到 2 这条也照样绿(变异验过)。真正的区别在于:η 配位的环
952 // 在**没有金属时就存在**(Cp⁻ 自己就是个五元环),螯合环不然。
953 //
954 // 那个 `>= 3` 的阈值**在本语料上没有能区分它的例子**:实测把口径放到
955 // `>= 2` 重扫 8831 个分子,"金属与配体环有正好 2 根键"的情形是 **0 个**。
956 // 所以它是个设计取舍,不是被数据逼出来的 —— 如实说,不编一个例子来
957 // 假装它有判据。
958 for smi in [
959 "C1CN[Co]23(N1)(NCCN2)NCCN3",
960 "[O-]S([O-])(=O)=O.C1CN[Cr+3]23(N1)(NCCN2)NCCN3",
961 "CC1=[O+][Co]23([O+]=C(C)C1)([O+]=C(C)CC(=[O+]2)C)[O+]=C(C)CC(=[O+]3)C",
962 ] {
963 let m = prep(smi);
964 let ranks = ranks_of(&m);
965 assert!(
966 hapto_extras(&m, &ranks).is_none(),
967 "{smi} 里没有 η 配位,不该摘任何键"
968 );
969 // 而且这些分子确实**有**金属、金属确实**有**多根键 —— 否则上面那句
970 // 是空过的
971 assert!(
972 (0..u32::try_from(m.num_atoms()).unwrap())
973 .any(|a| m.atoms()[a as usize].atomic_num > 20 && m.degree(a) >= 4),
974 "{smi} 里该有一个多配位的金属,不然这条判据说明不了问题"
975 );
976 }
977 }
978
979 #[test]
980 fn which_hapto_bond_survives_does_not_depend_on_how_it_was_written() {
981 // 每个 (金属, 环) 只留一根代表键,**留哪一根按规范秩定**。改用存储下标
982 // 的话,同一个二茂铁换种写法就会留下另一根 —— 金属挂到环上另一个位置,
983 // 整张图跟着转。变异验过:这条当场红,而别的判据全绿。
984 // **第二种写法由规范式回写产生,不是手写的。** 自己编 SMILES 会把编错
985 // 的风险带进结论 —— 本轮已经踩过一次(为查顺反造的"最小复现"
986 // `C/C=C/C` vs `C(/C)=C/C` 其实是 E 与 Z 两个不同分子)。
987 //
988 // # 这条判据抓不到"代表键改用存储序"
989 //
990 // 实测:把 `into.sort_unstable()`(按规范秩)换成按存储下标,这条**照样
991 // 绿**,全量语料也一处不变。原因是 Cp 环五重对称 —— 金属挂在哪个碳上
992 // 画出来都全等,`orient` 再把姿态归一,于是观测不到差别。
993 //
994 // 那句排序因此是**预防性的**:语料里没有能区分它的例子(带取代基的那个
995 // 二茂铁也不行,取代基在另一个环上)。**如实说,不编一个分子来假装它有
996 // 判据。** 这条判据守的是别的东西 —— 摘键这件事本身不引入写法依赖。
997 for base in [
998 "C12C3=C4C5=C1[Fe]23456789C%10C6=C7C8=C9%10",
999 "CN(C)C[C-]12C3=C4C5=C1[Fe++]23456789[C-]%10C6=C7C8=C9%10",
1000 ] {
1001 let m = prep(base);
1002 let other = omgkit_io::canon::canonical_smiles(&m).smiles;
1003 // 先证明它真的换了存储序,否则这条判据是空过的。
1004 //
1005 // **要比键表,不能只比元素序** —— 二茂铁除了 Fe 全是碳,两种写法的
1006 // 元素序碰巧一模一样,拿它当判据是空过的(踩过)。
1007 let seq = |s: &str| -> Vec<(u32, u32)> {
1008 prep(s).bonds().iter().map(|b| (b.begin, b.end)).collect()
1009 };
1010 assert_ne!(
1011 seq(base),
1012 seq(&other),
1013 "{base} 与它的规范式存储序一样,这条判据验不了东西"
1014 );
1015 for style in &Style::ALL {
1016 assert_eq!(
1017 scene_key(base, style),
1018 scene_key(&other, style),
1019 "[{}] {base}\n 写成 {other} 之后画出来的图元不同",
1020 style.name
1021 );
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn no_two_atoms_land_on_the_same_spot() {
1028 for smi in [
1029 "CC(=O)Oc1ccccc1C(=O)O",
1030 "OC(=O)c1ccccc1OC(C)=O",
1031 "c1ccc2ccccc2c1",
1032 "CC(C)(C)c1ccccc1",
1033 "CCCCCCCC",
1034 "C1CC2(CC1)CCCC2",
1035 "[Na+].[Cl-]",
1036 "CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
1037 ] {
1038 let d = generate(&prep(smi), &Style::ACS_1996);
1039 for i in 0..d.coords.len() {
1040 for j in (i + 1)..d.coords.len() {
1041 let dist = d.coords[i].dist(d.coords[j]);
1042 assert!(dist > 0.3, "{smi}:原子 {i} 与 {j} 距离只有 {dist:.4}");
1043 }
1044 }
1045 }
1046 }
1047
1048 #[test]
1049 fn every_bond_keeps_its_unit_length() {
1050 for smi in [
1051 "CC(=O)Oc1ccccc1C(=O)O",
1052 "c1ccc2ccccc2c1",
1053 "CCCCCCCC",
1054 "CN1C=NC2=C1C(=O)N(C)C(=O)N2C",
1055 ] {
1056 let m = prep(smi);
1057 let d = generate(&m, &Style::ACS_1996);
1058 for b in m.bonds() {
1059 let len = d.coords[b.begin as usize].dist(d.coords[b.end as usize]);
1060 assert!(
1061 (len - 1.0).abs() < 1e-9,
1062 "{smi} 键 {}–{} 长 {len}",
1063 b.begin,
1064 b.end
1065 );
1066 }
1067 }
1068 }
1069
1070 #[test]
1071 fn disconnected_components_do_not_sit_on_top_of_each_other() {
1072 let m = prep("[Na+].[Cl-]");
1073 let d = generate(&m, &Style::ACS_1996);
1074 assert!(d.coords[0].dist(d.coords[1]) > 1.0, "两个离子挨得太近");
1075 }
1076
1077 #[test]
1078 fn a_depiction_knows_which_style_made_it() {
1079 // 按 A 规范排版、拿 B 规范渲染会挤成一团,而且不报错。指纹让它可查。
1080 let d = generate(&prep("CCO"), &Style::ACS_1996);
1081 assert!(d.matches(&Style::ACS_1996));
1082 assert!(!d.matches(&Style::CHEMDRAW_DEFAULT), "换了规范却认为匹配");
1083 assert_eq!(d.style_name, "ACS Document 1996");
1084
1085 // 只改渲染项不该让坐标失效
1086 let mut only_render = Style::ACS_1996;
1087 only_render.line_width_pt = 3.0;
1088 assert!(d.matches(&only_render), "改线宽不该让已有坐标失效");
1089 }
1090
1091 #[test]
1092 fn trouble_is_reported_rather_than_hidden() {
1093 // 桥环没有平面好解 —— 必须报出来
1094 let d = generate(&prep("C1CC2CCC1CC2"), &Style::ACS_1996);
1095 assert!(!d.degraded.is_empty(), "桥环应当记进 degraded");
1096 assert!(!d.is_clean());
1097
1098 // 一般分子应当是干净的
1099 let ok = generate(&prep("CCO"), &Style::ACS_1996);
1100 assert!(ok.is_clean(), "乙醇不该有任何问题:{ok:?}");
1101 }
1102}