1use std::collections::HashMap;
8
9use crate::bonds::{bond_strokes, join_centered_multibonds, DrawnBond, StrokePath};
10use crate::colormap::colormap_rgb;
11use crate::font::FaceStyle;
12use crate::labels::{self, place_backbone};
13use crate::metrics::{
14 halo_gap_for_weight, halo_stroke_for_weight, label_weight_grow_px, stroke_px_for_weight,
15 BOND_PX, FONT_PX, HALO_OPACITY, MARK_FRAC, PAD_PX, SHADE_FRAC,
16};
17use crate::plotdot::PlotDot;
18use crate::rings::{bond_interior_normals, find_sssr};
19use crate::scene::{
20 AtomIn, Layer, LayerName, MoleculeIn, Primitive, Scene, Viewport,
21};
22
23pub fn depict_molecule(mol: &MoleculeIn) -> Scene {
27 let color = mol.color.as_deref().unwrap_or("#111");
28 let face = FaceStyle::Regular;
29 let weight = mol.weight;
31 let _ = mol.diagram_weight(); let stroke_px = stroke_px_for_weight(weight);
33 let label_grow = label_weight_grow_px(weight);
34 let by_index: HashMap<i32, usize> = mol
35 .atoms
36 .iter()
37 .enumerate()
38 .map(|(i, a)| (a.index, i))
39 .collect();
40
41 let mut min_x = f64::INFINITY;
43 let mut min_y = f64::INFINITY;
44 let mut max_x = f64::NEG_INFINITY;
45 let mut max_y = f64::NEG_INFINITY;
46 for a in &mol.atoms {
47 min_x = min_x.min(a.x);
48 min_y = min_y.min(a.y);
49 max_x = max_x.max(a.x);
50 max_y = max_y.max(a.y);
51 }
52 if mol.atoms.is_empty() {
53 min_x = 0.0;
54 min_y = 0.0;
55 max_x = 0.0;
56 max_y = 0.0;
57 } else if !mol.mark_atoms.is_empty() {
58 let mark_r = BOND_PX * MARK_FRAC;
59 min_x -= mark_r;
60 min_y -= mark_r;
61 max_x += mark_r;
62 max_y += mark_r;
63 }
64 let label_pad = FONT_PX * 0.85;
66 for a in &mol.atoms {
67 if display_label(a).is_some() {
68 min_x = min_x.min(a.x - label_pad);
69 max_x = max_x.max(a.x + label_pad * 1.6);
70 min_y = min_y.min(a.y - label_pad);
71 max_y = max_y.max(a.y + label_pad);
72 }
73 }
74 let pad = PAD_PX;
75 let dx = pad - min_x;
76 let dy = pad - min_y;
77 let mut width = (max_x - min_x) + 2.0 * pad;
78 let mut height = (max_y - min_y) + 2.0 * pad;
79
80 let label_atoms: Vec<labels::AtomIn> = mol
81 .atoms
82 .iter()
83 .map(|a| labels::AtomIn {
84 x: a.x + dx,
85 y: a.y + dy,
86 label: display_label(a),
87 })
88 .collect();
89 let label_bonds: Vec<labels::BondIn> = mol
90 .bonds
91 .iter()
92 .map(|b| labels::BondIn {
93 begin: *by_index.get(&b.begin).unwrap_or(&0),
94 end: *by_index.get(&b.end).unwrap_or(&0),
95 })
96 .collect();
97 let (shortened, placed) = place_backbone(&label_atoms, &label_bonds, FONT_PX, face, weight);
98
99 let ring_coords: HashMap<i32, (f64, f64)> = mol
101 .atoms
102 .iter()
103 .map(|a| (a.index, (a.x + dx, a.y + dy)))
104 .collect();
105 let ring_normals = bond_interior_normals(&find_sssr(mol, 8), &ring_coords);
106
107 let mut prepared: Vec<DrawnBond> = Vec::new();
108 for (bond, ends) in mol.bonds.iter().zip(shortened.iter()) {
109 if !by_index.contains_key(&bond.begin) || !by_index.contains_key(&bond.end) {
110 continue;
111 }
112 let i0 = by_index[&bond.begin];
113 let i1 = by_index[&bond.end];
114 let mut db = DrawnBond::new(
115 bond.index,
116 bond.begin,
117 bond.end,
118 ends.x1,
119 ends.y1,
120 ends.x2,
121 ends.y2,
122 bond.order,
123 );
124 db.stereo = bond.stereo.clone();
125 let key = if bond.begin < bond.end {
126 (bond.begin, bond.end)
127 } else {
128 (bond.end, bond.begin)
129 };
130 db.interior = ring_normals.get(&key).copied().or(bond.interior);
132 db.begin_labeled = placed[i0].is_some();
133 db.end_labeled = placed[i1].is_some();
134 prepared.push(db);
135 }
136 join_centered_multibonds(&mut prepared);
137
138 let mut bond_prims: Vec<Primitive> = Vec::new();
139 let mut bond_strokes_for_halo: Vec<StrokePath> = Vec::new();
140 for bond in &prepared {
141 let strokes = bond_strokes(
142 bond.x1,
143 bond.y1,
144 bond.x2,
145 bond.y2,
146 bond.order,
147 bond.interior,
148 bond.stereo.as_deref(),
149 bond.trims.as_ref(),
150 Some(stroke_px),
151 );
152 let tag = format!(
153 "bond-{} atom-{} atom-{}",
154 bond.index, bond.begin, bond.end
155 );
156 for sp in strokes.paint_order() {
157 bond_strokes_for_halo.push((*sp).clone());
158 }
159 for mut p in strokes.into_primitives(color) {
160 if let Primitive::Path { ref mut class, .. } = p {
161 if let Some(c) = class.take() {
162 *class = Some(format!("{tag} {c}"));
163 } else {
164 *class = Some(tag.clone());
165 }
166 }
167 bond_prims.push(p);
168 }
169 }
170
171 let mut label_prims: Vec<Primitive> = Vec::new();
172 let mut label_ink_for_halo: Vec<crate::geom::Shape> = Vec::new();
173 for (slot, pl) in placed.iter().enumerate() {
174 let Some(pl) = pl else { continue };
175 let atom_index = mol.atoms[slot].index;
176 width = width.max(pl.origin_x + FONT_PX * pl.text.chars().count() as f64 * 0.65 + pad * 0.25);
178 height = height.max(pl.y + FONT_PX * 0.35 + pad * 0.25);
179 if matches!(pl.side, labels::LabelSide::North | labels::LabelSide::South) {
180 height = height.max(pl.atom_y + FONT_PX * 1.6 + pad * 0.25);
181 }
182 let Some(mut ink) = labels::label_ink_shape(pl, FONT_PX, face) else {
183 continue;
184 };
185 if label_grow > 1e-9 {
186 ink = ink.buffer(label_grow);
187 }
188 let d = ink.to_svg_d();
189 label_ink_for_halo.push(ink);
190 if d.is_empty() {
191 continue;
192 }
193 label_prims.push(Primitive::Path {
194 d,
195 stroke: Some("none".into()),
196 fill: Some(color.to_string()),
197 stroke_width: 0.0,
198 opacity: 1.0,
199 stroke_dasharray: None,
200 stroke_linecap: None,
201 class: Some(format!("atom-{atom_index} label")),
202 data_text: Some(pl.text.clone()),
203 });
204 }
205
206 let shade_prims = paint_shade(mol, &by_index, dx, dy);
207 let mark_prims = paint_marks(mol, &by_index, dx, dy);
208 let halo_prims = paint_halo(&bond_strokes_for_halo, &label_ink_for_halo, stroke_px, weight);
209
210 let mut layers = Vec::new();
211 if !shade_prims.is_empty() {
212 layers.push(Layer {
213 name: LayerName::Shading,
214 primitives: shade_prims,
215 });
216 }
217 if !bond_prims.is_empty() {
218 layers.push(Layer {
219 name: LayerName::Bonds,
220 primitives: bond_prims,
221 });
222 }
223 if !label_prims.is_empty() {
224 layers.push(Layer {
225 name: LayerName::Labels,
226 primitives: label_prims,
227 });
228 }
229 if !mark_prims.is_empty() {
230 layers.push(Layer {
231 name: LayerName::Marks,
232 primitives: mark_prims,
233 });
234 }
235
236 let scene = Scene {
237 width,
238 height,
239 viewports: vec![Viewport {
240 id: mol.id.clone(),
241 x: 0.0,
242 y: 0.0,
243 width,
244 height,
245 layers,
246 }],
247 overlays: Vec::new(),
248 halo: halo_prims,
249 };
250 scene.scale_uniform(mol.diagram_scale())
252}
253
254fn display_label(a: &AtomIn) -> Option<String> {
259 let mut body = if let Some(ref l) = a.label {
260 let t = l.trim();
261 if t.is_empty() {
262 None
263 } else {
264 Some(t.to_string())
265 }
266 } else {
267 None
268 };
269 if body.is_none() {
270 let sym = a.symbol();
271 if sym == "C" && a.charge == 0 {
272 return None;
273 }
274 body = Some(sym.to_string());
275 }
276 let mut body = body.unwrap();
277 let parsed_chg = labels::split_label(&body).charge;
278 if a.charge != 0 && parsed_chg == 0 {
279 let sign = if a.charge > 0 { "+" } else { "−" };
280 let mag = a.charge.unsigned_abs();
281 if mag == 1 {
282 body.push_str(sign);
283 } else {
284 body.push_str(&format!("{mag}{sign}"));
285 }
286 }
287 Some(body)
288}
289
290fn paint_shade(
291 mol: &MoleculeIn,
292 by_index: &HashMap<i32, usize>,
293 dx: f64,
294 dy: f64,
295) -> Vec<Primitive> {
296 let atom_zs = mol.atom_shade.as_deref().unwrap_or(&[]);
297 let bond_zs = mol.bond_shade.as_deref().unwrap_or(&[]);
298 if atom_zs.is_empty() && bond_zs.is_empty() {
299 return Vec::new();
300 }
301 let vmin = mol.shade_vmin.unwrap_or(0.0);
303 let vmax = mol.shade_vmax.unwrap_or(1.0);
304 let diverging = vmin < 0.0 && vmax > 0.0;
305 let base_r = BOND_PX
306 * (if !atom_zs.is_empty() && !bond_zs.is_empty() {
307 0.8
308 } else {
309 SHADE_FRAC
310 });
311 let plot = PlotDot::default();
312 let mut zs: Vec<f64> = Vec::new();
315 let mut coords: Vec<(f64, f64)> = Vec::new();
316 if !atom_zs.is_empty() {
317 for (a, &z) in mol.atoms.iter().zip(atom_zs.iter()) {
318 zs.push(z);
319 coords.push((a.x + dx, a.y + dy));
320 }
321 }
322 if !bond_zs.is_empty() {
323 for (bond, &z) in mol.bonds.iter().zip(bond_zs.iter()) {
324 let Some(&i0) = by_index.get(&bond.begin) else {
325 continue;
326 };
327 let Some(&i1) = by_index.get(&bond.end) else {
328 continue;
329 };
330 let a0 = &mol.atoms[i0];
331 let a1 = &mol.atoms[i1];
332 zs.push(z);
333 coords.push(((a0.x + a1.x) * 0.5 + dx, (a0.y + a1.y) * 0.5 + dy));
334 }
335 }
336 let norm = normalize_shade_scores(&zs, vmin, vmax);
337 let n = norm.len().min(coords.len());
338 let mut out = Vec::new();
339 for d in plot.disks(&norm[..n], &coords[..n]) {
340 if d.color_z.abs() < 0.05 && d.radius_frac < 0.35 {
341 continue;
342 }
343 if d.color_z.abs() < 0.02 {
344 continue;
345 }
346 out.push(Primitive::Circle {
347 cx: d.x,
348 cy: d.y,
349 r: base_r * d.radius_frac,
350 fill: Some(colormap_rgb(d.color_z, diverging)),
351 stroke: None,
352 stroke_width: 0.0,
353 opacity: 1.0,
354 class: Some("shade".into()),
355 });
356 }
357 out
358}
359
360fn normalize_shade_scores(zs: &[f64], vmin: f64, vmax: f64) -> Vec<f64> {
361 if vmin >= 0.0 && vmax > 0.0 {
362 return zs
363 .iter()
364 .map(|&z| (z / vmax).clamp(0.0, 1.0))
365 .collect();
366 }
367 if vmax <= 0.0 && vmin < 0.0 {
368 return zs
369 .iter()
370 .map(|&z| (z / vmin.abs()).clamp(-1.0, 0.0))
371 .collect();
372 }
373 let scale = vmin.abs().max(vmax.abs()).max(1e-9);
374 zs.iter()
375 .map(|&z| (z / scale).clamp(-1.0, 1.0))
376 .collect()
377}
378
379#[cfg(feature = "geom")]
385fn paint_halo(
386 bond_strokes: &[StrokePath],
387 label_ink: &[crate::geom::Shape],
388 stroke_px: f64,
389 weight: f64,
390) -> Vec<Primitive> {
391 use crate::geom::Shape;
392
393 let mut ink: Option<Shape> = None;
394 let mut absorb = |piece: Shape| {
395 if piece.is_empty() {
396 return;
397 }
398 ink = Some(match ink.take() {
399 Some(acc) => acc.union(&piece),
400 None => piece,
401 });
402 };
403
404 for sp in bond_strokes {
405 let pts = path_points(&sp.d);
406 if pts.len() < 2 {
407 continue;
408 }
409 let ink_r = sp.stroke_width.max(stroke_px) * 0.5;
410 if sp.fill.as_deref().is_some_and(|f| f != "none") && pts.len() >= 3 {
412 let poly = Shape::from_ring(&pts);
413 if !poly.is_empty() {
414 let grown = if ink_r > 0.0 {
415 poly.buffer(ink_r)
416 } else {
417 poly
418 };
419 absorb(grown);
420 }
421 continue;
422 }
423 for w in pts.windows(2) {
424 let (x1, y1) = w[0];
425 let (x2, y2) = w[1];
426 if (x1 - x2).hypot(y1 - y2) < 1e-6 {
427 continue;
428 }
429 absorb(Shape::capsule(x1, y1, x2, y2, ink_r));
430 }
431 }
432
433 for shape in label_ink {
434 absorb(shape.clone());
435 }
436
437 let Some(ink) = ink.filter(|s| !s.is_empty()) else {
438 return Vec::new();
439 };
440 let ink_r = stroke_px * 0.5;
442 let halo_stroke = halo_stroke_for_weight(weight);
443 let dist = halo_gap_for_weight(weight).max(0.25 * halo_stroke - ink_r);
444 let grown = ink.halo(dist);
445 if grown.is_empty() {
446 return Vec::new();
447 }
448 let d = grown.to_svg_d();
449 if d.is_empty() {
450 return Vec::new();
451 }
452 vec![Primitive::Path {
453 d,
454 stroke: Some("none".into()),
455 fill: Some("#fff".into()),
456 stroke_width: 0.0,
457 opacity: HALO_OPACITY,
458 stroke_dasharray: None,
459 stroke_linecap: None,
460 class: Some("halo".into()),
461 data_text: None,
462 }]
463}
464
465#[cfg(not(feature = "geom"))]
466fn paint_halo(
467 _bond_strokes: &[StrokePath],
468 _label_ink: &[crate::geom::Shape],
469 _stroke_px: f64,
470 _weight: f64,
471) -> Vec<Primitive> {
472 Vec::new()
473}
474
475fn path_points(d: &str) -> Vec<(f64, f64)> {
477 let mut out = Vec::new();
478 let mut nums = Vec::new();
479 let mut cur = String::new();
480 let flush_num = |cur: &mut String, nums: &mut Vec<f64>| {
481 if !cur.is_empty() {
482 if let Ok(v) = cur.parse::<f64>() {
483 nums.push(v);
484 }
485 cur.clear();
486 }
487 };
488 for ch in d.chars() {
489 if ch.is_ascii_digit() || ch == '.' || ch == '-' || ch == '+' || ch == 'e' || ch == 'E' {
490 cur.push(ch);
491 } else {
492 flush_num(&mut cur, &mut nums);
493 }
494 }
495 flush_num(&mut cur, &mut nums);
496 for pair in nums.chunks(2) {
497 if pair.len() == 2 {
498 out.push((pair[0], pair[1]));
499 }
500 }
501 out
502}
503
504fn paint_marks(
505 mol: &MoleculeIn,
506 by_index: &HashMap<i32, usize>,
507 dx: f64,
508 dy: f64,
509) -> Vec<Primitive> {
510 use crate::geom::{capsule_polygon, polygon_to_svg_d};
511 use crate::metrics::{
512 MARK_HALO_COLOR, MARK_HALO_OPACITY, MARK_HALO_STROKE_PX, MARK_OPACITY, MARK_STROKE_PX,
513 };
514
515 let mut out = Vec::new();
516 let r = BOND_PX * MARK_FRAC;
517 const CAPSULE_QUAD_SEGS: u32 = 16;
524
525 let mut bond_capsules: Vec<(i32, i32, String)> = Vec::new();
527 for &(a, b) in &mol.mark_bonds {
528 let Some(&ia) = by_index.get(&a) else {
529 continue;
530 };
531 let Some(&ib) = by_index.get(&b) else {
532 continue;
533 };
534 let a0 = &mol.atoms[ia];
535 let a1 = &mol.atoms[ib];
536 let d = polygon_to_svg_d(&capsule_polygon(
537 a0.x + dx,
538 a0.y + dy,
539 a1.x + dx,
540 a1.y + dy,
541 r,
542 CAPSULE_QUAD_SEGS,
543 ));
544 if !d.is_empty() {
545 bond_capsules.push((a, b, d));
546 }
547 }
548
549 for &ai in &mol.mark_atoms {
551 let Some(&i) = by_index.get(&ai) else {
552 continue;
553 };
554 let a = &mol.atoms[i];
555 out.push(Primitive::Circle {
556 cx: a.x + dx,
557 cy: a.y + dy,
558 r,
559 fill: Some("none".into()),
560 stroke: Some(MARK_HALO_COLOR.into()),
561 stroke_width: MARK_HALO_STROKE_PX,
562 opacity: MARK_HALO_OPACITY,
563 class: Some(format!("atom-{ai} mark-halo")),
564 });
565 }
566 for (a, b, d) in &bond_capsules {
567 out.push(Primitive::Path {
568 d: d.clone(),
569 stroke: Some(MARK_HALO_COLOR.into()),
570 fill: Some("none".into()),
571 stroke_width: MARK_HALO_STROKE_PX,
572 opacity: MARK_HALO_OPACITY,
573 stroke_dasharray: None,
574 stroke_linecap: Some("round".into()),
575 class: Some(format!("bond-mark-halo atom-{a} atom-{b}")),
576 data_text: None,
577 });
578 }
579
580 for &ai in &mol.mark_atoms {
583 let Some(&i) = by_index.get(&ai) else {
584 continue;
585 };
586 let a = &mol.atoms[i];
587 out.push(Primitive::Circle {
588 cx: a.x + dx,
589 cy: a.y + dy,
590 r,
591 fill: Some("none".into()),
592 stroke: None,
593 stroke_width: MARK_STROKE_PX,
594 opacity: MARK_OPACITY,
595 class: Some(format!("atom-{ai} mark")),
596 });
597 }
598 for (a, b, d) in bond_capsules {
599 out.push(Primitive::Path {
600 d,
601 stroke: None,
602 fill: Some("none".into()),
603 stroke_width: MARK_STROKE_PX,
604 opacity: MARK_OPACITY,
605 stroke_dasharray: None,
606 stroke_linecap: Some("round".into()),
607 class: Some(format!("bond-mark atom-{a} atom-{b}")),
608 data_text: None,
609 });
610 }
611 out
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::scene::{AtomIn, BondIn};
618
619 fn atom(index: i32, element: &str, x: f64, y: f64, label: Option<&str>) -> AtomIn {
620 AtomIn {
621 index,
622 element: Some(element.into()),
623 z: None,
624 x,
625 y,
626 label: label.map(str::to_string),
627 charge: 0,
628 }
629 }
630
631 fn ethanol() -> MoleculeIn {
632 MoleculeIn {
633 id: Some("etoh".into()),
634 atoms: vec![
635 atom(0, "C", 0.0, 0.0, None),
636 atom(1, "C", 20.0, 0.0, None),
637 atom(2, "O", 30.0, 10.0, Some("OH")),
638 ],
639 bonds: vec![
640 BondIn {
641 index: 0,
642 begin: 0,
643 end: 1,
644 order: 1.0,
645 stereo: None,
646 interior: None,
647 },
648 BondIn {
649 index: 1,
650 begin: 1,
651 end: 2,
652 order: 1.0,
653 stereo: None,
654 interior: None,
655 },
656 ],
657 color: None,
658 atom_shade: None,
659 bond_shade: None,
660 shade_vmin: None,
661 shade_vmax: None,
662 mark_atoms: vec![],
663 mark_bonds: vec![],
664 weight: 1.0,
665 scale: 1.0,
666 }
667 }
668
669 fn acetone() -> MoleculeIn {
670 MoleculeIn {
671 id: Some("acetone".into()),
672 atoms: vec![
673 atom(0, "C", -20.0, 8.0, None),
674 atom(1, "C", 0.0, 0.0, None),
675 atom(2, "O", 0.0, -20.0, Some("O")),
676 atom(3, "C", 20.0, 8.0, None),
677 ],
678 bonds: vec![
679 BondIn {
680 index: 0,
681 begin: 0,
682 end: 1,
683 order: 1.0,
684 stereo: None,
685 interior: None,
686 },
687 BondIn {
688 index: 1,
689 begin: 1,
690 end: 2,
691 order: 2.0,
692 stereo: None,
693 interior: None,
694 },
695 BondIn {
696 index: 2,
697 begin: 1,
698 end: 3,
699 order: 1.0,
700 stereo: None,
701 interior: None,
702 },
703 ],
704 color: Some("#336699".into()),
705 atom_shade: None,
706 bond_shade: None,
707 shade_vmin: None,
708 shade_vmax: None,
709 mark_atoms: vec![],
710 mark_bonds: vec![],
711 weight: 1.0,
712 scale: 1.0,
713 }
714 }
715
716 #[test]
717 fn z_only_atoms_resolve_symbols() {
718 let mol = MoleculeIn {
719 id: Some("z".into()),
720 atoms: vec![
721 AtomIn {
722 index: 0,
723 element: None,
724 z: Some(6),
725 x: 0.0,
726 y: 0.0,
727 label: None,
728 charge: 0,
729 },
730 AtomIn {
731 index: 1,
732 element: None,
733 z: Some(8),
734 x: 20.0,
735 y: 0.0,
736 label: Some("O".into()),
737 charge: 0,
738 },
739 ],
740 bonds: vec![BondIn {
741 index: 0,
742 begin: 0,
743 end: 1,
744 order: 1.0,
745 stereo: None,
746 interior: None,
747 }],
748 color: None,
749 atom_shade: None,
750 bond_shade: None,
751 shade_vmin: None,
752 shade_vmax: None,
753 mark_atoms: vec![],
754 mark_bonds: vec![],
755 weight: 1.0,
756 scale: 1.0,
757 };
758 assert_eq!(mol.atoms[0].symbol(), "C");
759 assert_eq!(mol.atoms[1].symbol(), "O");
760 let scene = depict_molecule(&mol);
761 assert_eq!(scene.viewports.len(), 1);
762 assert_eq!(scene.viewports[0].id.as_deref(), Some("z"));
763 let bonds = scene.viewports[0]
764 .layers
765 .iter()
766 .find(|l| l.name == LayerName::Bonds)
767 .expect("bonds");
768 assert_eq!(bonds.primitives.len(), 1);
769 let labels = scene.viewports[0]
770 .layers
771 .iter()
772 .find(|l| l.name == LayerName::Labels)
773 .expect("labels");
774 let texts: Vec<_> = labels
775 .primitives
776 .iter()
777 .filter_map(|p| match p {
778 Primitive::Path {
779 data_text: Some(t),
780 ..
781 } => Some(t.as_str()),
782 _ => None,
783 })
784 .collect();
785 assert!(
786 texts.iter().any(|t| *t == "O"),
787 "expected O label from z=8 atom, got {texts:?}"
788 );
789 }
790
791 #[test]
792 fn ethanol_emits_oh_label() {
793 let scene = depict_molecule(ðanol());
794 let labels = scene.viewports[0]
795 .layers
796 .iter()
797 .find(|l| l.name == LayerName::Labels)
798 .expect("labels layer");
799 let texts: Vec<_> = labels
800 .primitives
801 .iter()
802 .filter_map(|p| match p {
803 Primitive::Path {
804 data_text: Some(t),
805 ..
806 } => Some(t.as_str()),
807 _ => None,
808 })
809 .collect();
810 assert!(
811 texts.iter().any(|t| *t == "OH" || *t == "HO"),
812 "expected OH/HO path data-text, got {texts:?}"
813 );
814 assert!(
815 labels.primitives.iter().all(|p| matches!(p, Primitive::Path { .. })),
816 "labels must be glyph paths, not text nodes"
817 );
818 }
819
820 #[test]
821 fn ethanol_has_bond_layer() {
822 let scene = depict_molecule(ðanol());
823 assert_eq!(scene.viewports.len(), 1);
824 let bonds = scene.viewports[0]
825 .layers
826 .iter()
827 .find(|l| l.name == LayerName::Bonds)
828 .expect("bonds");
829 assert_eq!(bonds.primitives.len(), 2);
830 assert!(scene.width > PAD_PX);
831 assert!(scene.height > PAD_PX);
832 }
833
834 #[test]
835 fn mol_weight_thickens_bond_stroke() {
836 use crate::metrics::{stroke_px_for_weight, STROKE_PX};
837 let mut mol = ethanol();
838 mol.weight = 2.0;
839 let scene = depict_molecule(&mol);
840 let bonds = scene.viewports[0]
841 .layers
842 .iter()
843 .find(|l| l.name == LayerName::Bonds)
844 .expect("bonds");
845 let sw = bonds
846 .primitives
847 .iter()
848 .find_map(|p| match p {
849 Primitive::Path { stroke_width, .. } if *stroke_width > 0.0 => Some(*stroke_width),
850 _ => None,
851 })
852 .expect("bond stroke");
853 let expected = stroke_px_for_weight(2.0);
854 assert!(
855 (sw - expected).abs() < 0.02,
856 "weight stroke {sw} vs {expected}"
857 );
858 assert!(sw > STROKE_PX + 0.3);
859 }
860
861 #[test]
862 fn mol_scale_doubles_scene_size_and_stroke() {
863 let base = depict_molecule(ðanol());
864 let mut mol = ethanol();
865 mol.scale = 2.0;
866 let scaled = depict_molecule(&mol);
867 assert!((scaled.width - 2.0 * base.width).abs() < 1e-6);
868 assert!((scaled.height - 2.0 * base.height).abs() < 1e-6);
869 let base_sw = base.viewports[0]
870 .layers
871 .iter()
872 .find(|l| l.name == LayerName::Bonds)
873 .unwrap()
874 .primitives
875 .iter()
876 .find_map(|p| match p {
877 Primitive::Path { stroke_width, .. } if *stroke_width > 0.0 => Some(*stroke_width),
878 _ => None,
879 })
880 .unwrap();
881 let scaled_sw = scaled.viewports[0]
882 .layers
883 .iter()
884 .find(|l| l.name == LayerName::Bonds)
885 .unwrap()
886 .primitives
887 .iter()
888 .find_map(|p| match p {
889 Primitive::Path { stroke_width, .. } if *stroke_width > 0.0 => Some(*stroke_width),
890 _ => None,
891 })
892 .unwrap();
893 assert!((scaled_sw - 2.0 * base_sw).abs() < 1e-6);
894 }
895
896 #[test]
897 fn acetone_centered_double_has_two_offsets() {
898 let scene = depict_molecule(&acetone());
899 let bonds = scene.viewports[0]
900 .layers
901 .iter()
902 .find(|l| l.name == LayerName::Bonds)
903 .expect("bonds");
904 let offset_count = bonds
905 .primitives
906 .iter()
907 .filter(|p| match p {
908 Primitive::Path { class: Some(c), .. } => c.contains("bond-offset"),
909 _ => false,
910 })
911 .count();
912 assert_eq!(offset_count, 2, "centered C=O must emit exactly two offsets");
913 assert!(
914 bonds.primitives.iter().all(|p| match p {
915 Primitive::Path {
916 stroke: Some(s), ..
917 } => s == "#336699",
918 _ => true,
919 }),
920 "all bond strokes must use molecule color #336699"
921 );
922 }
923
924 #[test]
925 fn benzene_ring_doubles_are_single_sided_short() {
926 let mut atoms = Vec::new();
929 for i in 0..6 {
930 let ang = std::f64::consts::PI / 2.0 + i as f64 * std::f64::consts::TAU / 6.0;
931 atoms.push(AtomIn {
932 index: i,
933 element: Some("C".into()),
934 z: None,
935 x: 20.0 * ang.cos(),
936 y: -20.0 * ang.sin(),
937 label: None,
938 charge: 0,
939 });
940 }
941 let orders = [1.0, 2.0, 1.0, 2.0, 1.0, 2.0];
942 let mut bonds = Vec::new();
943 for i in 0..6 {
944 bonds.push(BondIn {
945 index: i,
946 begin: i,
947 end: (i + 1) % 6,
948 order: orders[i as usize],
949 stereo: None,
950 interior: None,
951 });
952 }
953 let mol = MoleculeIn {
954 id: Some("phh".into()),
955 atoms,
956 bonds,
957 color: None,
958 atom_shade: None,
959 bond_shade: None,
960 shade_vmin: None,
961 shade_vmax: None,
962 mark_atoms: vec![],
963 mark_bonds: vec![],
964 weight: 1.0,
965 scale: 1.0,
966 };
967 let scene = depict_molecule(&mol);
968 let layer = scene.viewports[0]
969 .layers
970 .iter()
971 .find(|l| l.name == LayerName::Bonds)
972 .expect("bonds");
973 let offset_count = layer
974 .primitives
975 .iter()
976 .filter(|p| match p {
977 Primitive::Path { class: Some(c), .. } => c.contains("bond-offset"),
978 _ => false,
979 })
980 .count();
981 assert_eq!(offset_count, 3, "ring doubles should be single-sided");
983 }
984
985 #[test]
986 fn marks_and_shade_layers() {
987 let mut mol = ethanol();
988 mol.mark_atoms = vec![2];
989 mol.atom_shade = Some(vec![0.0, 0.0, 0.9]);
990 let scene = depict_molecule(&mol);
991 let names: Vec<_> = scene.viewports[0]
992 .layers
993 .iter()
994 .map(|l| l.name)
995 .collect();
996 assert!(names.contains(&LayerName::Shading));
997 assert!(names.contains(&LayerName::Bonds));
998 assert!(names.contains(&LayerName::Marks));
999 let shade = scene.viewports[0]
1001 .layers
1002 .iter()
1003 .find(|l| l.name == LayerName::Shading)
1004 .expect("shading");
1005 assert!(shade.primitives.iter().any(|p| match p {
1006 Primitive::Circle {
1007 fill: Some(f), ..
1008 } => f.starts_with("rgb(") && !f.starts_with("rgb(255,222"),
1009 _ => false,
1010 }));
1011 assert!(!scene.halo.is_empty(), "expected bond/label halo knockout");
1012 }
1013
1014 #[test]
1015 fn shade_atom_and_bond_share_one_sorted_layer() {
1016 let mut mol = ethanol();
1017 mol.atom_shade = Some(vec![0.3, 0.3, 0.3]);
1019 mol.bond_shade = Some(vec![1.0, 0.2]);
1020 let scene = depict_molecule(&mol);
1021 let shade = scene.viewports[0]
1022 .layers
1023 .iter()
1024 .find(|l| l.name == LayerName::Shading)
1025 .expect("shading");
1026 let fills: Vec<_> = shade
1027 .primitives
1028 .iter()
1029 .filter_map(|p| match p {
1030 Primitive::Circle {
1031 fill: Some(f),
1032 r,
1033 ..
1034 } => Some((f.clone(), *r)),
1035 _ => None,
1036 })
1037 .collect();
1038 assert!(fills.len() >= 4, "atom+bond shade should emit multiple disks, got {}", fills.len());
1039 let max_r = fills.iter().map(|(_, r)| *r).fold(0.0_f64, f64::max);
1041 assert!(
1042 (max_r - BOND_PX * 0.8).abs() < 0.05,
1043 "expected 0.8×bond when both atom+bond shaded, got {max_r}"
1044 );
1045 }
1046
1047 #[test]
1048 fn mark_circles_match_xenopict_style() {
1049 let mut mol = ethanol();
1050 mol.mark_atoms = vec![1];
1051 mol.mark_bonds = vec![(0, 1)];
1052 let scene = depict_molecule(&mol);
1053 let marks = scene.viewports[0]
1054 .layers
1055 .iter()
1056 .find(|l| l.name == LayerName::Marks)
1057 .expect("marks");
1058 let atom = marks.primitives.iter().find_map(|p| match p {
1059 Primitive::Circle {
1060 r,
1061 stroke_width,
1062 opacity,
1063 class: Some(c),
1064 stroke,
1065 ..
1066 } if c.contains(" mark") && !c.contains("halo") => {
1067 Some((*r, *stroke_width, *opacity, stroke.clone()))
1068 }
1069 _ => None,
1070 });
1071 let (r, sw, op, stroke) = atom.expect("atom mark");
1072 assert!((r - BOND_PX).abs() < 1e-9, "mark radius = scale");
1073 assert!(
1074 (sw - BOND_PX * 0.1).abs() < 1e-9,
1075 "mark stroke = scale*0.1 (xenopict), got {sw}"
1076 );
1077 assert!((op - 0.7).abs() < 1e-9);
1078 assert!(
1079 stroke.is_none(),
1080 "mark ink has no baked stroke (xenopict); visible color is halo #555"
1081 );
1082 mol.color = Some("#0b6e4f".into());
1084 let scene2 = depict_molecule(&mol);
1085 let marks2 = scene2.viewports[0]
1086 .layers
1087 .iter()
1088 .find(|l| l.name == LayerName::Marks)
1089 .expect("marks");
1090 let ink = marks2.primitives.iter().find_map(|p| match p {
1091 Primitive::Circle {
1092 stroke,
1093 class: Some(c),
1094 ..
1095 } if c.contains(" mark") && !c.contains("halo") => Some(stroke.clone()),
1096 _ => None,
1097 });
1098 assert_eq!(ink, Some(None), "backbone color must not paint mark ink");
1099 let halo = marks.primitives.iter().any(|p| match p {
1100 Primitive::Circle {
1101 stroke: Some(s),
1102 stroke_width,
1103 opacity,
1104 class: Some(c),
1105 ..
1106 } => c.contains("mark-halo") && s == "#555" && (*stroke_width - BOND_PX * 0.2).abs() < 1e-9 && (*opacity - 0.45).abs() < 1e-9,
1107 _ => false,
1108 });
1109 assert!(halo, "expected #555 mark halo underlay");
1110 let bond = marks.primitives.iter().any(|p| match p {
1111 Primitive::Path {
1112 d,
1113 stroke,
1114 stroke_width,
1115 opacity,
1116 class: Some(c),
1117 ..
1118 } => {
1119 c.contains("bond-mark")
1120 && !c.contains("halo")
1121 && d.contains('Z')
1122 && stroke.is_none()
1123 && (*stroke_width - BOND_PX * 0.1).abs() < 1e-9
1124 && (*opacity - 0.7).abs() < 1e-9
1125 }
1126 _ => false,
1127 });
1128 assert!(bond, "bond mark should be a closed capsule outline");
1129 }
1130
1131 #[test]
1132 fn scene_json_roundtrips() {
1133 let scene = depict_molecule(&acetone());
1134 let json = serde_json::to_string(&scene).expect("ser");
1135 let back: Scene = serde_json::from_str(&json).expect("de");
1136 assert_eq!(back.viewports[0].id.as_deref(), Some("acetone"));
1137 let orig_names: Vec<_> = scene.viewports[0].layers.iter().map(|l| l.name).collect();
1138 let back_names: Vec<_> = back.viewports[0].layers.iter().map(|l| l.name).collect();
1139 assert_eq!(back_names, orig_names);
1140 assert!(back_names.contains(&LayerName::Bonds));
1141 assert!(back_names.contains(&LayerName::Labels));
1142 let bond_count = |s: &Scene| {
1143 s.viewports[0]
1144 .layers
1145 .iter()
1146 .find(|l| l.name == LayerName::Bonds)
1147 .map(|l| l.primitives.len())
1148 .unwrap_or(0)
1149 };
1150 assert_eq!(bond_count(&back), bond_count(&scene));
1151 assert_eq!(bond_count(&back), 4);
1152 let bond_colors = |s: &Scene| -> Vec<String> {
1153 s.viewports[0]
1154 .layers
1155 .iter()
1156 .find(|l| l.name == LayerName::Bonds)
1157 .into_iter()
1158 .flat_map(|l| l.primitives.iter())
1159 .filter_map(|p| match p {
1160 Primitive::Path {
1161 stroke: Some(s), ..
1162 } => Some(s.clone()),
1163 _ => None,
1164 })
1165 .collect()
1166 };
1167 let colors = bond_colors(&back);
1168 assert_eq!(colors, bond_colors(&scene));
1169 assert!(colors.iter().all(|c| c == "#336699"));
1170 }
1171
1172 #[test]
1174 fn ethanol_viewport_id_and_two_bonds() {
1175 let scene = depict_molecule(ðanol());
1176 assert_eq!(scene.viewports[0].id.as_deref(), Some("etoh"));
1177 let bonds = scene.viewports[0]
1178 .layers
1179 .iter()
1180 .find(|l| l.name == LayerName::Bonds)
1181 .expect("bonds");
1182 assert_eq!(bonds.primitives.len(), 2);
1183 assert!(scene.width.is_finite() && scene.height.is_finite());
1184 assert!(scene.width > PAD_PX);
1185 assert!(scene.height > PAD_PX);
1186 let labels = scene.viewports[0]
1187 .layers
1188 .iter()
1189 .find(|l| l.name == LayerName::Labels)
1190 .expect("labels");
1191 let texts: Vec<_> = labels
1192 .primitives
1193 .iter()
1194 .filter_map(|p| match p {
1195 Primitive::Path {
1196 data_text: Some(t),
1197 ..
1198 } => Some(t.as_str()),
1199 _ => None,
1200 })
1201 .collect();
1202 assert!(
1203 texts.iter().any(|t| *t == "OH" || *t == "HO"),
1204 "expected OH/HO data-text, got {texts:?}"
1205 );
1206 }
1207
1208 #[test]
1210 fn acetone_joined_offsets_and_color() {
1211 let scene = depict_molecule(&acetone());
1212 let bonds = scene.viewports[0]
1213 .layers
1214 .iter()
1215 .find(|l| l.name == LayerName::Bonds)
1216 .expect("bonds");
1217 let offsets: Vec<_> = bonds
1218 .primitives
1219 .iter()
1220 .filter(|p| match p {
1221 Primitive::Path { class: Some(c), .. } => c.contains("bond-offset"),
1222 _ => false,
1223 })
1224 .collect();
1225 assert_eq!(offsets.len(), 2, "centered C=O must emit exactly two offsets");
1226 assert!(bonds.primitives.iter().all(|p| match p {
1227 Primitive::Path {
1228 stroke: Some(s), ..
1229 } => s == "#336699",
1230 _ => true,
1231 }));
1232 assert_eq!(crate::metrics::OFFSET_PX, 3.0);
1233 }
1234
1235 #[test]
1237 fn marks_and_shade_co_o_fixture() {
1238 let mol = MoleculeIn {
1239 id: None,
1240 atoms: vec![
1241 atom(0, "C", 0.0, 0.0, None),
1242 atom(1, "O", 20.0, 0.0, Some("O")),
1243 ],
1244 bonds: vec![BondIn {
1245 index: 0,
1246 begin: 0,
1247 end: 1,
1248 order: 1.0,
1249 stereo: None,
1250 interior: None,
1251 }],
1252 color: None,
1253 atom_shade: Some(vec![0.0, 0.85]),
1254 bond_shade: None,
1255 shade_vmin: None,
1256 shade_vmax: None,
1257 mark_atoms: vec![1],
1258 mark_bonds: vec![],
1259 weight: 1.0,
1260 scale: 1.0,
1261 };
1262 let scene = depict_molecule(&mol);
1263 let names: std::collections::HashSet<_> = scene.viewports[0]
1264 .layers
1265 .iter()
1266 .map(|l| l.name)
1267 .collect();
1268 assert!(names.contains(&LayerName::Shading));
1269 assert!(names.contains(&LayerName::Bonds));
1270 assert!(names.contains(&LayerName::Marks));
1271
1272 let shade = scene.viewports[0]
1273 .layers
1274 .iter()
1275 .find(|l| l.name == LayerName::Shading)
1276 .expect("shading");
1277 let disks: Vec<_> = shade
1278 .primitives
1279 .iter()
1280 .filter_map(|p| match p {
1281 Primitive::Circle {
1282 cx,
1283 cy,
1284 r,
1285 class: Some(c),
1286 ..
1287 } if c == "shade" => Some((*cx, *cy, *r)),
1288 _ => None,
1289 })
1290 .collect();
1291 assert_eq!(disks.len(), 4);
1293 let base_r = BOND_PX * SHADE_FRAC;
1294 let max_r = disks.iter().map(|(_, _, r)| *r).fold(0.0_f64, f64::max);
1295 let expected = base_r * (0.85_f64).sqrt(); assert!(
1298 (max_r - expected).abs() < 1e-6,
1299 "outer shade radius = {max_r}, expected {expected}"
1300 );
1301 assert!(
1302 max_r < base_r - 1e-6,
1303 "0.85 must not auto-window to full strength (got {max_r} vs base {base_r})"
1304 );
1305 let (sx, sy, _) = disks[0];
1306 assert!(disks.iter().all(|(x, y, _)| (*x - sx).abs() < 1e-9 && (*y - sy).abs() < 1e-9));
1307
1308 let marks = scene.viewports[0]
1309 .layers
1310 .iter()
1311 .find(|l| l.name == LayerName::Marks)
1312 .expect("marks");
1313 let mark = marks.primitives.iter().find_map(|p| match p {
1314 Primitive::Circle {
1315 cx,
1316 cy,
1317 r,
1318 class: Some(c),
1319 ..
1320 } if c.contains("atom-1") && c.contains(" mark") && !c.contains("halo") => {
1321 Some((*cx, *cy, *r))
1322 }
1323 _ => None,
1324 });
1325 let (mx, my, mr) = mark.expect("atom-1 mark circle");
1326 assert!((mr - BOND_PX).abs() < 1e-9);
1327 assert!((mx - sx).abs() < 1e-9 && (my - sy).abs() < 1e-9);
1328 }
1329
1330 #[test]
1331 fn empty_molecule_and_hetero_auto_label() {
1332 let empty = MoleculeIn {
1333 id: Some("empty".into()),
1334 atoms: vec![],
1335 bonds: vec![],
1336 color: None,
1337 atom_shade: None,
1338 bond_shade: None,
1339 shade_vmin: None,
1340 shade_vmax: None,
1341 mark_atoms: vec![],
1342 mark_bonds: vec![],
1343 weight: 1.0,
1344 scale: 1.0,
1345 };
1346 let scene = depict_molecule(&empty);
1347 assert_eq!(scene.viewports[0].id.as_deref(), Some("empty"));
1348 assert!(scene.viewports[0].layers.is_empty());
1349
1350 let mol = MoleculeIn {
1352 id: None,
1353 atoms: vec![
1354 atom(0, "C", 0.0, 0.0, None),
1355 atom(1, "N", 20.0, 0.0, None),
1356 ],
1357 bonds: vec![BondIn {
1358 index: 0,
1359 begin: 0,
1360 end: 1,
1361 order: 1.0,
1362 stereo: None,
1363 interior: None,
1364 }],
1365 color: None,
1366 atom_shade: None,
1367 bond_shade: None,
1368 shade_vmin: None,
1369 shade_vmax: None,
1370 mark_atoms: vec![],
1371 mark_bonds: vec![],
1372 weight: 1.0,
1373 scale: 1.0,
1374 };
1375 let scene = depict_molecule(&mol);
1376 let labels = scene.viewports[0]
1377 .layers
1378 .iter()
1379 .find(|l| l.name == LayerName::Labels)
1380 .expect("labels");
1381 assert!(labels.primitives.iter().any(|p| match p {
1382 Primitive::Path {
1383 data_text: Some(t),
1384 ..
1385 } => t == "N",
1386 _ => false,
1387 }));
1388 }
1389
1390 #[test]
1391 fn charged_label_and_empty_label_string() {
1392 let mut charged = atom(0, "N", 0.0, 0.0, Some("N"));
1393 charged.charge = 1;
1394 let mut anion = atom(1, "O", 20.0, 0.0, None);
1395 anion.charge = -2;
1396 let blank = AtomIn {
1397 index: 2,
1398 element: Some("C".into()),
1399 z: None,
1400 x: 40.0,
1401 y: 0.0,
1402 label: Some(" ".into()),
1403 charge: 0,
1404 };
1405 let mol = MoleculeIn {
1406 id: None,
1407 atoms: vec![charged, anion, blank],
1408 bonds: vec![
1409 BondIn {
1410 index: 0,
1411 begin: 0,
1412 end: 1,
1413 order: 1.0,
1414 stereo: None,
1415 interior: None,
1416 },
1417 BondIn {
1418 index: 1,
1419 begin: 1,
1420 end: 2,
1421 order: 1.0,
1422 stereo: None,
1423 interior: None,
1424 },
1425 ],
1426 color: None,
1427 atom_shade: None,
1428 bond_shade: None,
1429 shade_vmin: None,
1430 shade_vmax: None,
1431 mark_atoms: vec![],
1432 mark_bonds: vec![],
1433 weight: 1.0,
1434 scale: 1.0,
1435 };
1436 let scene = depict_molecule(&mol);
1437 let labels = scene.viewports[0]
1438 .layers
1439 .iter()
1440 .find(|l| l.name == LayerName::Labels)
1441 .expect("labels");
1442 let texts: Vec<_> = labels
1443 .primitives
1444 .iter()
1445 .filter_map(|p| match p {
1446 Primitive::Path {
1447 data_text: Some(t),
1448 ..
1449 } => Some(t.as_str()),
1450 _ => None,
1451 })
1452 .collect();
1453 assert!(texts.iter().any(|t| t.contains('⁺') || t.contains('+')));
1454 assert!(texts.iter().any(|t| t.contains('⁻') || t.contains('−') || t.contains('-')));
1455 }
1456
1457 #[test]
1458 fn north_south_label_grows_canvas() {
1459 let mol = MoleculeIn {
1461 id: None,
1462 atoms: vec![
1463 atom(0, "C", 0.0, 20.0, None),
1464 atom(1, "C", 0.0, -20.0, None),
1465 atom(2, "N", 0.0, 0.0, Some("NH2")),
1466 ],
1467 bonds: vec![
1468 BondIn {
1469 index: 0,
1470 begin: 2,
1471 end: 0,
1472 order: 1.0,
1473 stereo: None,
1474 interior: None,
1475 },
1476 BondIn {
1477 index: 1,
1478 begin: 2,
1479 end: 1,
1480 order: 1.0,
1481 stereo: None,
1482 interior: None,
1483 },
1484 ],
1485 color: None,
1486 atom_shade: None,
1487 bond_shade: None,
1488 shade_vmin: None,
1489 shade_vmax: None,
1490 mark_atoms: vec![],
1491 mark_bonds: vec![],
1492 weight: 1.0,
1493 scale: 1.0,
1494 };
1495 let scene = depict_molecule(&mol);
1496 assert!(scene.height > PAD_PX * 2.0);
1497 let labels = scene.viewports[0]
1498 .layers
1499 .iter()
1500 .find(|l| l.name == LayerName::Labels)
1501 .expect("labels");
1502 let texts: Vec<_> = labels
1503 .primitives
1504 .iter()
1505 .filter_map(|p| match p {
1506 Primitive::Path {
1507 data_text: Some(t),
1508 ..
1509 } => Some(t.as_str()),
1510 _ => None,
1511 })
1512 .collect();
1513 assert!(
1514 texts.iter().any(|t| t.contains("NH") || t.contains("HN")),
1515 "expected NH2/HN2 label, got {texts:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn shade_negative_and_diverging() {
1521 let mut mol = ethanol();
1522 mol.shade_vmin = Some(-1.0);
1524 mol.shade_vmax = Some(1.0);
1525 mol.atom_shade = Some(vec![-0.8, -0.2, 0.0]);
1526 let scene = depict_molecule(&mol);
1527 let shade = scene.viewports[0]
1528 .layers
1529 .iter()
1530 .find(|l| l.name == LayerName::Shading)
1531 .expect("shading");
1532 assert!(
1533 shade.primitives.len() >= 2,
1534 "negative scores should still paint shade disks"
1535 );
1536
1537 mol.atom_shade = Some(vec![-0.9, 0.0, 0.9]);
1538 let scene2 = depict_molecule(&mol);
1539 let shade2 = scene2.viewports[0]
1540 .layers
1541 .iter()
1542 .find(|l| l.name == LayerName::Shading)
1543 .expect("shading");
1544 let fills: Vec<_> = shade2
1545 .primitives
1546 .iter()
1547 .filter_map(|p| match p {
1548 Primitive::Circle {
1549 fill: Some(f), ..
1550 } => Some(f.as_str()),
1551 _ => None,
1552 })
1553 .collect();
1554 assert!(fills.len() >= 2);
1555 assert!(
1556 fills.iter().any(|f| f.starts_with("rgb(")),
1557 "diverging colormap must emit rgb() fills"
1558 );
1559 }
1560
1561 #[test]
1562 fn shade_default_window_is_zero_to_one() {
1563 let mut mol = ethanol();
1564 mol.atom_shade = Some(vec![0.0, 0.0, 0.45]);
1566 let scene = depict_molecule(&mol);
1567 let shade = scene.viewports[0]
1568 .layers
1569 .iter()
1570 .find(|l| l.name == LayerName::Shading)
1571 .expect("shading");
1572 assert!(
1573 !shade.primitives.is_empty(),
1574 "0.45 on a 0..1 window should still paint"
1575 );
1576 let mut mol_hot = ethanol();
1579 mol_hot.atom_shade = Some(vec![0.0, 0.0, 1.0]);
1580 let hot = depict_molecule(&mol_hot);
1581 let hot_shade = hot.viewports[0]
1582 .layers
1583 .iter()
1584 .find(|l| l.name == LayerName::Shading)
1585 .expect("shading");
1586 let max_r = |scene_shade: &crate::scene::Layer| {
1587 scene_shade
1588 .primitives
1589 .iter()
1590 .filter_map(|p| match p {
1591 Primitive::Circle { r, .. } => Some(r),
1592 _ => None,
1593 })
1594 .fold(0.0_f64, |a, b| a.max(*b))
1595 };
1596 assert!(
1597 max_r(shade) < max_r(hot_shade) * 0.85,
1598 "0.45 must not be auto-stretched to full strength (got {} vs hot {})",
1599 max_r(shade),
1600 max_r(hot_shade)
1601 );
1602 }
1603
1604 #[test]
1605 fn skips_bonds_and_marks_with_missing_indices() {
1606 let mut mol = ethanol();
1607 mol.bonds.push(BondIn {
1608 index: 99,
1609 begin: 0,
1610 end: 999,
1611 order: 1.0,
1612 stereo: None,
1613 interior: None,
1614 });
1615 mol.mark_atoms = vec![2, 999];
1616 mol.mark_bonds = vec![(0, 1), (0, 999)];
1617 let scene = depict_molecule(&mol);
1618 let marks = scene.viewports[0]
1619 .layers
1620 .iter()
1621 .find(|l| l.name == LayerName::Marks)
1622 .expect("marks");
1623 let atom_marks = marks
1625 .primitives
1626 .iter()
1627 .filter(|p| match p {
1628 Primitive::Circle {
1629 class: Some(c), ..
1630 } => c.contains("atom-2") && c.contains(" mark") && !c.contains("halo"),
1631 _ => false,
1632 })
1633 .count();
1634 let bond_marks = marks
1635 .primitives
1636 .iter()
1637 .filter(|p| match p {
1638 Primitive::Path {
1639 class: Some(c), ..
1640 } => c.contains("bond-mark") && !c.contains("halo"),
1641 _ => false,
1642 })
1643 .count();
1644 assert_eq!(atom_marks, 1);
1645 assert_eq!(bond_marks, 1);
1646 }
1647
1648 #[test]
1649 fn wedge_stereo_fills_halo_from_polygon() {
1650 let mol = MoleculeIn {
1651 id: None,
1652 atoms: vec![atom(0, "C", 0.0, 0.0, None), atom(1, "C", 20.0, 0.0, None)],
1653 bonds: vec![BondIn {
1654 index: 0,
1655 begin: 0,
1656 end: 1,
1657 order: 1.0,
1658 stereo: Some("up".into()),
1659 interior: None,
1660 }],
1661 color: None,
1662 atom_shade: Some(vec![0.5, 0.5]),
1663 bond_shade: None,
1664 shade_vmin: None,
1665 shade_vmax: None,
1666 mark_atoms: vec![],
1667 mark_bonds: vec![],
1668 weight: 1.0,
1669 scale: 1.0,
1670 };
1671 let scene = depict_molecule(&mol);
1672 assert!(!scene.halo.is_empty());
1673 let bonds = scene.viewports[0]
1674 .layers
1675 .iter()
1676 .find(|l| l.name == LayerName::Bonds)
1677 .expect("bonds");
1678 let wedges: Vec<_> = bonds
1679 .primitives
1680 .iter()
1681 .filter(|p| match p {
1682 Primitive::Path { class: Some(c), .. } => c.contains("wedge"),
1683 _ => false,
1684 })
1685 .collect();
1686 assert_eq!(wedges.len(), 1);
1687 match wedges[0] {
1688 Primitive::Path { d, fill: Some(f), .. } => {
1689 assert!(d.starts_with("M "), "wedge path starts with move: {d}");
1690 assert!(d.contains('Z'), "wedge must be closed");
1691 assert_eq!(f, "#111");
1692 }
1693 _ => panic!("expected filled wedge path"),
1694 }
1695 }
1696
1697 #[test]
1698 fn shade_bond_only_and_tiny_scores_filtered() {
1699 let mut mol = ethanol();
1700 mol.atom_shade = None;
1701 mol.bond_shade = Some(vec![0.9, 0.01]);
1702 let scene = depict_molecule(&mol);
1703 let shade = scene.viewports[0]
1704 .layers
1705 .iter()
1706 .find(|l| l.name == LayerName::Shading)
1707 .expect("shading");
1708 assert!(!shade.primitives.is_empty());
1710 assert!(shade.primitives.iter().all(|p| matches!(
1711 p,
1712 Primitive::Circle {
1713 class: Some(c),
1714 ..
1715 } if c == "shade"
1716 )));
1717
1718 mol.bond_shade = Some(vec![0.0, 0.0]);
1720 mol.atom_shade = Some(vec![0.0, 0.0, 0.0]);
1721 let scene2 = depict_molecule(&mol);
1722 let shade2 = scene2.viewports[0]
1723 .layers
1724 .iter()
1725 .find(|l| l.name == LayerName::Shading);
1726 assert!(
1727 shade2.is_none() || shade2.unwrap().primitives.is_empty(),
1728 "expected no shade disks for zero scores"
1729 );
1730 }
1731
1732 #[test]
1733 fn shade_skips_bond_with_unknown_endpoints() {
1734 let mut mol = ethanol();
1735 mol.bond_shade = Some(vec![0.8, 0.8, 0.8]);
1736 mol.bonds.push(BondIn {
1737 index: 50,
1738 begin: 0,
1739 end: 999,
1740 order: 1.0,
1741 stereo: None,
1742 interior: None,
1743 });
1744 let scene = depict_molecule(&mol);
1745 assert!(scene.viewports[0]
1746 .layers
1747 .iter()
1748 .any(|l| l.name == LayerName::Shading));
1749 }
1750}