Skip to main content

xpict_core/doc/
mod.rs

1//! Declarative document — plan (pass 1) + paint (pass 2) + scheme compose.
2//!
3//! Wire types live in [`spec`] (opts cascade + DepictSpec collocated).
4//! Hosts call [`plan_edge`] → process the [`EdgePlan`] with RDKit →
5//! [`render_doc`] with the [`EdgeResult`] → [`compose_scheme`] for reaction
6//! schemes. No RDKit in this module.
7
8mod compose;
9mod spec;
10
11pub use compose::compose_scheme;
12pub use spec::*;
13
14use std::collections::HashMap;
15
16use crate::cxsmiles::{apply_cx_by_index, cx_source};
17use crate::depict::depict_molecule;
18use crate::edge::{
19    AlignOpts, CoordGenMoleculeResult, EdgePlan, EdgeResult, EdgeTask, EdgeTaskResult,
20    MolTemplate,
21};
22use crate::scene::{MoleculeIn, Scene};
23
24/// Assign stable unique ids (`user id` or `m_<i>`) in document order.
25pub fn assign_mol_ids(mols: &[MolNode]) -> Result<Vec<String>, String> {
26    let mut used = std::collections::HashSet::new();
27    let mut out = Vec::with_capacity(mols.len());
28    for (i, m) in mols.iter().enumerate() {
29        let id = match m.id.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
30            Some(s) => s.to_string(),
31            None => format!("m_{i}"),
32        };
33        if !used.insert(id.clone()) {
34            return Err(format!("duplicate mol id {id}"));
35        }
36        out.push(id);
37    }
38    Ok(out)
39}
40
41/// Ensure edge endpoints and label refs resolve to the right node kinds.
42pub fn validate_edges(spec: &DepictSpec) -> Result<(), String> {
43    // Groups must not contain edge children.
44    if matches!(spec, DepictSpec::Group { .. }) {
45        for (i, n) in spec.nodes().iter().enumerate() {
46            if n.as_edge().is_some() {
47                return Err(format!("group children[{i}] cannot be an edge"));
48            }
49        }
50    }
51
52    let mol_ids = assign_mol_ids(&spec.mols())?;
53    let mol_set: std::collections::HashSet<&str> =
54        mol_ids.iter().map(String::as_str).collect();
55
56    // Collect labelable ids (mol + text).
57    let mut labelable = mol_set.clone();
58    for t in spec.texts() {
59        let Some(id) = t
60            .id
61            .as_deref()
62            .map(str::trim)
63            .filter(|s| !s.is_empty())
64        else {
65            continue;
66        };
67        if !labelable.insert(id) {
68            return Err(format!("duplicate node id {id}"));
69        }
70    }
71
72    for m in spec.mols() {
73        if let Some(ref lab) = m.label {
74            for (id, _pos) in lab.placements() {
75                let id = id.trim();
76                if id.is_empty() {
77                    return Err("mol label ref must be non-empty".into());
78                }
79                match spec.node_by_id(id) {
80                    Some(Node::Text(_)) => {}
81                    Some(Node::Mol(_)) => {
82                        return Err(format!("mol label {id} must ref a text node, not a mol"));
83                    }
84                    Some(Node::Edge(_)) => {
85                        return Err(format!("mol label {id} must ref a text node, not an edge"));
86                    }
87                    None => return Err(format!("mol label unknown text id {id}")),
88                }
89            }
90        }
91    }
92
93    for e in spec.edges() {
94        if e.sources.is_empty() {
95            return Err("edge sources must be non-empty".into());
96        }
97        if e.targets.is_empty() {
98            return Err("edge targets must be non-empty".into());
99        }
100        for id in e.sources.iter() {
101            let id = id.trim();
102            if id.is_empty() {
103                return Err("edge source id must be non-empty".into());
104            }
105            if !mol_set.contains(id) {
106                return Err(format!("edge source unknown id {id}"));
107            }
108        }
109        for id in e.targets.iter() {
110            let id = id.trim();
111            if id.is_empty() {
112                return Err("edge target id must be non-empty".into());
113            }
114            if !mol_set.contains(id) {
115                return Err(format!("edge target unknown id {id}"));
116            }
117        }
118        if let Some(ref lab) = e.label {
119            for (id, pos) in lab.placements() {
120                let id = id.trim();
121                if id.is_empty() {
122                    return Err("edge label ref must be non-empty".into());
123                }
124                match spec.node_by_id(id) {
125                    Some(Node::Text(_)) | Some(Node::Mol(_)) => {}
126                    Some(Node::Edge(_)) => {
127                        return Err(format!("edge label ({pos:?}) id {id} cannot ref an edge"));
128                    }
129                    None => return Err(format!("edge label ({pos:?}) unknown id {id}")),
130                }
131            }
132        }
133    }
134    Ok(())
135}
136
137/// Pass 1: build an [`EdgePlan`] for every mol that needs host coord gen.
138///
139/// - No align: each mol is its own free-layout root.
140/// - `align: true`: forest — default parent = first child; `align_to` overrides.
141///
142/// Returns `None` only when the document has no molecules.
143pub fn plan_edge(spec: &DepictSpec) -> Result<Option<EdgePlan>, String> {
144    validate_edges(spec)?;
145    let mols = spec.mols();
146    if mols.is_empty() {
147        return Ok(None);
148    }
149    let ids = assign_mol_ids(&mols)?;
150    let id_to_idx: HashMap<&str, usize> = ids
151        .iter()
152        .enumerate()
153        .map(|(i, id)| (id.as_str(), i))
154        .collect();
155
156    let align = spec.align_enabled();
157
158    // parent_of[i] = None → root; Some(j) → align onto j
159    let mut parent_of: Vec<Option<usize>> = vec![None; mols.len()];
160    if align && mols.len() >= 2 {
161        parent_of[0] = None;
162        for i in 1..mols.len() {
163            if let Some(ref target) = mols[i].align_to {
164                let name = target.ref_id();
165                let j = *id_to_idx
166                    .get(name)
167                    .ok_or_else(|| format!("align_to unknown id {name}"))?;
168                if j == i {
169                    return Err(format!("mol {} cannot align_to itself", ids[i]));
170                }
171                parent_of[i] = Some(j);
172            } else {
173                parent_of[i] = Some(0);
174            }
175        }
176    }
177
178    let mut nodes: Vec<MolTemplate> = mols
179        .iter()
180        .enumerate()
181        .map(|(i, m)| {
182            let align_opts = parent_of[i].map(|_| match &m.align_to {
183                Some(a) => a.align_opts(),
184                None => AlignOpts::default(),
185            });
186            MolTemplate {
187                id: ids[i].clone(),
188                smiles: m.smiles.clone(),
189                cxsmiles: m.cxsmiles.clone(),
190                molfile: m.molfile.clone(),
191                align: align_opts,
192                template_for: vec![],
193            }
194        })
195        .collect();
196
197    // Attach children (drain from leaves upward by building ownership via indices).
198    // Collect children indices per parent, then move nodes into place.
199    let mut children_of: Vec<Vec<usize>> = vec![vec![]; mols.len()];
200    let mut roots_idx: Vec<usize> = Vec::new();
201    for (i, p) in parent_of.iter().enumerate() {
202        match p {
203            None => roots_idx.push(i),
204            Some(j) => children_of[*j].push(i),
205        }
206    }
207
208    fn build_tree(i: usize, nodes: &mut Vec<Option<MolTemplate>>, children_of: &[Vec<usize>]) -> MolTemplate {
209        let mut node = nodes[i].take().expect("node");
210        for &c in &children_of[i] {
211            node.template_for.push(build_tree(c, nodes, children_of));
212        }
213        node
214    }
215
216    let mut slots: Vec<Option<MolTemplate>> = nodes.drain(..).map(Some).collect();
217    let roots: Vec<MolTemplate> = roots_idx
218        .iter()
219        .map(|&i| build_tree(i, &mut slots, &children_of))
220        .collect();
221
222    let plan = EdgePlan::new_v1(vec![EdgeTask::CoordGen { roots }]);
223    plan.validate()?;
224    Ok(Some(plan))
225}
226
227fn edge_molecules(edge: &EdgeResult) -> HashMap<&str, &CoordGenMoleculeResult> {
228    let mut map = HashMap::new();
229    for r in &edge.results {
230        let EdgeTaskResult::CoordGen { molecules, .. } = r;
231        for m in molecules {
232            map.insert(m.id.as_str(), m);
233        }
234    }
235    map
236}
237
238/// Apply ``star_labels`` onto ``*`` atoms in layout encounter order.
239pub fn apply_star_labels(mol: &mut MoleculeIn, labels: &[Option<String>]) {
240    if labels.is_empty() {
241        return;
242    }
243    let stars: Vec<usize> = mol
244        .atoms
245        .iter()
246        .enumerate()
247        .filter(|(_, a)| a.z == Some(0) || a.element.as_deref() == Some("*"))
248        .map(|(i, _)| i)
249        .collect();
250    if stars.is_empty() {
251        return;
252    }
253    for (slot, lab) in labels.iter().enumerate() {
254        if slot >= stars.len() {
255            break;
256        }
257        let text = match lab {
258            None => "*".to_string(),
259            Some(s) if s.trim().is_empty() => "*".to_string(),
260            Some(s) => s.trim().to_string(),
261        };
262        if let Some(atom) = mol.atoms.get_mut(stars[slot]) {
263            atom.label = Some(text);
264        }
265    }
266}
267
268fn apply_doc_chrome(
269    mut mol: MoleculeIn,
270    node: &MolNode,
271    id: &str,
272    chrome: &MolOpts,
273) -> MoleculeIn {
274    mol.id = Some(id.to_string());
275    if let Some(ref c) = chrome.color {
276        mol.color = Some(c.clone());
277    }
278    // Scores always from the node (non-cascading); window from resolved chrome.
279    if let Some(ref shade) = node.shade {
280        mol.atom_shade = shade.atoms.clone();
281        mol.bond_shade = shade.bonds.clone();
282    }
283    if let Some(ref style) = chrome.shade {
284        if let Some(v) = style.vmin {
285            mol.shade_vmin = Some(v);
286        } else if let Some(ref shade) = node.shade {
287            mol.shade_vmin = Some(shade.vmin);
288        }
289        if let Some(v) = style.vmax {
290            mol.shade_vmax = Some(v);
291        } else if let Some(ref shade) = node.shade {
292            mol.shade_vmax = Some(shade.vmax);
293        }
294    } else if let Some(ref shade) = node.shade {
295        mol.shade_vmin = Some(shade.vmin);
296        mol.shade_vmax = Some(shade.vmax);
297    }
298    if let Some(s) = chrome.scale {
299        mol.scale = s;
300    }
301    if let Some(w) = chrome.weight {
302        mol.weight = w;
303    }
304    // halo reserved on chrome for future paint; MoleculeIn has no halo flag yet.
305    let _ = chrome.halo;
306    if let Some(ref labels) = node.star_labels {
307        apply_star_labels(&mut mol, labels);
308    } else if let Some(src) = cx_source(node.cxsmiles.as_deref(), node.smiles.as_deref()) {
309        mol = apply_cx_by_index(mol, src);
310    }
311    mol
312}
313
314/// One painted molecule from [`render_doc`] (pass 2).
315#[derive(Debug, Clone)]
316pub struct DocPaint {
317    pub id: String,
318    pub molecule: MoleculeIn,
319    pub scene: Scene,
320}
321
322/// Pass 2: stitch host [`EdgeResult`] coords onto the document and paint.
323pub fn render_doc(spec: &DepictSpec, edge: &EdgeResult) -> Result<Vec<DocPaint>, String> {
324    let mols = spec.mols();
325    let ids = assign_mol_ids(&mols)?;
326    let by_id = edge_molecules(edge);
327    let mut out = Vec::with_capacity(mols.len());
328
329    for (i, node) in mols.iter().enumerate() {
330        let id = &ids[i];
331        let row = by_id
332            .get(id.as_str())
333            .ok_or_else(|| format!("EdgeResult missing molecule id {id}"))?;
334        if !row.ok {
335            return Err(format!(
336                "EdgeResult {id} has ok=false: {}",
337                row.error.as_deref().unwrap_or("coord gen failed")
338            ));
339        }
340        let base = row
341            .molecule
342            .clone()
343            .ok_or_else(|| format!("EdgeResult {id} missing molecule"))?;
344        let chrome = spec.resolve_mol_chrome(i);
345        let molecule = apply_doc_chrome(base, node, id, &chrome);
346        let scene = depict_molecule(&molecule);
347        out.push(DocPaint {
348            id: id.clone(),
349            molecule,
350            scene,
351        });
352    }
353    Ok(out)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::edge::{CoordMethod, EdgeTaskResult};
360    use crate::scene::{AtomIn, BondIn};
361
362    fn ethanol_mol(id: &str) -> MoleculeIn {
363        MoleculeIn {
364            id: Some(id.into()),
365            atoms: vec![
366                AtomIn {
367                    index: 0,
368                    element: Some("C".into()),
369                    z: Some(6),
370                    x: 0.0,
371                    y: 0.0,
372                    label: None,
373                    charge: 0,
374                },
375                AtomIn {
376                    index: 1,
377                    element: Some("C".into()),
378                    z: Some(6),
379                    x: 1.5,
380                    y: 0.0,
381                    label: None,
382                    charge: 0,
383                },
384                AtomIn {
385                    index: 2,
386                    element: Some("O".into()),
387                    z: Some(8),
388                    x: 2.2,
389                    y: 1.0,
390                    label: Some("OH".into()),
391                    charge: 0,
392                },
393            ],
394            bonds: vec![
395                BondIn {
396                    index: 0,
397                    begin: 0,
398                    end: 1,
399                    order: 1.0,
400                    stereo: None,
401                    interior: None,
402                },
403                BondIn {
404                    index: 1,
405                    begin: 1,
406                    end: 2,
407                    order: 1.0,
408                    stereo: None,
409                    interior: None,
410                },
411            ],
412            color: None,
413            atom_shade: None,
414            bond_shade: None,
415            shade_vmin: None,
416            shade_vmax: None,
417            mark_atoms: vec![],
418            mark_bonds: vec![],
419            scale: 1.0,
420            weight: 1.0,
421        }
422    }
423
424    #[test]
425    fn plan_edge_no_align_independent_roots() {
426        let spec = DepictSpec::Group {
427            id: None,
428            align: false,
429            children: vec![
430                Node::Mol(MolNode {
431                    smiles: Some("CCO".into()),
432                    ..Default::default()
433                }),
434                Node::Mol(MolNode {
435                    smiles: Some("CCCO".into()),
436                    ..Default::default()
437                }),
438            ],
439                    opts: None,
440            color: None,
441            scale: None,
442        };
443        let plan = plan_edge(&spec).unwrap().unwrap();
444        match &plan.tasks[0] {
445            EdgeTask::CoordGen { roots } => {
446                assert_eq!(roots.len(), 2);
447                assert!(roots[0].template_for.is_empty());
448                assert!(roots[1].template_for.is_empty());
449            }
450        }
451    }
452
453    #[test]
454    fn plan_edge_align_forest_default_first() {
455        let spec = DepictSpec::Group {
456            id: None,
457            align: true,
458            children: vec![
459                Node::Mol(MolNode {
460                    id: Some("ref".into()),
461                    smiles: Some("c1ccccc1".into()),
462                    ..Default::default()
463                }),
464                Node::Mol(MolNode {
465                    smiles: Some("Cc1ccccc1".into()),
466                    align_to: Some(AlignTo::Spec(AlignToSpec {
467                        ref_id: "ref".into(),
468                        atom_map: Some(vec![(1, 0), (2, 1), (3, 2)]),
469                        min_atoms: None,
470                    })),
471                    ..Default::default()
472                }),
473            ],
474            opts: None,
475            color: None,
476            scale: None,
477        };
478        let plan = plan_edge(&spec).unwrap().unwrap();
479        match &plan.tasks[0] {
480            EdgeTask::CoordGen { roots } => {
481                assert_eq!(roots.len(), 1);
482                assert_eq!(roots[0].id, "ref");
483                assert_eq!(roots[0].template_for.len(), 1);
484                let child = &roots[0].template_for[0];
485                assert_eq!(child.id, "m_1");
486                assert_eq!(
487                    child.align.as_ref().unwrap().atom_map.as_ref().unwrap().len(),
488                    3
489                );
490            }
491        }
492    }
493
494    #[test]
495    fn render_doc_stitches_edge_result() {
496        let spec = DepictSpec::Mol {
497            label: None,
498            smiles: Some("CCO".into()),
499            cxsmiles: None,
500            molfile: None,
501            id: Some("e".into()),
502            color: Some("#0b6e4f".into()),
503            shade: None,
504            star_labels: None,
505            scale: None,
506            weight: None,
507            align_to: None,
508                    halo: None,
509            opts: None,
510        };
511        let plan = plan_edge(&spec).unwrap().unwrap();
512        let id = match &plan.tasks[0] {
513            EdgeTask::CoordGen { roots } => roots[0].id.clone(),
514        };
515        let edge = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
516            ok: true,
517            molecules: vec![CoordGenMoleculeResult {
518                id: id.clone(),
519                ok: true,
520                method: CoordMethod::Free,
521                used_map: None,
522                molecule: Some(ethanol_mol(&id)),
523                error: None,
524            }],
525        }]);
526        let painted = render_doc(&spec, &edge).unwrap();
527        assert_eq!(painted.len(), 1);
528        assert!(painted[0].scene.width > 0.0);
529        assert_eq!(painted[0].molecule.color.as_deref(), Some("#0b6e4f"));
530    }
531
532    #[test]
533    fn render_doc_applies_cx_when_no_star_labels() {
534        let spec = DepictSpec::Mol {
535            label: None,
536            smiles: None,
537            cxsmiles: Some("*C |$R1;$|".into()),
538            molfile: None,
539            id: Some("m".into()),
540            color: None,
541            shade: None,
542            star_labels: None,
543            scale: None,
544            weight: None,
545            align_to: None,
546                    halo: None,
547            opts: None,
548        };
549        let mol = MoleculeIn {
550            id: Some("m".into()),
551            atoms: vec![
552                AtomIn {
553                    index: 0,
554                    element: Some("*".into()),
555                    z: Some(0),
556                    x: 0.0,
557                    y: 0.0,
558                    label: Some("*".into()),
559                    charge: 0,
560                },
561                AtomIn {
562                    index: 1,
563                    element: Some("C".into()),
564                    z: Some(6),
565                    x: 1.5,
566                    y: 0.0,
567                    label: None,
568                    charge: 0,
569                },
570            ],
571            bonds: vec![BondIn {
572                index: 0,
573                begin: 0,
574                end: 1,
575                order: 1.0,
576                stereo: None,
577                interior: None,
578            }],
579            color: None,
580            atom_shade: None,
581            bond_shade: None,
582            shade_vmin: None,
583            shade_vmax: None,
584            mark_atoms: vec![],
585            mark_bonds: vec![],
586            scale: 1.0,
587            weight: 1.0,
588        };
589        let edge = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
590            ok: true,
591            molecules: vec![CoordGenMoleculeResult {
592                id: "m".into(),
593                ok: true,
594                method: CoordMethod::Free,
595                used_map: None,
596                molecule: Some(mol),
597                error: None,
598            }],
599        }]);
600        let painted = render_doc(&spec, &edge).unwrap();
601        assert_eq!(painted[0].molecule.atoms[0].label.as_deref(), Some("R1"));
602    }
603
604    #[test]
605    fn structure_prefers_molfile_then_cx_then_smiles() {
606        let mut n = MolNode {
607            smiles: Some("CCO".into()),
608            cxsmiles: Some("CCO |$;$|".into()),
609            molfile: Some("molblock".into()),
610            ..Default::default()
611        };
612        assert_eq!(n.structure().unwrap(), "molblock");
613        n.molfile = None;
614        assert_eq!(n.structure().unwrap(), "CCO |$;$|");
615        n.cxsmiles = None;
616        assert_eq!(n.structure().unwrap(), "CCO");
617        n.smiles = Some("  ".into());
618        assert!(n.structure().unwrap_err().contains("needs smiles"));
619    }
620
621    #[test]
622    fn assign_mol_ids_rejects_duplicates_and_trims() {
623        let mols = vec![
624            MolNode {
625                id: Some("  a  ".into()),
626                smiles: Some("C".into()),
627                ..Default::default()
628            },
629            MolNode {
630                id: Some("a".into()),
631                smiles: Some("CC".into()),
632                ..Default::default()
633            },
634        ];
635        assert!(assign_mol_ids(&mols).unwrap_err().contains("duplicate"));
636        let auto = assign_mol_ids(&[
637            MolNode {
638                smiles: Some("C".into()),
639                ..Default::default()
640            },
641            MolNode {
642                id: Some("".into()),
643                smiles: Some("CC".into()),
644                ..Default::default()
645            },
646        ])
647        .unwrap();
648        assert_eq!(auto, ["m_0", "m_1"]);
649    }
650
651    #[test]
652    fn plan_edge_empty_group_is_none() {
653        let spec = DepictSpec::Group {
654            id: None,
655            align: false,
656            children: vec![],
657                    opts: None,
658            color: None,
659            scale: None,
660        };
661        assert!(plan_edge(&spec).unwrap().is_none());
662    }
663
664    #[test]
665    fn plan_edge_align_to_unknown_and_self_error() {
666        let unknown = DepictSpec::Group {
667            id: None,
668            align: true,
669            children: vec![
670                Node::Mol(MolNode {
671                    id: Some("a".into()),
672                    smiles: Some("C".into()),
673                    ..Default::default()
674                }),
675                Node::Mol(MolNode {
676                    smiles: Some("CC".into()),
677                    align_to: Some(AlignTo::Ref("missing".into())),
678                    ..Default::default()
679                }),
680            ],
681                    opts: None,
682            color: None,
683            scale: None,
684        };
685        assert!(plan_edge(&unknown).unwrap_err().contains("unknown id"));
686
687        let self_align = DepictSpec::Group {
688            id: None,
689            align: true,
690            children: vec![
691                Node::Mol(MolNode {
692                    id: Some("a".into()),
693                    smiles: Some("C".into()),
694                    ..Default::default()
695                }),
696                Node::Mol(MolNode {
697                    id: Some("b".into()),
698                    smiles: Some("CC".into()),
699                    align_to: Some(AlignTo::Ref("b".into())),
700                    ..Default::default()
701                }),
702            ],
703                    opts: None,
704            color: None,
705            scale: None,
706        };
707        assert!(plan_edge(&self_align).unwrap_err().contains("itself"));
708    }
709
710    #[test]
711    fn plan_edge_align_to_explicit_parent() {
712        let spec = DepictSpec::Group {
713            id: None,
714            align: true,
715            children: vec![
716                Node::Mol(MolNode {
717                    id: Some("left".into()),
718                    smiles: Some("C".into()),
719                    ..Default::default()
720                }),
721                Node::Mol(MolNode {
722                    id: Some("mid".into()),
723                    smiles: Some("CC".into()),
724                    ..Default::default()
725                }),
726                Node::Mol(MolNode {
727                    id: Some("q".into()),
728                    smiles: Some("CCC".into()),
729                    align_to: Some(AlignTo::Ref("mid".into())),
730                    ..Default::default()
731                }),
732            ],
733                    opts: None,
734            color: None,
735            scale: None,
736        };
737        let plan = plan_edge(&spec).unwrap().unwrap();
738        match &plan.tasks[0] {
739            EdgeTask::CoordGen { roots } => {
740                assert_eq!(roots.len(), 1);
741                assert_eq!(roots[0].id, "left");
742                assert_eq!(roots[0].template_for.len(), 1);
743                assert_eq!(roots[0].template_for[0].id, "mid");
744                assert_eq!(roots[0].template_for[0].template_for[0].id, "q");
745            }
746        }
747    }
748
749    #[test]
750    fn apply_star_labels_covers_null_empty_and_overflow() {
751        let mut mol = MoleculeIn {
752            id: None,
753            atoms: vec![
754                AtomIn {
755                    index: 0,
756                    element: Some("*".into()),
757                    z: Some(0),
758                    x: 0.0,
759                    y: 0.0,
760                    label: Some("*".into()),
761                    charge: 0,
762                },
763                AtomIn {
764                    index: 1,
765                    element: Some("C".into()),
766                    z: Some(6),
767                    x: 1.0,
768                    y: 0.0,
769                    label: None,
770                    charge: 0,
771                },
772                AtomIn {
773                    index: 2,
774                    element: Some("*".into()),
775                    z: Some(0),
776                    x: 2.0,
777                    y: 0.0,
778                    label: Some("*".into()),
779                    charge: 0,
780                },
781            ],
782            bonds: vec![],
783            color: None,
784            atom_shade: None,
785            bond_shade: None,
786            shade_vmin: None,
787            shade_vmax: None,
788            mark_atoms: vec![],
789            mark_bonds: vec![],
790            scale: 1.0,
791            weight: 1.0,
792        };
793        apply_star_labels(&mut mol, &[]);
794        assert_eq!(mol.atoms[0].label.as_deref(), Some("*"));
795        apply_star_labels(
796            &mut mol,
797            &[None, Some("  ".into()), Some("R2".into()), Some("extra".into())],
798        );
799        // Two stars: slot0 → "*", slot1 empty → "*"; R2/extra overflow ignored.
800        assert_eq!(mol.atoms[0].label.as_deref(), Some("*"));
801        assert_eq!(mol.atoms[2].label.as_deref(), Some("*"));
802    }
803
804    #[test]
805    fn render_doc_star_labels_shade_scale_weight() {
806        let spec = DepictSpec::Mol {
807            label: None,
808            smiles: Some("*C".into()),
809            cxsmiles: None,
810            molfile: None,
811            id: Some("s".into()),
812            color: None,
813            shade: Some(ShadeSpec {
814                atoms: Some(vec![0.2, 0.8]),
815                bonds: Some(vec![0.5]),
816                colormap: Some("xenosite".into()),
817                vmin: 0.0,
818                vmax: 1.0,
819            }),
820            star_labels: Some(vec![Some("R1".into())]),
821            scale: Some(1.5),
822            weight: Some(1.2),
823            align_to: None,
824                    halo: None,
825            opts: None,
826        };
827        let mut mol = ethanol_mol("s");
828        mol.atoms[0].element = Some("*".into());
829        mol.atoms[0].z = Some(0);
830        mol.atoms[0].label = Some("*".into());
831        let edge = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
832            ok: true,
833            molecules: vec![CoordGenMoleculeResult {
834                id: "s".into(),
835                ok: true,
836                method: CoordMethod::Free,
837                used_map: None,
838                molecule: Some(mol),
839                error: None,
840            }],
841        }]);
842        let painted = render_doc(&spec, &edge).unwrap();
843        let m = &painted[0].molecule;
844        assert_eq!(m.atoms[0].label.as_deref(), Some("R1"));
845        assert_eq!(m.atom_shade.as_ref().unwrap()[0], 0.2);
846        assert_eq!(m.scale, 1.5);
847        assert!((m.weight - 1.2).abs() < 1e-12);
848    }
849
850    #[test]
851    fn render_doc_errors_on_missing_or_failed_edge_row() {
852        let spec = DepictSpec::Mol {
853            label: None,
854            smiles: Some("C".into()),
855            cxsmiles: None,
856            molfile: None,
857            id: Some("x".into()),
858            color: None,
859            shade: None,
860            star_labels: None,
861            scale: None,
862            weight: None,
863            align_to: None,
864                    halo: None,
865            opts: None,
866        };
867        let missing = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
868            ok: false,
869            molecules: vec![],
870        }]);
871        assert!(render_doc(&spec, &missing).unwrap_err().contains("missing"));
872
873        let failed = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
874            ok: false,
875            molecules: vec![CoordGenMoleculeResult {
876                id: "x".into(),
877                ok: false,
878                method: CoordMethod::None,
879                used_map: None,
880                molecule: None,
881                error: Some("boom".into()),
882            }],
883        }]);
884        assert!(render_doc(&spec, &failed).unwrap_err().contains("ok=false"));
885
886        let no_mol = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
887            ok: true,
888            molecules: vec![CoordGenMoleculeResult {
889                id: "x".into(),
890                ok: true,
891                method: CoordMethod::Free,
892                used_map: None,
893                molecule: None,
894                error: None,
895            }],
896        }]);
897        assert!(render_doc(&spec, &no_mol).unwrap_err().contains("missing molecule"));
898    }
899
900    #[test]
901    fn shade_spec_default_and_group_align_flag() {
902        let s = ShadeSpec::default();
903        assert_eq!(s.vmin, 0.0);
904        assert_eq!(s.vmax, 1.0);
905        let g = DepictSpec::Group {
906            id: Some("g".into()),
907            align: true,
908            children: vec![],
909                    opts: None,
910            color: None,
911            scale: None,
912        };
913        assert!(g.align_enabled());
914        assert!(g.mols().is_empty());
915    }
916
917    #[test]
918    fn align_to_json_string_or_object() {
919        let s: AlignTo = serde_json::from_str(r#""ref""#).unwrap();
920        assert_eq!(s.ref_id(), "ref");
921        let o: AlignTo = serde_json::from_str(
922            r#"{"ref":"mid","atom_map":[[1,0],[2,1]],"min_atoms":4}"#,
923        )
924        .unwrap();
925        assert_eq!(o.ref_id(), "mid");
926        let opts = o.align_opts();
927        assert_eq!(opts.atom_map.as_ref().unwrap().len(), 2);
928        assert_eq!(opts.min_atoms, Some(4));
929    }
930
931    #[test]
932    fn opts_patch_json_discriminated_and_list() {
933        // Universal singleton
934        let u: Opts = serde_json::from_str(r##"{"color":"#111"}"##).unwrap();
935        match &u {
936            Opts::One(OptsPatch::Universal(c)) => assert_eq!(c.color.as_deref(), Some("#111")),
937            _ => panic!("expected universal"),
938        }
939        // Typed mol patch
940        let t: OptsPatch = serde_json::from_str(r#"{"type":"mol","weight":1.5,"halo":false}"#).unwrap();
941        match t {
942            OptsPatch::Typed(TypedOptsPatch::Mol { opts }) => {
943                assert_eq!(opts.weight, Some(1.5));
944                assert_eq!(opts.halo, Some(false));
945            }
946            _ => panic!("expected typed mol"),
947        }
948        // for_types multi
949        let f: OptsPatch =
950            serde_json::from_str(r##"{"for_types":["mol","group"],"color":"#0b6e4f"}"##).unwrap();
951        match f {
952            OptsPatch::ForTypes(p) => {
953                assert_eq!(p.for_types, vec![NodeType::Mol, NodeType::Group]);
954                assert_eq!(p.opts.color.as_deref(), Some("#0b6e4f"));
955            }
956            _ => panic!("expected for_types"),
957        }
958        // List container
959        let list: Opts = serde_json::from_str(
960            r##"[{"color":"#111"},{"type":"mol","weight":1.2},{"for_types":["mol"],"scale":2.0}]"##,
961        )
962        .unwrap();
963        assert!(matches!(list, Opts::Many(ref v) if v.len() == 3));
964    }
965
966    #[test]
967    fn cascade_group_opts_list_inherits_then_child_overrides() {
968        let spec: DepictSpec = serde_json::from_str(
969            r##"{
970              "type": "group",
971              "align": false,
972              "opts": [
973                {"color": "#111", "scale": 1.0},
974                {"type": "mol", "weight": 1.0, "halo": true},
975                {"type": "mol", "weight": 1.25},
976                {"for_types": ["mol"], "scale": 1.5}
977              ],
978              "children": [
979                {
980                  "type": "mol",
981                  "id": "a",
982                  "smiles": "CCO",
983                  "opts": {"type": "mol", "color": "#0b6e4f"}
984                },
985                {
986                  "type": "mol",
987                  "id": "b",
988                  "smiles": "CCC",
989                  "weight": 2.0,
990                  "opts": [
991                    {"type": "mol", "halo": false},
992                    {"for_types": ["group"], "color": "#fff"}
993                  ]
994                }
995              ]
996            }"##,
997        )
998        .unwrap();
999
1000        let a = spec.resolve_mol_chrome(0);
1001        assert_eq!(a.color.as_deref(), Some("#0b6e4f")); // child typed patch wins
1002        assert_eq!(a.weight, Some(1.25)); // later typed patch in group list wins
1003        assert_eq!(a.scale, Some(1.5)); // later for_types common key overrides universal
1004        assert_eq!(a.halo, Some(true));
1005
1006        let b = spec.resolve_mol_chrome(1);
1007        assert_eq!(b.color.as_deref(), Some("#111")); // group universal; child for_types group ignored
1008        assert_eq!(b.weight, Some(2.0)); // local flat last
1009        assert_eq!(b.halo, Some(false)); // child opts list
1010        assert_eq!(b.scale, Some(1.5)); // inherited from group for_types
1011    }
1012
1013    #[test]
1014    fn cascade_group_flat_overrides_group_opts_list() {
1015        let spec: DepictSpec = serde_json::from_str(
1016            r##"{
1017              "type": "group",
1018              "opts": [{"color": "#111", "scale": 1.0}],
1019              "color": "#222",
1020              "children": [{"type": "mol", "smiles": "C", "scale": 3.0}]
1021            }"##,
1022        )
1023        .unwrap();
1024        let chrome = spec.resolve_mol_chrome(0);
1025        assert_eq!(chrome.color.as_deref(), Some("#222")); // group flat after opts list
1026        assert_eq!(chrome.scale, Some(3.0)); // node local last
1027    }
1028
1029    #[test]
1030    fn cascade_shade_style_deep_merges_across_list() {
1031        let spec: DepictSpec = serde_json::from_str(
1032            r#"{
1033              "type": "group",
1034              "opts": [
1035                {"type": "mol", "shade": {"vmin": 0.0, "vmax": 1.0}},
1036                {"type": "mol", "shade": {"vmax": 0.5, "colormap": "xenosite"}}
1037              ],
1038              "children": [
1039                {
1040                  "type": "mol",
1041                  "smiles": "C",
1042                  "shade": {"atoms": [0.2], "vmin": -1.0, "vmax": 1.0}
1043                }
1044              ]
1045            }"#,
1046        )
1047        .unwrap();
1048        let chrome = spec.resolve_mol_chrome(0);
1049        let s = chrome.shade.as_ref().unwrap();
1050        // Local shade.style() merges last → vmin/vmax from node ShadeSpec
1051        assert_eq!(s.vmin, Some(-1.0));
1052        assert_eq!(s.vmax, Some(1.0));
1053        // colormap from group list still present unless local cleared it
1054        assert_eq!(s.colormap.as_deref(), Some("xenosite"));
1055    }
1056
1057    #[test]
1058    fn cascade_typed_group_patch_does_not_apply_to_mol() {
1059        let spec: DepictSpec = serde_json::from_str(
1060            r##"{
1061              "type": "group",
1062              "opts": [
1063                {"type": "group", "color": "#ff0000"},
1064                {"type": "mol", "color": "#00ff00"}
1065              ],
1066              "children": [{"type": "mol", "smiles": "C"}]
1067            }"##,
1068        )
1069        .unwrap();
1070        let chrome = spec.resolve_mol_chrome(0);
1071        assert_eq!(chrome.color.as_deref(), Some("#00ff00"));
1072    }
1073
1074    #[test]
1075    fn render_doc_uses_cascaded_color_from_group_opts_list() {
1076        let spec: DepictSpec = serde_json::from_str(
1077            r##"{
1078              "type": "group",
1079              "opts": [{"color": "#abcdef"}, {"type": "mol", "weight": 1.5}],
1080              "children": [{"type": "mol", "id": "e", "smiles": "CCO"}]
1081            }"##,
1082        )
1083        .unwrap();
1084        let edge = EdgeResult::new_v1(vec![EdgeTaskResult::CoordGen {
1085            ok: true,
1086            molecules: vec![CoordGenMoleculeResult {
1087                id: "e".into(),
1088                ok: true,
1089                method: CoordMethod::Free,
1090                used_map: None,
1091                molecule: Some(ethanol_mol("e")),
1092                error: None,
1093            }],
1094        }]);
1095        let painted = render_doc(&spec, &edge).unwrap();
1096        assert_eq!(painted[0].molecule.color.as_deref(), Some("#abcdef"));
1097        assert!((painted[0].molecule.weight - 1.5).abs() < 1e-12);
1098    }
1099
1100    #[test]
1101    fn reaction_scheme_nodes_and_edges_json() {
1102        let spec: DepictSpec = serde_json::from_str(
1103            r##"{
1104              "type": "reaction_scheme",
1105              "opts": [{"color": "#111"}, {"type": "mol", "weight": 1.2}],
1106              "children": [
1107                {"type": "text", "id": "adh", "text": "ADH"},
1108                {"type": "text", "id": "aldh", "text": "ALDH"},
1109                {"type": "mol", "id": "a", "smiles": "CCO", "label": "adh"},
1110                {
1111                  "type": "edge",
1112                  "source": "a",
1113                  "target": "b",
1114                  "label": {
1115                    "above": ["adh"],
1116                    "below": ["rt"],
1117                    "left": ["nabh4"]
1118                  },
1119                  "arrow": "forward"
1120                },
1121                {"type": "text", "id": "rt", "text": "rt"},
1122                {"type": "mol", "id": "nabh4", "smiles": "[BH4-]", "scale": 0.4},
1123                {"type": "mol", "id": "b", "smiles": "CC=O"},
1124                {
1125                  "type": "edge",
1126                  "source": "b",
1127                  "target": "c",
1128                  "label": [
1129                    "aldh",
1130                    {"id": "nad", "pos": "right"}
1131                  ],
1132                  "arrow": "equilibrium",
1133                  "color": "#064",
1134                  "dashed": true
1135                },
1136                {"type": "text", "id": "nad", "text": "NAD+"},
1137                {"type": "mol", "id": "c", "smiles": "CC(=O)O"}
1138              ]
1139            }"##,
1140        )
1141        .unwrap();
1142
1143        assert_eq!(spec.mols().len(), 4);
1144        assert_eq!(spec.texts().len(), 4);
1145        assert_eq!(spec.nodes().len(), 10);
1146        let edges = spec.edges();
1147        assert_eq!(edges.len(), 2);
1148        assert_eq!(edges[0].sources.as_slice(), &["a".to_string()]);
1149        assert_eq!(edges[0].targets.as_slice(), &["b".to_string()]);
1150        let e0 = edges[0].label.as_ref().unwrap().placements();
1151        assert_eq!(
1152            e0,
1153            vec![
1154                ("adh".into(), crate::doc::LabelPos::Above),
1155                ("rt".into(), crate::doc::LabelPos::Below),
1156                ("nabh4".into(), crate::doc::LabelPos::Left),
1157            ]
1158        );
1159        assert_eq!(edges[0].arrow, EdgeArrow::Forward);
1160        assert_eq!(edges[1].arrow, EdgeArrow::Equilibrium);
1161        let e1 = edges[1].label.as_ref().unwrap().placements();
1162        assert_eq!(
1163            e1,
1164            vec![
1165                ("aldh".into(), crate::doc::LabelPos::Above),
1166                ("nad".into(), crate::doc::LabelPos::Right),
1167            ]
1168        );
1169        assert_eq!(edges[1].color.as_deref(), Some("#064"));
1170        assert!(edges[1].dashed);
1171        assert_eq!(
1172            spec.mols()[0].label.as_ref().unwrap().placements(),
1173            vec![("adh".into(), crate::doc::LabelPos::Above)]
1174        );
1175        validate_edges(&spec).unwrap();
1176
1177        let chrome = spec.resolve_mol_chrome(0);
1178        assert_eq!(chrome.color.as_deref(), Some("#111"));
1179        assert_eq!(chrome.weight, Some(1.2));
1180    }
1181
1182    #[test]
1183    fn group_mol_label_refs_text_node() {
1184        let spec: DepictSpec = serde_json::from_str(
1185            r#"{
1186              "type": "group",
1187              "children": [
1188                {"type": "text", "id": "cap", "text": "ethanol"},
1189                {"type": "mol", "id": "a", "smiles": "CCO", "label": "cap"}
1190              ]
1191            }"#,
1192        )
1193        .unwrap();
1194        assert_eq!(spec.mols().len(), 1);
1195        assert_eq!(spec.texts().len(), 1);
1196        assert_eq!(
1197            spec.mols()[0].label.as_ref().unwrap().placements(),
1198            vec![("cap".into(), LabelPos::Above)]
1199        );
1200        validate_edges(&spec).unwrap();
1201    }
1202
1203    #[test]
1204    fn edge_label_string_list_and_placed_json() {
1205        let spec: DepictSpec = serde_json::from_str(
1206            r#"{
1207              "type": "reaction_scheme",
1208              "children": [
1209                {"type": "text", "id": "adh", "text": "ADH"},
1210                {"type": "text", "id": "rt", "text": "rt"},
1211                {"type": "mol", "id": "a", "smiles": "C"},
1212                {"type": "mol", "id": "b", "smiles": "CC"},
1213                {
1214                  "type": "edge",
1215                  "source": "a",
1216                  "target": "b",
1217                  "label": "adh"
1218                },
1219                {
1220                  "type": "edge",
1221                  "source": "b",
1222                  "target": "a",
1223                  "label": {"id": "rt", "pos": "below"}
1224                }
1225              ]
1226            }"#,
1227        )
1228        .unwrap();
1229        let edges = spec.edges();
1230        assert_eq!(
1231            edges[0].label.as_ref().unwrap().placements(),
1232            vec![("adh".into(), LabelPos::Above)]
1233        );
1234        assert_eq!(
1235            edges[1].label.as_ref().unwrap().placements(),
1236            vec![("rt".into(), LabelPos::Below)]
1237        );
1238        validate_edges(&spec).unwrap();
1239    }
1240
1241    #[test]
1242    fn reaction_scheme_edge_unknown_id_errors() {
1243        let spec: DepictSpec = serde_json::from_str(
1244            r#"{
1245              "type": "reaction_scheme",
1246              "children": [
1247                {"type": "mol", "id": "a", "smiles": "C"},
1248                {"type": "edge", "source": "a", "target": "missing"}
1249              ]
1250            }"#,
1251        )
1252        .unwrap();
1253        let err = validate_edges(&spec).unwrap_err();
1254        assert!(err.contains("unknown id missing"));
1255        assert!(plan_edge(&spec).unwrap_err().contains("unknown id"));
1256    }
1257
1258    #[test]
1259    fn mol_label_unknown_text_id_errors() {
1260        let spec: DepictSpec = serde_json::from_str(
1261            r#"{
1262              "type": "group",
1263              "children": [
1264                {"type": "mol", "id": "a", "smiles": "C", "label": "nope"}
1265              ]
1266            }"#,
1267        )
1268        .unwrap();
1269        let err = validate_edges(&spec).unwrap_err();
1270        assert!(err.contains("unknown text id nope"));
1271    }
1272
1273    #[test]
1274    fn reaction_scheme_layout_defaults_and_overrides() {
1275        let bare: DepictSpec = serde_json::from_str(
1276            r#"{"type":"reaction_scheme","children":[{"type":"mol","id":"a","smiles":"C"}]}"#,
1277        )
1278        .unwrap();
1279        let layout = match &bare {
1280            DepictSpec::ReactionScheme { layout, .. } => layout.clone().unwrap_or_default(),
1281            _ => panic!("expected reaction_scheme"),
1282        };
1283        assert_eq!(layout.direction_or_default(), LayoutDirection::Right);
1284        assert_eq!(layout.edge_routing_or_default(), EdgeRouting::Polyline);
1285        assert_eq!(layout.algorithm_or_default(), LayoutAlgorithm::Layered);
1286
1287        let custom: DepictSpec = serde_json::from_str(
1288            r#"{
1289              "type": "reaction_scheme",
1290              "layout": {
1291                "direction": "down",
1292                "edge_routing": "orthogonal",
1293                "algorithm": "force"
1294              },
1295              "children": [{"type":"mol","id":"a","smiles":"C"}]
1296            }"#,
1297        )
1298        .unwrap();
1299        let layout = match &custom {
1300            DepictSpec::ReactionScheme {
1301                layout: Some(l), ..
1302            } => l,
1303            _ => panic!("expected layout"),
1304        };
1305        assert_eq!(layout.direction, Some(LayoutDirection::Down));
1306        assert_eq!(layout.edge_routing, Some(EdgeRouting::Orthogonal));
1307        assert_eq!(layout.algorithm, Some(LayoutAlgorithm::Force));
1308    }
1309
1310    #[test]
1311    fn edge_routing_overrides_scheme_layout() {
1312        let spec: DepictSpec = serde_json::from_str(
1313            r#"{
1314              "type": "reaction_scheme",
1315              "layout": { "edge_routing": "polyline" },
1316              "children": [
1317                {"type": "mol", "id": "a", "smiles": "C"},
1318                {"type": "mol", "id": "b", "smiles": "CC"},
1319                {
1320                  "type": "edge",
1321                  "source": "a",
1322                  "target": "b",
1323                  "edge_routing": "orthogonal"
1324                },
1325                {
1326                  "type": "edge",
1327                  "source": "b",
1328                  "target": "a"
1329                }
1330              ]
1331            }"#,
1332        )
1333        .unwrap();
1334        let layout = match &spec {
1335            DepictSpec::ReactionScheme {
1336                layout: Some(l), ..
1337            } => l.clone(),
1338            _ => LayoutOpts::default(),
1339        };
1340        let edges = spec.edges();
1341        assert_eq!(edges[0].edge_routing, Some(EdgeRouting::Orthogonal));
1342        assert_eq!(edges[0].edge_routing_or(&layout), EdgeRouting::Orthogonal);
1343        assert_eq!(edges[1].edge_routing, None);
1344        assert_eq!(edges[1].edge_routing_or(&layout), EdgeRouting::Polyline);
1345    }
1346
1347    #[test]
1348    fn edge_multi_reactants_and_products() {
1349        let spec: DepictSpec = serde_json::from_str(
1350            r#"{
1351              "type": "reaction_scheme",
1352              "children": [
1353                {"type": "mol", "id": "a", "smiles": "C"},
1354                {"type": "mol", "id": "b", "smiles": "O"},
1355                {"type": "mol", "id": "c", "smiles": "CO"},
1356                {"type": "mol", "id": "d", "smiles": "O=O"},
1357                {
1358                  "type": "edge",
1359                  "sources": ["a", "b"],
1360                  "targets": ["c", "d"],
1361                  "arrow": "forward"
1362                }
1363              ]
1364            }"#,
1365        )
1366        .unwrap();
1367        let e = &spec.edges()[0];
1368        assert_eq!(
1369            e.sources.as_slice(),
1370            &["a".to_string(), "b".to_string()]
1371        );
1372        assert_eq!(
1373            e.targets.as_slice(),
1374            &["c".to_string(), "d".to_string()]
1375        );
1376        validate_edges(&spec).unwrap();
1377    }
1378
1379    #[test]
1380    fn cascade_reaction_scheme_typed_patch_does_not_paint_mol() {
1381        let spec: DepictSpec = serde_json::from_str(
1382            r##"{
1383              "type": "reaction_scheme",
1384              "opts": [
1385                {"type": "reaction_scheme", "color": "#ff0000"},
1386                {"type": "mol", "color": "#00ff00"}
1387              ],
1388              "children": [{"type": "mol", "id": "n", "smiles": "C"}]
1389            }"##,
1390        )
1391        .unwrap();
1392        let chrome = spec.resolve_mol_chrome(0);
1393        assert_eq!(chrome.color.as_deref(), Some("#00ff00"));
1394    }
1395}