1mod 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
24pub 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
41pub fn validate_edges(spec: &DepictSpec) -> Result<(), String> {
43 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 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
137pub 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 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 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
238pub 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 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 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#[derive(Debug, Clone)]
316pub struct DocPaint {
317 pub id: String,
318 pub molecule: MoleculeIn,
319 pub scene: Scene,
320}
321
322pub 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 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 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 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 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 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")); assert_eq!(a.weight, Some(1.25)); assert_eq!(a.scale, Some(1.5)); assert_eq!(a.halo, Some(true));
1005
1006 let b = spec.resolve_mol_chrome(1);
1007 assert_eq!(b.color.as_deref(), Some("#111")); assert_eq!(b.weight, Some(2.0)); assert_eq!(b.halo, Some(false)); assert_eq!(b.scale, Some(1.5)); }
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")); assert_eq!(chrome.scale, Some(3.0)); }
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 assert_eq!(s.vmin, Some(-1.0));
1052 assert_eq!(s.vmax, Some(1.0));
1053 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}