Skip to main content

xpict_core/
bonds.rs

1//! Bond strokes: skeleton centerlines, multi-bond offsets, stereo, joins.
2//!
3//! Keep in sync with `python/xpict/draw/bonds.py`.
4
5use std::collections::HashMap;
6
7use crate::metrics::{
8    hash_count, BOND_PX, CHAIN_END_GAP_PX, END_GAP_PX, OFFSET_PX, STROKE_PX, WEDGE_HALF_PX,
9};
10use crate::scene::Primitive;
11
12/// Normalize engine bond orders for 2D depiction.
13///
14/// Indigo aromatic = 4; RDKit aromatic = 1.5. Prefer Kekulé from backends;
15/// this is a safety net so aromatics never become triple lines.
16pub fn depict_order(order: f64) -> f64 {
17    if order >= 3.5 || (1.4..1.6).contains(&order) {
18        1.0
19    } else {
20        order
21    }
22}
23
24/// Parallel spacing for double/triple strokes (RDKit `multipleBondOffset`).
25///
26/// Prefer [`OFFSET_PX`]. Do **not** scale down to the post-label stroke length —
27/// heteroatom insets must not collapse carbonyl spacing. Only shrink for
28/// degenerate stubs shorter than two offset widths.
29pub fn multi_bond_offset(length: f64) -> f64 {
30    if length < 2.0 * OFFSET_PX {
31        OFFSET_PX.min(length * 0.25)
32    } else {
33        OFFSET_PX
34    }
35}
36
37/// Signed offsets of a centered multiple bond along the left normal.
38///
39/// A double is two lines split evenly about the axis (separation `off`).
40/// A triple keeps the axis and one line `off` to each side.
41pub fn centered_displacements(order: f64, off: f64) -> Vec<f64> {
42    if order >= 2.5 {
43        vec![-off, 0.0, off]
44    } else {
45        vec![-off * 0.5, off * 0.5]
46    }
47}
48
49pub fn shorten(
50    x1: f64,
51    y1: f64,
52    x2: f64,
53    y2: f64,
54    gap1: f64,
55    gap2: f64,
56) -> (f64, f64, f64, f64) {
57    let dx = x2 - x1;
58    let dy = y2 - y1;
59    let length = (dx * dx + dy * dy).sqrt().max(1.0);
60    if gap1 + gap2 >= length {
61        let mx = (x1 + x2) * 0.5;
62        let my = (y1 + y2) * 0.5;
63        return (mx, my, mx, my);
64    }
65    let ux = dx / length;
66    let uy = dy / length;
67    (
68        x1 + ux * gap1,
69        y1 + uy * gap1,
70        x2 - ux * gap2,
71        y2 - uy * gap2,
72    )
73}
74
75fn unit(x1: f64, y1: f64, x2: f64, y2: f64) -> (f64, f64, f64, f64, f64) {
76    let dx = x2 - x1;
77    let dy = y2 - y1;
78    let length = (dx * dx + dy * dy).sqrt().max(1.0);
79    (dx / length, dy / length, -dy / length, dx / length, length)
80}
81
82fn fmt2(v: f64) -> String {
83    format!("{v:.2}")
84}
85
86fn line_path(
87    x1: f64,
88    y1: f64,
89    x2: f64,
90    y2: f64,
91    class: &str,
92    width: f64,
93) -> StrokePath {
94    StrokePath {
95        d: format!(
96            "M {} {} L {} {}",
97            fmt2(x1),
98            fmt2(y1),
99            fmt2(x2),
100            fmt2(y2)
101        ),
102        stroke: "#111".into(),
103        fill: None,
104        stroke_width: width,
105        stroke_linecap: Some("round".into()),
106        class: class.into(),
107    }
108}
109
110/// One SVG stroke before it becomes a [`Primitive`].
111#[derive(Debug, Clone, PartialEq)]
112pub struct StrokePath {
113    pub d: String,
114    pub stroke: String,
115    pub fill: Option<String>,
116    pub stroke_width: f64,
117    pub stroke_linecap: Option<String>,
118    pub class: String,
119}
120
121impl StrokePath {
122    pub fn to_primitive(&self) -> Primitive {
123        Primitive::Path {
124            d: self.d.clone(),
125            stroke: Some(self.stroke.clone()),
126            fill: self.fill.clone().or_else(|| Some("none".into())),
127            stroke_width: self.stroke_width,
128            opacity: 1.0,
129            stroke_dasharray: None,
130            stroke_linecap: self.stroke_linecap.clone(),
131            class: Some(self.class.clone()),
132            data_text: None,
133        }
134    }
135
136    pub fn with_color(mut self, color: &str) -> Self {
137        self.stroke = color.into();
138        if self
139            .fill
140            .as_ref()
141            .is_some_and(|f| f != "none")
142        {
143            self.fill = Some(color.into());
144        }
145        self
146    }
147}
148
149/// Strokes for one bond, ordered for painting: skeleton → offsets → stereo.
150#[derive(Debug, Clone, Default, PartialEq)]
151pub struct BondStrokes {
152    pub skeleton: Option<StrokePath>,
153    pub offsets: Vec<StrokePath>,
154    pub stereo: Vec<StrokePath>,
155}
156
157impl BondStrokes {
158    pub fn paint_order(&self) -> Vec<&StrokePath> {
159        let mut out = Vec::new();
160        if let Some(ref s) = self.skeleton {
161            out.push(s);
162        }
163        out.extend(self.offsets.iter());
164        out.extend(self.stereo.iter());
165        out
166    }
167
168    pub fn into_primitives(self, color: &str) -> Vec<Primitive> {
169        let mut out = Vec::new();
170        if let Some(s) = self.skeleton {
171            out.push(s.with_color(color).to_primitive());
172        }
173        for p in self.offsets {
174            out.push(p.with_color(color).to_primitive());
175        }
176        for p in self.stereo {
177            out.push(p.with_color(color).to_primitive());
178        }
179        out
180    }
181}
182
183/// Filled wedge; tip at `(x1,y1)` stereocenter — RDKit BEGINWEDGE.
184pub fn solid_wedge(x1: f64, y1: f64, x2: f64, y2: f64, half: Option<f64>) -> StrokePath {
185    let half = half.unwrap_or(WEDGE_HALF_PX);
186    let (_ux, _uy, nx, ny, _) = unit(x1, y1, x2, y2);
187    let ax = x2 + nx * half;
188    let ay = y2 + ny * half;
189    let bx = x2 - nx * half;
190    let by = y2 - ny * half;
191    StrokePath {
192        d: format!(
193            "M {} {} L {} {} L {} {} Z",
194            fmt2(x1),
195            fmt2(y1),
196            fmt2(ax),
197            fmt2(ay),
198            fmt2(bx),
199            fmt2(by)
200        ),
201        stroke: "#111".into(),
202        fill: Some("#111".into()),
203        stroke_width: 0.6,
204        stroke_linecap: Some("round".into()),
205        class: "bond bond-wedge-up".into(),
206    }
207}
208
209/// Hashed wedge for a bond going away from the viewer.
210///
211/// `(x1, y1)` is the stereocenter. Hashes are wide there and narrow at the
212/// substituent.
213pub fn hashed_wedge(
214    x1: f64,
215    y1: f64,
216    x2: f64,
217    y2: f64,
218    half: Option<f64>,
219    n: Option<usize>,
220) -> Vec<StrokePath> {
221    hashed_wedge_sw(x1, y1, x2, y2, half, n, STROKE_PX)
222}
223
224fn hashed_wedge_sw(
225    x1: f64,
226    y1: f64,
227    x2: f64,
228    y2: f64,
229    half: Option<f64>,
230    n: Option<usize>,
231    stroke_width: f64,
232) -> Vec<StrokePath> {
233    let half = half.unwrap_or(WEDGE_HALF_PX);
234    let (ux, uy, nx, ny, length) = unit(x1, y1, x2, y2);
235    let count = n.unwrap_or_else(|| hash_count(length));
236    let mut paths = Vec::with_capacity(count);
237    for i in 0..count {
238        let t = (i + 1) as f64 / (count + 1) as f64;
239        let w = half * (1.0 - t);
240        let cx = x1 + ux * length * t;
241        let cy = y1 + uy * length * t;
242        paths.push(line_path(
243            cx + nx * w,
244            cy + ny * w,
245            cx - nx * w,
246            cy - ny * w,
247            "bond bond-wedge-down",
248            stroke_width,
249        ));
250    }
251    paths
252}
253
254/// Wiggly single for unspecified stereo (RDKit UNKNOWN / Indigo EITHER).
255pub fn wavy_bond(
256    x1: f64,
257    y1: f64,
258    x2: f64,
259    y2: f64,
260    amp: Option<f64>,
261    waves: usize,
262) -> StrokePath {
263    wavy_bond_sw(x1, y1, x2, y2, amp, waves, STROKE_PX)
264}
265
266fn wavy_bond_sw(
267    x1: f64,
268    y1: f64,
269    x2: f64,
270    y2: f64,
271    amp: Option<f64>,
272    waves: usize,
273    stroke_width: f64,
274) -> StrokePath {
275    let (ux, uy, nx, ny, length) = unit(x1, y1, x2, y2);
276    let amp = amp.unwrap_or(0.055 * BOND_PX);
277    let mut parts = vec![format!("M {} {}", fmt2(x1), fmt2(y1))];
278    let steps = waves * 2;
279    for i in 1..=steps {
280        let t = i as f64 / steps as f64;
281        let side = if i % 2 == 1 { 1.0 } else { -1.0 };
282        let px = x1 + ux * length * t + nx * amp * side;
283        let py = y1 + uy * length * t + ny * amp * side;
284        parts.push(format!("L {} {}", fmt2(px), fmt2(py)));
285    }
286    StrokePath {
287        d: parts.join(" "),
288        stroke: "#111".into(),
289        fill: Some("none".into()),
290        stroke_width,
291        stroke_linecap: Some("round".into()),
292        class: "bond bond-either".into(),
293    }
294}
295
296fn offset_gap(length: f64, chain: bool) -> f64 {
297    let px = if chain { CHAIN_END_GAP_PX } else { END_GAP_PX };
298    px.min(length * 0.22)
299}
300
301/// Crossed double for unspecified E/Z (RDKit EITHERDOUBLE).
302pub fn crossed_double(
303    x1: f64,
304    y1: f64,
305    x2: f64,
306    y2: f64,
307    interior: Option<(f64, f64)>,
308) -> Vec<StrokePath> {
309    crossed_double_sw(x1, y1, x2, y2, interior, STROKE_PX)
310}
311
312fn crossed_double_sw(
313    x1: f64,
314    y1: f64,
315    x2: f64,
316    y2: f64,
317    interior: Option<(f64, f64)>,
318    stroke_width: f64,
319) -> Vec<StrokePath> {
320    let (_ux, _uy, lx, ly, length) = unit(x1, y1, x2, y2);
321    let (nx, ny) = interior.unwrap_or((lx, ly));
322    let off = multi_bond_offset(length);
323    let gap = offset_gap(length, interior.is_none());
324    let (sx1, sy1, sx2, sy2) = shorten(x1, y1, x2, y2, gap, gap);
325    let a1x = sx1 + nx * off;
326    let a1y = sy1 + ny * off;
327    let a2x = sx2 + nx * off;
328    let a2y = sy2 + ny * off;
329    let b1x = sx1 - nx * off;
330    let b1y = sy1 - ny * off;
331    let b2x = sx2 - nx * off;
332    let b2y = sy2 - ny * off;
333    vec![
334        line_path(x1, y1, x2, y2, "bond bond-skeleton", stroke_width),
335        line_path(a1x, a1y, b2x, b2y, "bond bond-either-cross", stroke_width),
336        line_path(b1x, b1y, a2x, a2y, "bond bond-either-cross", stroke_width),
337    ]
338}
339
340/// Parallel lines have `|cross(d0, d1)|` below this (unit directions).
341const JOIN_PARALLEL_EPS: f64 = 1e-9;
342/// Reject mitres that run farther than this fraction of the multi-bond length.
343const JOIN_MAX_T_FRAC: f64 = 0.9;
344
345/// Intersect `p0 + t d0` with `p1 + s d1`.
346///
347/// Returns `(t, s, ix, iy)` or `None` when the directions are parallel.
348#[allow(clippy::too_many_arguments)]
349pub fn line_intersect(
350    p0x: f64,
351    p0y: f64,
352    d0x: f64,
353    d0y: f64,
354    p1x: f64,
355    p1y: f64,
356    d1x: f64,
357    d1y: f64,
358) -> Option<(f64, f64, f64, f64)> {
359    let det = d0x * d1y - d0y * d1x;
360    if det.abs() < JOIN_PARALLEL_EPS {
361        return None;
362    }
363    let dx = p1x - p0x;
364    let dy = p1y - p0y;
365    let t = (dx * d1y - dy * d1x) / det;
366    let s = (dx * d0y - dy * d0x) / det;
367    Some((t, s, p0x + t * d0x, p0y + t * d0y))
368}
369
370/// Trim distance at begin, then end, one entry per centered line.
371pub type EndTrims = (Vec<f64>, Vec<f64>);
372
373/// One bond after label inset, before multiple-bond joins.
374#[derive(Debug, Clone, PartialEq)]
375pub struct DrawnBond {
376    pub index: i32,
377    pub begin: i32,
378    pub end: i32,
379    pub x1: f64,
380    pub y1: f64,
381    pub x2: f64,
382    pub y2: f64,
383    pub order: f64,
384    pub interior: Option<(f64, f64)>,
385    pub stereo: Option<String>,
386    pub begin_labeled: bool,
387    pub end_labeled: bool,
388    /// Trim distance at begin, then end, one entry per centered line.
389    pub trims: Option<EndTrims>,
390}
391
392impl DrawnBond {
393    #[allow(clippy::too_many_arguments)]
394    pub fn new(
395        index: i32,
396        begin: i32,
397        end: i32,
398        x1: f64,
399        y1: f64,
400        x2: f64,
401        y2: f64,
402        order: f64,
403    ) -> Self {
404        Self {
405            index,
406            begin,
407            end,
408            x1,
409            y1,
410            x2,
411            y2,
412            order,
413            interior: None,
414            stereo: None,
415            begin_labeled: false,
416            end_labeled: false,
417            trims: None,
418        }
419    }
420}
421
422fn plain_single(bond: &DrawnBond) -> bool {
423    let order = depict_order(bond.order);
424    let stereo = bond
425        .stereo
426        .as_deref()
427        .unwrap_or("none")
428        .to_ascii_lowercase();
429    order < 1.5 && (stereo.is_empty() || stereo == "none")
430}
431
432fn centered_multi(bond: &DrawnBond) -> bool {
433    let order = depict_order(bond.order);
434    let stereo = bond
435        .stereo
436        .as_deref()
437        .unwrap_or("none")
438        .to_ascii_lowercase();
439    if matches!(stereo.as_str(), "up" | "down" | "either") {
440        return false;
441    }
442    bond.interior.is_none() && order >= 1.5
443}
444
445fn end_frame(bond: &DrawnBond, at_begin: bool) -> (f64, f64, f64, f64, f64, f64) {
446    let (ux, uy, nx, ny, _length) = unit(bond.x1, bond.y1, bond.x2, bond.y2);
447    if at_begin {
448        (bond.x1, bond.y1, ux, uy, nx, ny)
449    } else {
450        (bond.x2, bond.y2, -ux, -uy, -nx, -ny)
451    }
452}
453
454/// Mitre acyclic doubles and triples to their single-bond neighbors.
455///
456/// Each parallel stroke is an infinite line; each neighboring single is another.
457/// Junction ends are **line–line intersections** (no fixed-length stubs), so
458/// acute and obtuse angles both close cleanly.
459///
460/// One single: extend it to the far parallel line; both multiple-bond strokes
461/// stop on that single.
462///
463/// Two singles: leave them meeting at the atom; extend each multiple-bond
464/// stroke past the atom until its end lies on a single (closes the vertex
465/// angle gap).
466pub fn join_centered_multibonds(bonds: &mut [DrawnBond]) {
467    let mut by_atom: HashMap<i32, Vec<usize>> = HashMap::new();
468    for (i, bond) in bonds.iter().enumerate() {
469        by_atom.entry(bond.begin).or_default().push(i);
470        by_atom.entry(bond.end).or_default().push(i);
471    }
472
473    // (single index, 0=begin) -> (x, y, distance past the atom)
474    let mut moves: HashMap<(i32, i32), (f64, f64, f64)> = HashMap::new();
475    // (bond index, (begin trims, end trims))
476    let mut trim_updates: Vec<(usize, EndTrims)> = Vec::new();
477
478    for bi in 0..bonds.len() {
479        if !centered_multi(&bonds[bi]) {
480            continue;
481        }
482        let (_ux, _uy, _nx, _ny, length) = unit(
483            bonds[bi].x1,
484            bonds[bi].y1,
485            bonds[bi].x2,
486            bonds[bi].y2,
487        );
488        if length < 1.0 {
489            continue;
490        }
491        let off = multi_bond_offset(length);
492        let disps = centered_displacements(depict_order(bonds[bi].order), off);
493        let end_disps = (disps.clone(), disps.iter().map(|d| -d).collect::<Vec<_>>());
494        let mut trims = (
495            vec![0.0; disps.len()],
496            vec![0.0; disps.len()],
497        );
498        let mut joined = false;
499        let t_lo = -0.5 * length;
500        let t_hi = JOIN_MAX_T_FRAC * length;
501
502        let ends = [
503            (0usize, bonds[bi].begin, bonds[bi].begin_labeled),
504            (1usize, bonds[bi].end, bonds[bi].end_labeled),
505        ];
506        for &(end_i, atom, labeled) in &ends {
507            if labeled {
508                continue;
509            }
510            let singles: Vec<usize> = by_atom
511                .get(&atom)
512                .into_iter()
513                .flatten()
514                .copied()
515                .filter(|&oi| oi != bi && plain_single(&bonds[oi]))
516                .collect();
517            if singles.is_empty() || singles.len() > 2 {
518                continue;
519            }
520            let (ex, ey, ux, uy, nx, ny) = end_frame(&bonds[bi], end_i == 0);
521            let disps_e = if end_i == 0 {
522                &end_disps.0
523            } else {
524                &end_disps.1
525            };
526
527            let mut line_ts: Vec<Vec<f64>> = vec![Vec::new(); disps_e.len()];
528            for &si in &singles {
529                let single = &bonds[si];
530                let (sx, sy, end_flag) = if single.begin == atom {
531                    (single.x2, single.y2, 0i32)
532                } else {
533                    (single.x1, single.y1, 1i32)
534                };
535                let vx = sx - ex;
536                let vy = sy - ey;
537                let vlen = (vx * vx + vy * vy).sqrt();
538                if vlen < 1e-6 {
539                    continue;
540                }
541                let vhx = vx / vlen;
542                let vhy = vy / vlen;
543                for (i, &d) in disps_e.iter().enumerate() {
544                    let Some((ti, _s, _ix, _iy)) =
545                        line_intersect(ex + nx * d, ey + ny * d, ux, uy, ex, ey, vhx, vhy)
546                    else {
547                        continue;
548                    };
549                    if ti > t_lo && ti < t_hi && ti.abs() > 1e-9 {
550                        line_ts[i].push(ti);
551                    }
552                }
553                if singles.len() == 1 {
554                    let side = ux * vhy - uy * vhx;
555                    let d_far = if side > 0.0 {
556                        disps_e.iter().cloned().fold(f64::INFINITY, f64::min)
557                    } else {
558                        disps_e.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
559                    };
560                    if let Some((t_far, _s, px, py)) =
561                        line_intersect(ex + nx * d_far, ey + ny * d_far, ux, uy, ex, ey, vhx, vhy)
562                    {
563                        if t_far > 0.0 && t_far < t_hi {
564                            let dist = ((px - ex).powi(2) + (py - ey).powi(2)).sqrt();
565                            let key = (single.index, end_flag);
566                            let replace = match moves.get(&key) {
567                                None => true,
568                                Some(&(_, _, prev)) => dist > prev,
569                            };
570                            if replace {
571                                moves.insert(key, (px, py, dist));
572                            }
573                        }
574                    }
575                }
576            }
577
578            for (i, ts) in line_ts.iter().enumerate() {
579                if ts.is_empty() {
580                    continue;
581                }
582                if singles.len() == 2 {
583                    let neg: Vec<f64> = ts.iter().copied().filter(|&t| t < 0.0).collect();
584                    let pos: Vec<f64> = ts.iter().copied().filter(|&t| t > 0.0).collect();
585                    if !neg.is_empty() {
586                        let v = neg.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
587                        if end_i == 0 {
588                            trims.0[i] = v;
589                        } else {
590                            trims.1[i] = v;
591                        }
592                    } else if !pos.is_empty() {
593                        let v = pos.iter().cloned().fold(f64::INFINITY, f64::min);
594                        if end_i == 0 {
595                            trims.0[i] = v;
596                        } else {
597                            trims.1[i] = v;
598                        }
599                    }
600                } else {
601                    for &ti in ts {
602                        if ti > 0.0 {
603                            let cur = if end_i == 0 { trims.0[i] } else { trims.1[i] };
604                            if cur <= 0.0 || ti < cur {
605                                if end_i == 0 {
606                                    trims.0[i] = ti;
607                                } else {
608                                    trims.1[i] = ti;
609                                }
610                            }
611                        } else {
612                            let cur = if end_i == 0 { trims.0[i] } else { trims.1[i] };
613                            if cur == 0.0 || (cur < 0.0 && ti > cur) {
614                                if end_i == 0 {
615                                    trims.0[i] = ti;
616                                } else {
617                                    trims.1[i] = ti;
618                                }
619                            }
620                        }
621                    }
622                }
623                joined = true;
624            }
625        }
626        if joined {
627            trim_updates.push((bi, trims));
628        }
629    }
630
631    for (bi, trims) in trim_updates {
632        bonds[bi].trims = Some(trims);
633    }
634    for ((index, end_flag), (px, py, _dist)) in moves {
635        if let Some(single) = bonds.iter_mut().find(|b| b.index == index) {
636            if end_flag == 0 {
637                single.x1 = px;
638                single.y1 = py;
639            } else {
640                single.x2 = px;
641                single.y2 = py;
642            }
643        }
644    }
645}
646
647#[allow(clippy::too_many_arguments)]
648fn centered_strokes(
649    x1: f64,
650    y1: f64,
651    x2: f64,
652    y2: f64,
653    order: f64,
654    off: f64,
655    ux: f64,
656    uy: f64,
657    nx: f64,
658    ny: f64,
659    length: f64,
660    trims: Option<&EndTrims>,
661    stroke_width: f64,
662) -> BondStrokes {
663    let disps = centered_displacements(order, off);
664    let (t1_owned, t2_owned) = match trims {
665        Some((a, b)) => (a.clone(), b.clone()),
666        None => (vec![0.0; disps.len()], vec![0.0; disps.len()]),
667    };
668    let mut skeleton = None;
669    let mut offsets = Vec::new();
670    for (i, &disp) in disps.iter().enumerate() {
671        let mut t1 = t1_owned.get(i).copied().unwrap_or(0.0);
672        let mut t2 = t2_owned.get(i).copied().unwrap_or(0.0);
673        if t1 + t2 > length * 0.9 && t1 + t2 > 0.0 {
674            let scale = (length * 0.9) / (t1 + t2);
675            t1 *= scale;
676            t2 *= scale;
677        }
678        let ax = x1 + nx * disp + ux * t1;
679        let ay = y1 + ny * disp + uy * t1;
680        let bx = x2 + nx * disp - ux * t2;
681        let by = y2 + ny * disp - uy * t2;
682        let class = if disp.abs() < 1e-9 {
683            "bond bond-skeleton"
684        } else {
685            "bond bond-offset"
686        };
687        let line = line_path(ax, ay, bx, by, class, stroke_width);
688        if disp.abs() < 1e-9 {
689            skeleton = Some(line);
690        } else {
691            offsets.push(line);
692        }
693    }
694    BondStrokes {
695        skeleton,
696        offsets,
697        stereo: Vec::new(),
698    }
699}
700
701/// Build skeleton / offset / stereo strokes for one bond.
702///
703/// `stereo`: `up` | `down` | `either` | `none` | `None`.
704/// Thin end of a solid wedge, and the thick end of a hashed wedge, are at
705/// `(x1, y1)`. Callers pass the stereocenter first.
706///
707/// `trims` are per-line inset distances at `(x1, y1)` then `(x2, y2)`
708/// for a centered multiple bond (see [`join_centered_multibonds`]).
709///
710/// `stroke_width` defaults to [`STROKE_PX`] when `None` — pass a weight-scaled
711/// width when mol ``weight`` thickens ink.
712#[allow(clippy::too_many_arguments)]
713pub fn bond_strokes(
714    x1: f64,
715    y1: f64,
716    x2: f64,
717    y2: f64,
718    order: f64,
719    interior: Option<(f64, f64)>,
720    stereo: Option<&str>,
721    trims: Option<&EndTrims>,
722    stroke_width: Option<f64>,
723) -> BondStrokes {
724    let sw = stroke_width.unwrap_or(STROKE_PX);
725    let order = depict_order(order);
726    let mut stereo = stereo.unwrap_or("none").to_ascii_lowercase();
727    if stereo == "none" {
728        stereo.clear();
729    }
730
731    if stereo == "up" && order < 1.5 {
732        return BondStrokes {
733            stereo: vec![solid_wedge(x1, y1, x2, y2, None)],
734            ..Default::default()
735        };
736    }
737    if stereo == "down" && order < 1.5 {
738        return BondStrokes {
739            stereo: hashed_wedge_sw(x1, y1, x2, y2, None, None, sw),
740            ..Default::default()
741        };
742    }
743    if stereo == "either" && order < 1.5 {
744        return BondStrokes {
745            stereo: vec![wavy_bond_sw(x1, y1, x2, y2, None, 5, sw)],
746            ..Default::default()
747        };
748    }
749    if stereo == "either" && order >= 1.5 {
750        return BondStrokes {
751            stereo: crossed_double_sw(x1, y1, x2, y2, interior, sw),
752            ..Default::default()
753        };
754    }
755
756    let (ux, uy, lx, ly, length) = unit(x1, y1, x2, y2);
757    let skeleton = line_path(x1, y1, x2, y2, "bond bond-skeleton", sw);
758    if order < 1.5 {
759        return BondStrokes {
760            skeleton: Some(skeleton),
761            ..Default::default()
762        };
763    }
764
765    let off = multi_bond_offset(length);
766    let Some(interior) = interior else {
767        return centered_strokes(
768            x1, y1, x2, y2, order, off, ux, uy, lx, ly, length, trims, sw,
769        );
770    };
771
772    let gap = offset_gap(length, false);
773    let (sx1, sy1, sx2, sy2) = shorten(x1, y1, x2, y2, gap, gap);
774    let mut offsets = Vec::new();
775    if order >= 2.5 {
776        for side in [-1.0, 1.0] {
777            let ox = lx * off * side;
778            let oy = ly * off * side;
779            offsets.push(line_path(
780                sx1 + ox,
781                sy1 + oy,
782                sx2 + ox,
783                sy2 + oy,
784                "bond bond-offset",
785                sw,
786            ));
787        }
788        return BondStrokes {
789            skeleton: Some(skeleton),
790            offsets,
791            stereo: Vec::new(),
792        };
793    }
794    offsets.push(line_path(
795        sx1 + interior.0 * off,
796        sy1 + interior.1 * off,
797        sx2 + interior.0 * off,
798        sy2 + interior.1 * off,
799        "bond bond-offset",
800        sw,
801    ));
802    BondStrokes {
803        skeleton: Some(skeleton),
804        offsets,
805        stereo: Vec::new(),
806    }
807}
808
809/// Flat list (skeleton then offsets then stereo).
810pub fn bond_paths(
811    x1: f64,
812    y1: f64,
813    x2: f64,
814    y2: f64,
815    order: f64,
816    interior: Option<(f64, f64)>,
817    stereo: Option<&str>,
818) -> Vec<StrokePath> {
819    bond_strokes(x1, y1, x2, y2, order, interior, stereo, None, None)
820        .paint_order()
821        .into_iter()
822        .cloned()
823        .collect()
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    fn path_ys(d: &str) -> f64 {
831        // "M x y L ..." — second number is y of start.
832        let nums: Vec<f64> = d
833            .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+'))
834            .filter(|s| !s.is_empty())
835            .filter_map(|s| s.parse().ok())
836            .collect();
837        nums[1]
838    }
839
840    fn path_pts(d: &str) -> Vec<(f64, f64)> {
841        let nums: Vec<f64> = d
842            .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+'))
843            .filter(|s| !s.is_empty())
844            .filter_map(|s| s.parse().ok())
845            .collect();
846        nums.chunks(2).map(|c| (c[0], c[1])).collect()
847    }
848
849    fn on_line(px: f64, py: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> bool {
850        let cross = ((x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)).abs();
851        let length = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt().max(1.0);
852        cross / length < 0.08
853    }
854
855    #[test]
856    fn short_visible_stroke_keeps_full_offset() {
857        let stub = 10.0;
858        assert!(stub * 0.22 < OFFSET_PX);
859        assert!(stub >= 2.0 * OFFSET_PX);
860        assert!((multi_bond_offset(stub) - OFFSET_PX).abs() < 1e-9);
861        assert!((multi_bond_offset(BOND_PX) - OFFSET_PX).abs() < 1e-9);
862    }
863
864    #[test]
865    fn double_is_symmetric_about_axis() {
866        let d = centered_displacements(2.0, OFFSET_PX);
867        assert_eq!(d.len(), 2);
868        assert!((d[0] + d[1]).abs() < 1e-9);
869        assert!((d[1] - d[0] - OFFSET_PX).abs() < 1e-9);
870    }
871
872    #[test]
873    fn skeleton_then_offset_for_double() {
874        let strokes = bond_strokes(0.0, 0.0, 20.0, 0.0, 2.0, None, None, None, None);
875        assert!(strokes.skeleton.is_none());
876        assert_eq!(strokes.offsets.len(), 2);
877        assert!(strokes
878            .offsets
879            .iter()
880            .all(|p| p.class.contains("bond-offset")));
881        let mut ys: Vec<f64> = strokes.offsets.iter().map(|p| path_ys(&p.d)).collect();
882        ys.sort_by(|a, b| a.partial_cmp(b).unwrap());
883        assert!((ys[0] + ys[1]).abs() < 0.05);
884        assert!((ys[0].abs() - OFFSET_PX / 2.0).abs() < 0.05);
885    }
886
887    #[test]
888    fn triple_has_skeleton_and_two_offsets() {
889        let strokes = bond_strokes(0.0, 0.0, 30.0, 0.0, 3.0, None, None, None, None);
890        assert!(strokes.skeleton.is_some());
891        assert!(strokes
892            .skeleton
893            .as_ref()
894            .unwrap()
895            .class
896            .contains("bond-skeleton"));
897        assert_eq!(strokes.offsets.len(), 2);
898        let skel_y = path_ys(&strokes.skeleton.as_ref().unwrap().d);
899        assert!(skel_y.abs() < 0.05);
900        let mut off_y: Vec<f64> = strokes.offsets.iter().map(|p| path_ys(&p.d)).collect();
901        off_y.sort_by(|a, b| a.partial_cmp(b).unwrap());
902        assert!((off_y[0] + off_y[1]).abs() < 0.05);
903    }
904
905    #[test]
906    fn solid_wedge_tip_at_begin() {
907        let w = solid_wedge(1.0, 2.0, 11.0, 2.0, None);
908        assert!(w.d.starts_with("M 1.00 2.00"));
909        assert_eq!(w.fill.as_deref(), Some("#111"));
910        assert!(w.class.contains("wedge-up"));
911    }
912
913    #[test]
914    fn hashed_wedge_wide_at_stereocenter() {
915        let dashes = hashed_wedge(0.0, 0.0, 40.0, 0.0, None, None);
916        assert!(dashes.len() >= 4);
917        let mut widths = Vec::new();
918        let mut centers = Vec::new();
919        for dash in &dashes {
920            let pts = path_pts(&dash.d);
921            widths.push((pts[0].1 - pts[1].1).abs());
922            centers.push((pts[0].0 + pts[1].0) * 0.5);
923        }
924        assert!(centers[0] < *centers.last().unwrap());
925        assert!(widths[0] > widths.last().unwrap() * 2.0);
926    }
927
928    #[test]
929    fn stereo_up_replaces_skeleton() {
930        let strokes = bond_strokes(0.0, 0.0, 10.0, 0.0, 1.0, None, Some("up"), None, None);
931        assert!(strokes.skeleton.is_none());
932        assert_eq!(strokes.stereo.len(), 1);
933        assert!(strokes.stereo[0].class.contains("wedge-up"));
934        let pts = path_pts(&strokes.stereo[0].d);
935        assert_eq!(pts.len(), 3, "filled wedge is a triangle (Z closes)");
936        assert!((pts[0].0 - 0.0).abs() < 1e-6 && (pts[0].1 - 0.0).abs() < 1e-6);
937        assert!(strokes.stereo[0].d.starts_with("M 0.00 0.00"));
938        assert!(strokes.stereo[0].d.contains('Z'));
939    }
940
941    #[test]
942    fn either_single_is_wavy() {
943        let strokes = bond_strokes(0.0, 0.0, 20.0, 0.0, 1.0, None, Some("either"), None, None);
944        assert!(strokes.skeleton.is_none());
945        assert_eq!(strokes.stereo.len(), 1);
946        assert!(strokes.stereo[0].class.contains("bond-either"));
947        // wavy_bond_sw(..., waves=5) → M + (5*2) L segments.
948        let pts = path_pts(&strokes.stereo[0].d);
949        assert_eq!(pts.len(), 1 + 5 * 2);
950        assert!((pts[0].0 - 0.0).abs() < 1e-6 && (pts[0].1 - 0.0).abs() < 1e-6);
951    }
952
953    #[test]
954    fn either_double_is_crossed() {
955        let strokes = bond_strokes(0.0, 0.0, 20.0, 0.0, 2.0, None, Some("either"), None, None);
956        assert!(strokes.skeleton.is_none());
957        let crosses: Vec<_> = strokes
958            .stereo
959            .iter()
960            .filter(|p| p.class.contains("either-cross"))
961            .collect();
962        assert_eq!(crosses.len(), 2, "crossed double has two diagonals");
963        assert!(crosses.iter().all(|p| p.class == "bond bond-either-cross"));
964        for p in &crosses {
965            let pts = path_pts(&p.d);
966            assert_eq!(pts.len(), 2);
967        }
968    }
969
970    #[test]
971    fn one_single_extends_to_the_far_line() {
972        let mut bonds = vec![
973            DrawnBond::new(0, 0, 1, 0.0, 0.0, 20.0, 0.0, 2.0),
974            DrawnBond::new(1, 0, 2, 0.0, 0.0, -10.0, 10.0, 1.0),
975        ];
976        join_centered_multibonds(&mut bonds);
977        assert!(bonds[1].y1 < -0.5);
978        assert!((bonds[1].y1 + OFFSET_PX / 2.0).abs() < 0.05);
979        assert!(on_line(0.0, 0.0, bonds[1].x1, bonds[1].y1, -10.0, 10.0));
980        let strokes = bond_strokes(
981            0.0,
982            0.0,
983            20.0,
984            0.0,
985            2.0,
986            None,
987            None,
988            bonds[0].trims.as_ref(),
989            None,
990        );
991        for path in &strokes.offsets {
992            let end = path_pts(&path.d)[0];
993            assert!(on_line(
994                end.0,
995                end.1,
996                bonds[1].x1,
997                bonds[1].y1,
998                -10.0,
999                10.0
1000            ));
1001        }
1002    }
1003
1004    #[test]
1005    fn two_singles_meet_and_clip_the_double() {
1006        let mut bonds = vec![
1007            DrawnBond::new(0, 0, 1, 0.0, 0.0, 20.0, 0.0, 2.0),
1008            DrawnBond::new(1, 0, 2, 0.0, 0.0, -10.0, 8.0, 1.0),
1009            DrawnBond::new(2, 0, 3, 0.0, 0.0, -10.0, -8.0, 1.0),
1010        ];
1011        join_centered_multibonds(&mut bonds);
1012        assert!((bonds[1].x1).abs() < 1e-9);
1013        assert!((bonds[1].y1).abs() < 1e-9);
1014        assert!((bonds[2].x1).abs() < 1e-9);
1015        assert!((bonds[2].y1).abs() < 1e-9);
1016        let trims = bonds[0].trims.as_ref().expect("trims");
1017        assert!(trims.0.iter().all(|&t| t < 0.0));
1018        let strokes = bond_strokes(0.0, 0.0, 20.0, 0.0, 2.0, None, None, Some(trims), None);
1019        for path in &strokes.offsets {
1020            let (x, y) = path_pts(&path.d)[0];
1021            let on_upper = on_line(x, y, 0.0, 0.0, -10.0, 8.0);
1022            let on_lower = on_line(x, y, 0.0, 0.0, -10.0, -8.0);
1023            assert!(on_upper || on_lower);
1024            assert!(x < -0.2);
1025        }
1026    }
1027
1028    #[test]
1029    fn far_end_single_joins_too() {
1030        let mut bonds = vec![
1031            DrawnBond::new(0, 0, 1, 0.0, 0.0, 20.0, 0.0, 2.0),
1032            DrawnBond::new(1, 1, 2, 20.0, 0.0, 30.0, -10.0, 1.0),
1033        ];
1034        join_centered_multibonds(&mut bonds);
1035        assert!((bonds[1].y1 - OFFSET_PX / 2.0).abs() < 0.05);
1036        assert!(bonds[1].x1 < 20.0);
1037        let strokes = bond_strokes(
1038            0.0,
1039            0.0,
1040            20.0,
1041            0.0,
1042            2.0,
1043            None,
1044            None,
1045            bonds[0].trims.as_ref(),
1046            None,
1047        );
1048        for path in &strokes.offsets {
1049            let end = path_pts(&path.d)[1];
1050            assert!(on_line(
1051                end.0,
1052                end.1,
1053                bonds[1].x1,
1054                bonds[1].y1,
1055                30.0,
1056                -10.0
1057            ));
1058        }
1059    }
1060
1061    #[test]
1062    fn acute_two_singles_still_land_on_lines() {
1063        // ~25° between singles — old JOIN_MIN_SIN (0.34) rejected this angle.
1064        let mut bonds = vec![
1065            DrawnBond::new(0, 0, 1, 0.0, 0.0, 20.0, 0.0, 2.0),
1066            DrawnBond::new(1, 0, 2, 0.0, 0.0, -18.0, 4.0, 1.0),
1067            DrawnBond::new(2, 0, 3, 0.0, 0.0, -18.0, -4.0, 1.0),
1068        ];
1069        join_centered_multibonds(&mut bonds);
1070        let trims = bonds[0].trims.as_ref().expect("trims at acute junction");
1071        assert!(trims.0.iter().all(|&t| t < 0.0));
1072        let strokes = bond_strokes(0.0, 0.0, 20.0, 0.0, 2.0, None, None, Some(trims), None);
1073        for path in &strokes.offsets {
1074            let (x, y) = path_pts(&path.d)[0];
1075            let on_upper = on_line(x, y, 0.0, 0.0, -18.0, 4.0);
1076            let on_lower = on_line(x, y, 0.0, 0.0, -18.0, -4.0);
1077            assert!(on_upper || on_lower, "end ({x},{y}) not on a single");
1078            assert!(x < -0.2);
1079        }
1080    }
1081
1082    #[test]
1083    fn line_intersect_crosses_unit_axes() {
1084        let (t, s, ix, iy) = line_intersect(0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0).unwrap();
1085        assert!((t - 1.0).abs() < 1e-9);
1086        assert!((s - 1.0).abs() < 1e-9);
1087        assert!((ix - 1.0).abs() < 1e-9);
1088        assert!((iy - 1.0).abs() < 1e-9);
1089        assert!(line_intersect(0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0).is_none());
1090    }
1091
1092    #[test]
1093    fn ring_double_keeps_skeleton_and_interior_offset() {
1094        let strokes = bond_strokes(
1095            0.0,
1096            0.0,
1097            BOND_PX,
1098            0.0,
1099            2.0,
1100            Some((0.0, -1.0)),
1101            None,
1102            None,
1103            None,
1104        );
1105        assert!(strokes.skeleton.is_some());
1106        assert_eq!(strokes.offsets.len(), 1);
1107        let y = path_ys(&strokes.offsets[0].d);
1108        assert!(y < -1.0);
1109    }
1110
1111    /// Parity with Python ``test_multi_bond_offset_matches_python``.
1112    #[test]
1113    fn multi_bond_offset_lengths() {
1114        for length in [3.0, 10.0, 20.0, 100.0] {
1115            let got = multi_bond_offset(length);
1116            let expected = if length < 2.0 * OFFSET_PX {
1117                OFFSET_PX.min(length * 0.25)
1118            } else {
1119                OFFSET_PX
1120            };
1121            assert!((got - expected).abs() < 1e-12, "len={length}");
1122        }
1123        // Degenerate stub shorter than 2×OFFSET.
1124        let short = multi_bond_offset(2.0);
1125        assert!((short - OFFSET_PX.min(0.5)).abs() < 1e-12);
1126    }
1127
1128    #[test]
1129    fn depict_order_maps_aromatic_and_indigo() {
1130        assert!((depict_order(1.5) - 1.0).abs() < 1e-12);
1131        assert!((depict_order(4.0) - 1.0).abs() < 1e-12);
1132        assert!((depict_order(2.0) - 2.0).abs() < 1e-12);
1133    }
1134
1135    #[test]
1136    fn shorten_collapses_when_gaps_eat_length() {
1137        let (x1, y1, x2, y2) = shorten(0.0, 0.0, 10.0, 0.0, 6.0, 6.0);
1138        assert!((x1 - 5.0).abs() < 1e-9);
1139        assert!((x2 - 5.0).abs() < 1e-9);
1140        assert!((y1 - y2).abs() < 1e-9);
1141    }
1142
1143    #[test]
1144    fn stereo_down_and_public_stereo_helpers() {
1145        let strokes = bond_strokes(0.0, 0.0, 40.0, 0.0, 1.0, None, Some("down"), None, None);
1146        assert!(strokes.skeleton.is_none());
1147        assert!(!strokes.stereo.is_empty());
1148        assert!(strokes.stereo[0].class.contains("wedge-down"));
1149
1150        let wavy = wavy_bond(0.0, 0.0, 20.0, 0.0, None, 5);
1151        assert!(wavy.class.contains("bond-either"));
1152        let crossed = crossed_double(0.0, 0.0, 20.0, 0.0, None);
1153        assert!(crossed.iter().any(|p| p.class.contains("either-cross")));
1154    }
1155
1156    #[test]
1157    fn bond_paths_and_filled_wedge_recolor() {
1158        let paths = bond_paths(0.0, 0.0, 20.0, 0.0, 1.0, None, None);
1159        assert_eq!(paths.len(), 1);
1160        assert!(paths[0].class.contains("skeleton"));
1161
1162        let wedge = solid_wedge(0.0, 0.0, 10.0, 0.0, None).with_color("#c00");
1163        assert_eq!(wedge.stroke, "#c00");
1164        assert_eq!(wedge.fill.as_deref(), Some("#c00"));
1165        let prim = wedge.to_primitive();
1166        match prim {
1167            crate::scene::Primitive::Path {
1168                fill: Some(f),
1169                stroke: Some(s),
1170                ..
1171            } => {
1172                assert_eq!(f, "#c00");
1173                assert_eq!(s, "#c00");
1174            }
1175            _ => panic!("expected path"),
1176        }
1177    }
1178
1179    #[test]
1180    fn interior_triple_emits_two_side_offsets() {
1181        let strokes = bond_strokes(
1182            0.0,
1183            0.0,
1184            30.0,
1185            0.0,
1186            3.0,
1187            Some((0.0, 1.0)),
1188            None,
1189            None,
1190            None,
1191        );
1192        assert!(strokes.skeleton.is_some());
1193        assert_eq!(strokes.offsets.len(), 2);
1194    }
1195
1196    #[test]
1197    fn centered_multi_skips_stereo_up() {
1198        let mut b = DrawnBond::new(0, 0, 1, 0.0, 0.0, 20.0, 0.0, 2.0);
1199        b.stereo = Some("up".into());
1200        // join should ignore stereo multi; no trims set.
1201        let mut bonds = vec![b, DrawnBond::new(1, 0, 2, 0.0, 0.0, -10.0, 10.0, 1.0)];
1202        join_centered_multibonds(&mut bonds);
1203        assert!(bonds[0].trims.is_none());
1204    }
1205
1206    #[test]
1207    fn join_moves_single_when_atom_is_bond_end() {
1208        // Single oriented so atom is `end` (end_flag == 1) → updates x2/y2.
1209        let mut bonds = vec![
1210            DrawnBond::new(0, 0, 1, 0.0, 0.0, 20.0, 0.0, 2.0),
1211            DrawnBond::new(1, 2, 0, -10.0, 10.0, 0.0, 0.0, 1.0),
1212        ];
1213        join_centered_multibonds(&mut bonds);
1214        assert!(bonds[1].y2 < -0.5 || bonds[1].y2 > 0.5 || bonds[0].trims.is_some());
1215        // End attached at atom 0 should have moved off the atom along the single.
1216        assert!((bonds[1].x2 - 0.0).abs() > 0.01 || (bonds[1].y2 - 0.0).abs() > 0.01);
1217    }
1218
1219    #[test]
1220    fn large_trims_are_scaled_to_fit() {
1221        let trims = (vec![8.0, 8.0], vec![8.0, 8.0]); // sum 16 > 0.9*20
1222        let strokes = bond_strokes(0.0, 0.0, 20.0, 0.0, 2.0, None, None, Some(&trims), None);
1223        assert_eq!(strokes.offsets.len(), 2);
1224        for p in &strokes.offsets {
1225            let pts = path_pts(&p.d);
1226            let len = ((pts[1].0 - pts[0].0).powi(2) + (pts[1].1 - pts[0].1).powi(2)).sqrt();
1227            assert!(len < 20.0 * 0.95, "scaled trim length {len}");
1228        }
1229    }
1230}