Skip to main content

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}