Skip to main content

xpict_core/
depict.rs

1//! Single-molecule depiction: [`MoleculeIn`] → [`Scene`].
2//!
3//! MVP paint path for WASM/Python thin serializers. Caller supplies SVG-space
4//! coords (+ optional shade / marks / atom labels). Shade uses the xenosite
5//! rainbow LUT; halo knocks out white channels through shade for bond/label ink.
6
7use 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
23/// Paint one molecule into a single-viewport [`Scene`].
24///
25/// Layers (bottom → top): shading → bonds → labels → marks.
26pub fn depict_molecule(mol: &MoleculeIn) -> Scene {
27    let color = mol.color.as_deref().unwrap_or("#111");
28    let face = FaceStyle::Regular;
29    // User-facing weight (default 1 = house). Helpers convert via diagram_weight.
30    let weight = mol.weight;
31    let _ = mol.diagram_weight(); // validate early
32    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    // BBox from atom centers (+ mark radii), then pad into positive space.
42    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    // Extra room for heteroatom labels (OH / NH2 / …) beyond atom centers.
65    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    // Ring bonds get interior normals → short inside offsets (not acyclic extend).
100    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        // Ring membership overrules centered/extend; else keep caller interior.
131        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        // Grow canvas if traveling text spills past the initial pad.
177        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    // Uniform diagram scale (font outlines, stroke, pad, geometry).
251    scene.scale_uniform(mol.diagram_scale())
252}
253
254/// Explicit ``label``, else heteroatom / charged symbol (carbons stay silent).
255///
256/// Appends a charge suffix when the label body does not already include one
257/// (matches Python ``display_text``).
258fn 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    // Fixed window by default — never auto-scale to the data range.
302    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    // Atom + bond scores share one PlotDot pass so overlapping rings stack by
313    // strength (weak first → strong on top), matching xenopict shade().
314    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/// White knockout under bonds/labels so shade disks don't cover ink.
380///
381/// xenopict: union of per-ink buffers at a weight-scaled gap (√weight vs linear
382/// ink), with a floor from halo stroke thickness. Requires the `geom` feature
383/// (WASM enables it via `font`).
384#[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        // Filled wedges: treat the polygon as ink; stroked lines → capsules.
411        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    // Match Python BondsDrawable: max(halo_gap(w), 0.25*halo_stroke(w) - ink_r).
441    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
475/// Extract absolute M/L coordinates from a simple path `d` (bond strokes).
476fn 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    // xenopict: mark halo via `<use href="#mark" stroke="#555" …>`; mark ink
518    // sits in a separate group with stroke-width scale×0.1 / opacity 0.7 but
519    // **no stroke color** (host CSS on `.mark` may add one). Xpict does not
520    // style by class: bake the visible ring on the halo attrs only. Backbone
521    // color never recolors marks.
522    // shapely `resolution=6` → 24 verts/circle; keep capsules smooth.
523    const CAPSULE_QUAD_SEGS: u32 = 16;
524
525    // Bond capsules (outline path `d`) — xenopict buffers the bond at mark radius.
526    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    // Halo underlay first (xenopict `<use href="#mark">`: #555, scale*0.2, 0.45).
550    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    // Mark ink (xenopict mark group: fill none, stroke-width scale×0.1,
581    // opacity 0.7, **no stroke color**). Visible color is the halo above.
582    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(&ethanol());
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(&ethanol());
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(&ethanol());
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        // Regular hexagon — without ring interiors, join_centered would extend
927        // doubles onto singles and emit two parallel offsets per double.
928        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        // Three ring doubles → one interior offset each (not two centered lines).
982        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        // Rainbow LUT (not the old coral stub): high score → hot orange-red.
1000        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        // Strong bond mid + weaker atoms → bond disk should paint after (on top).
1018        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        // Combined atom+bond → base radius 0.8×bond (not 0.9).
1040        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        // Colored backbone must not recolor marks.
1083        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    /// Parity: Python ``test_depict_molecule_ethanol_two_bonds``.
1173    #[test]
1174    fn ethanol_viewport_id_and_two_bonds() {
1175        let scene = depict_molecule(&ethanol());
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    /// Parity: Python ``test_depict_acetone_centered_double_offsets`` + joined offsets.
1209    #[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    /// Parity: Python ``test_depict_marks_and_shade_layers``.
1236    #[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        // z=0 filtered; z=0.85 on fixed 0..1 window → 4 PlotDot rings at O.
1292        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        // PlotDot outer ring for |z|=0.85 is ~0.922×base — not stretched to 1.0.
1296        let expected = base_r * (0.85_f64).sqrt(); // rings use sqrt steps; 0.85→√0.85
1297        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        // Heteroatom without explicit label still gets a glyph path.
1351        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        // Degree-2 steep neighbors → North/South orientation for NH2.
1460        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        // Explicit diverging window — defaults are vmin=0, vmax=1 (no auto-window).
1523        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        // Peak score 0.45 must stay mid-strength — not stretched to vmax of data.
1565        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        // Auto-windowing to max=0.45 would normalize to 1.0 and grow the disk;
1577        // with fixed vmax=1 the radius stays clearly below a full-strength hit.
1578        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        // Valid atom mark (+halo) + bond mark (+halo); missing indices skipped.
1624        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        // Only bond 0 score 0.9 survives the 0.01 filter.
1709        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        // Near-zero-only scores: either no shading layer or only filtered disks.
1719        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}