Skip to main content

xpict_core/
labels.rs

1//! Atom-label orientation and backbone bond insets.
2//!
3//! Caller supplies SVG-space atom coordinates. Labels split into an
4//! **atom-center** part (sits on the atom) and an optional **traveling**
5//! part (extends away from the molecule). `OH` on the left of a mol becomes
6//! `HO` with `O` still at the atom; abbreviations like `GlcA` have no
7//! traveling part — the first or last glyph is the center depending on the
8//! bond approach side.
9//!
10//! Orientation follows RDKit MolDraw2D (`E`/`W`/`N`/`S`). **All** chem scripts
11//! (H-counts, charges, star-label markup, symbols, bold/italic) go through
12//! [`crate::markup::parse_label_markup`] → [`ScriptRole`] fake scripts,
13//! outlined as glyph **paths**, not ``<text>``.
14
15use crate::font::{self, ChemGlyph, FaceStyle, ScriptRole};
16use crate::geom::Shape;
17use crate::markup;
18use crate::metrics::{label_weight_standoff_px, LABEL_GAP_PX};
19
20/// Which side the traveling text extends toward (RDKit OrientType).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LabelSide {
23    /// Bond approaches from the left; traveling text extends east (right).
24    East,
25    /// Bond approaches from the right; traveling text extends west (left).
26    West,
27    /// Bond approaches from below; traveling text extends north (up, −Y SVG).
28    North,
29    /// Bond approaches from above; traveling text extends south (down, +Y SVG).
30    South,
31}
32
33/// Atom-center glyph(s) vs optional traveling H / charge.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LabelParts {
36    pub center: String,
37    /// Traveling H run (`"H"`, `"H2"`, …) or empty.
38    pub traveling: String,
39    /// Formal charge (`0` = none). Drawn as a superscript.
40    pub charge: i32,
41}
42
43/// One placed atom label in SVG space (+Y down).
44#[derive(Debug, Clone)]
45pub struct PlacedLabel {
46    /// Original input label (for re-outline / halo).
47    pub raw: String,
48    /// Display string for ``data-text`` (unicode scripts when possible).
49    pub text: String,
50    /// Left edge of the advance (SVG text origin), baseline at [`Self::y`].
51    pub origin_x: f64,
52    pub y: f64,
53    pub atom_x: f64,
54    pub atom_y: f64,
55    pub side: LabelSide,
56    /// Bond inset from the atom toward neighbors (isotropic: center glyph
57    /// advance/ink width + gap). Per-bond shortening uses directional ink
58    /// support via [`place_backbone`].
59    pub clearance: f64,
60    /// Outlined glyph path `d` (empty when no ink).
61    pub path_d: String,
62}
63
64/// Split a raw label into center + traveling H + charge.
65///
66/// Accepts backend strings like ``NH2``, ``OH``, ``NH4+``, ``O−``, ``*``,
67/// and marked forms ``$R_1$`` / ``R^2`` (markup stays on ``center`` for
68/// [`parse_label_markup`]). Bare underscores (``my_name``) are **not**
69/// treated as scripts at split time.
70pub fn split_label(raw: &str) -> LabelParts {
71    let s = raw.trim();
72    if s.is_empty() {
73        return LabelParts {
74            center: String::new(),
75            traveling: String::new(),
76            charge: 0,
77        };
78    }
79    // Keep `$…$` intact so the markup parser still sees the chem zone.
80    let (body, charge) = if s.starts_with('$') && s.ends_with('$') && s.len() >= 2 {
81        // Charge rarely wraps the dollars; strip charge outside only.
82        (s.to_string(), 0)
83    } else {
84        strip_charge(s)
85    };
86    if body == "*" {
87        return LabelParts {
88            center: "*".into(),
89            traveling: String::new(),
90            charge,
91        };
92    }
93    // Explicit markup: `$…$`, `^`, or braced `_{…}` — no H-split.
94    let has_markup = body.contains('$')
95        || body.contains('^')
96        || body.contains("_{")
97        || body.contains("\\")
98        || body.contains("**")
99        || (body.contains('*') && body != "*");
100    if has_markup {
101        return LabelParts {
102            center: body,
103            traveling: String::new(),
104            charge,
105        };
106    }
107    let bytes = body.as_bytes();
108    if !bytes.is_empty() && bytes[0].is_ascii_uppercase() {
109        let mut i = 1usize;
110        if bytes.len() > 1 && bytes[1].is_ascii_lowercase() {
111            i = 2;
112        }
113        let (elem, rest) = body.split_at(i);
114        if let Some(travel) = parse_h_suffix(rest) {
115            return LabelParts {
116                center: elem.to_string(),
117                traveling: travel.to_string(),
118                charge,
119            };
120        }
121    }
122    LabelParts {
123        center: body.to_string(),
124        traveling: String::new(),
125        charge,
126    }
127}
128
129fn strip_charge(s: &str) -> (String, i32) {
130    let chars: Vec<char> = s.chars().collect();
131    if chars.is_empty() {
132        return (String::new(), 0);
133    }
134    // Trailing + / − / - / ⁺ / ⁻, optional leading magnitude or repeated signs.
135    let last = *chars.last().unwrap();
136    let is_plus = last == '+' || last == '⁺';
137    let is_minus = last == '-' || last == '−' || last == '⁻';
138    if !is_plus && !is_minus {
139        return (s.to_string(), 0);
140    }
141    let sign: i32 = if is_plus { 1 } else { -1 };
142    let mut i = chars.len() - 1;
143    // Collapse repeated ++++ / ----
144    while i > 0 {
145        let c = chars[i - 1];
146        let same = (sign > 0 && (c == '+' || c == '⁺'))
147            || (sign < 0 && (c == '-' || c == '−' || c == '⁻'));
148        if same {
149            i -= 1;
150        } else {
151            break;
152        }
153    }
154    let n_signs = (chars.len() - i) as i32;
155    let without_sign: String = chars[..i].iter().collect();
156    // ``NH4+`` → H-count owns the digits; charge mag is just the sign count.
157    // ``Fe3+`` / ``N2+`` → digits before the sign are the charge magnitude.
158    if without_sign.contains('H') {
159        // Element…H… — don't steal H-count digits for the charge.
160        return (without_sign, sign * n_signs);
161    }
162    let mut j = i;
163    while j > 0 && chars[j - 1].is_ascii_digit() {
164        j -= 1;
165    }
166    let mag = if j < i {
167        let digits: String = chars[j..i].iter().collect();
168        digits.parse::<i32>().unwrap_or(n_signs).max(1)
169    } else {
170        n_signs
171    };
172    let body: String = chars[..j].iter().collect();
173    (body, sign * mag)
174}
175
176fn parse_h_suffix(rest: &str) -> Option<&str> {
177    if rest.is_empty() {
178        return None;
179    }
180    let bytes = rest.as_bytes();
181    if bytes[0] != b'H' {
182        return None;
183    }
184    let mut i = 1usize;
185    while i < bytes.len() && bytes[i].is_ascii_digit() {
186        i += 1;
187    }
188    // Only pure H / Hn — no trailing junk (NHAc, etc.).
189    if i == rest.len() {
190        Some(rest)
191    } else {
192        None
193    }
194}
195
196fn h_count(parts: &LabelParts) -> u32 {
197    let t = parts.traveling.as_str();
198    if t.is_empty() || !t.starts_with('H') {
199        return 0;
200    }
201    if t.len() == 1 {
202        return 1;
203    }
204    t[1..].parse().unwrap_or(1)
205}
206
207/// Orient a label from the sum of neighbor vectors (SVG +Y down).
208///
209/// Matches RDKit ``getAtomOrientation`` (70° vertical slope; degree-1 prefers
210/// E/W; degree-0 defaults East except O/F/S/Cl-class → West).
211pub fn label_side(atom: (f64, f64), neighbors: &[(f64, f64)]) -> LabelSide {
212    label_side_for(atom, neighbors, None)
213}
214
215/// Like [`label_side`] but degree-0 Hs-first elements use West (RDKit).
216pub fn label_side_for(
217    atom: (f64, f64),
218    neighbors: &[(f64, f64)],
219    element: Option<&str>,
220) -> LabelSide {
221    const VERT_SLOPE: f64 = 2.747477; // tan(70°)
222    if neighbors.is_empty() {
223        // RDKit: O, F, S, Cl, … list H first → West.
224        const HS_FIRST: &[&str] = &["O", "F", "S", "Cl", "Br", "I", "Se", "Te", "At"];
225        if element.is_some_and(|e| HS_FIRST.contains(&e)) {
226            return LabelSide::West;
227        }
228        return LabelSide::East;
229    }
230    let (ax, ay) = atom;
231    let mut sx = 0.0;
232    let mut sy = 0.0;
233    for &(nx, ny) in neighbors {
234        sx += nx - ax;
235        sy += ny - ay;
236    }
237    let islope = if sx.abs() > 1e-4 { sy / sx } else { 1000.0 };
238    let mut orient = if islope.abs() <= VERT_SLOPE {
239        if sx > 0.0 {
240            LabelSide::West
241        } else {
242            LabelSide::East
243        }
244    } else if sy > 0.0 {
245        // SVG +Y down → neighbor sum south → label extends North (away).
246        LabelSide::North
247    } else {
248        LabelSide::South
249    };
250
251    // Degree-1: never N/S (RDKit).
252    if neighbors.len() == 1 && matches!(orient, LabelSide::North | LabelSide::South) {
253        if islope.abs() > VERT_SLOPE {
254            orient = LabelSide::East;
255        } else if sx > 0.0 {
256            orient = LabelSide::West;
257        } else {
258            orient = LabelSide::East;
259        }
260    }
261
262    // Degree-3: keep N/S when a near-vertical bond points that way.
263    if neighbors.len() == 3 && matches!(orient, LabelSide::North | LabelSide::South) {
264        for &(nx, ny) in neighbors {
265            let dx = nx - ax;
266            let dy = ny - ay;
267            if dx.abs() < 1e-9 {
268                if dy > 0.0 {
269                    orient = LabelSide::North;
270                } else {
271                    orient = LabelSide::South;
272                }
273                break;
274            }
275            let ang = (dy / dx).atan().to_degrees();
276            if (80.0..100.0).contains(&ang) && orient == LabelSide::North {
277                break;
278            }
279            if (-100.0..-80.0).contains(&ang) && orient == LabelSide::South {
280                break;
281            }
282        }
283    }
284
285    orient
286}
287
288fn charge_glyphs(charge: i32, style: FaceStyle) -> Vec<ChemGlyph> {
289    if charge == 0 {
290        return Vec::new();
291    }
292    let mag = charge.unsigned_abs();
293    let sign = if charge > 0 { '+' } else { '-' };
294    // Single pathway: charge → ``^`` / ``^{…}`` → parse_label_markup.
295    let marked = if mag == 1 {
296        format!("^{sign}")
297    } else {
298        format!("^{{{mag}{sign}}}")
299    };
300    markup::parse_label_markup(&marked, style)
301}
302
303fn h_travel_glyphs(parts: &LabelParts, style: FaceStyle) -> Vec<ChemGlyph> {
304    let n = h_count(parts);
305    if n == 0 {
306        return Vec::new();
307    }
308    // Braced ``H_{n}`` so subscript works outside ``$…$`` (bare ``_`` does not).
309    let marked = if n == 1 {
310        "H".to_string()
311    } else {
312        format!("H_{{{n}}}")
313    };
314    markup::parse_label_markup(&marked, style)
315}
316
317fn center_glyphs(parts: &LabelParts, style: FaceStyle) -> Vec<ChemGlyph> {
318    markup::parse_label_markup(&parts.center, style)
319}
320
321/// Glyphs in draw order for an orientation (atom-center first for E; …).
322fn glyphs_for_side(parts: &LabelParts, side: LabelSide, style: FaceStyle) -> Vec<ChemGlyph> {
323    let center = center_glyphs(parts, style);
324    let travel = h_travel_glyphs(parts, style);
325    let charge = charge_glyphs(parts.charge, style);
326    match side {
327        LabelSide::East | LabelSide::South | LabelSide::North => {
328            let mut g = center;
329            g.extend(travel);
330            g.extend(charge);
331            g
332        }
333        LabelSide::West => {
334            // Travel + charge left of center (RDKit reverses pieces for W).
335            let mut g = charge;
336            g.extend(travel);
337            g.extend(center);
338            g
339        }
340    }
341}
342
343/// Plain display string after orientation (``OH`` / ``HO`` / ``NH₂⁺``).
344pub fn compose_label(parts: &LabelParts, side: LabelSide) -> String {
345    let glyphs = glyphs_for_side(parts, side, FaceStyle::Regular);
346    glyphs_to_data_text(&glyphs)
347}
348
349fn glyphs_to_data_text(glyphs: &[ChemGlyph]) -> String {
350    let mut out = String::new();
351    for g in glyphs {
352        match g.role {
353            ScriptRole::Normal => out.push(g.ch),
354            ScriptRole::Subscript => out.push(subscript_char(g.ch)),
355            ScriptRole::Superscript => out.push(superscript_char(g.ch)),
356        }
357    }
358    out
359}
360
361fn subscript_char(ch: char) -> char {
362    match ch {
363        '0' => '₀',
364        '1' => '₁',
365        '2' => '₂',
366        '3' => '₃',
367        '4' => '₄',
368        '5' => '₅',
369        '6' => '₆',
370        '7' => '₇',
371        '8' => '₈',
372        '9' => '₉',
373        _ => ch,
374    }
375}
376
377fn superscript_char(ch: char) -> char {
378    match ch {
379        '+' => '⁺',
380        '-' => '⁻',
381        '0' => '⁰',
382        '1' => '¹',
383        '2' => '²',
384        '3' => '³',
385        '4' => '⁴',
386        '5' => '⁵',
387        '6' => '⁶',
388        '7' => '⁷',
389        '8' => '⁸',
390        '9' => '⁹',
391        _ => ch,
392    }
393}
394
395fn advance_glyphs_px(glyphs: &[ChemGlyph], font_px: f64, style: FaceStyle) -> f64 {
396    if glyphs.is_empty() {
397        return 0.0;
398    }
399    let face = font::face_metrics(style);
400    let (_shape, adv_em) = font::outline_chem_run_em(glyphs, style);
401    adv_em * (font_px / face.upem)
402}
403
404#[cfg(test)]
405fn advance_px(text: &str, font_px: f64, style: FaceStyle) -> f64 {
406    if text.is_empty() {
407        return 0.0;
408    }
409    let glyphs: Vec<_> = text
410        .chars()
411        .map(|ch| ChemGlyph {
412            ch,
413            role: ScriptRole::Normal,
414            face: style,
415        })
416        .collect();
417    advance_glyphs_px(&glyphs, font_px, style)
418}
419
420fn baseline_offset(font_px: f64, style: FaceStyle) -> f64 {
421    let face = font::face_metrics(style);
422    0.5 * (face.cap_height / face.upem) * font_px
423}
424
425/// Atom-center glyph(s) for clearance / anchoring (element token, or one
426/// base glyph of an abbreviation — never a script mark or digit alone).
427fn center_anchor_glyphs(parts: &LabelParts, side: LabelSide, style: FaceStyle) -> Vec<ChemGlyph> {
428    let center = center_glyphs(parts, style);
429    if center.is_empty() {
430        return Vec::new();
431    }
432    if !parts.traveling.is_empty() || parts.charge != 0 {
433        return center;
434    }
435    let normals: Vec<ChemGlyph> = center
436        .iter()
437        .copied()
438        .filter(|g| g.role == ScriptRole::Normal)
439        .collect();
440    if normals.is_empty() {
441        return center;
442    }
443    let g = match side {
444        LabelSide::East | LabelSide::South | LabelSide::North => normals[0],
445        LabelSide::West => *normals.last().unwrap(),
446    };
447    vec![g]
448}
449
450/// Advance from string start to the start of the center glyph (E/W runs).
451fn prefix_before_center(
452    parts: &LabelParts,
453    side: LabelSide,
454    font_px: f64,
455    style: FaceStyle,
456) -> f64 {
457    match side {
458        LabelSide::East | LabelSide::North | LabelSide::South => 0.0,
459        LabelSide::West => {
460            if parts.traveling.is_empty() && parts.charge == 0 {
461                let center = center_glyphs(parts, style);
462                let normals: Vec<_> = center
463                    .iter()
464                    .enumerate()
465                    .filter(|(_, g)| g.role == ScriptRole::Normal)
466                    .collect();
467                let Some(&(idx, _)) = normals.last() else {
468                    return 0.0;
469                };
470                return advance_glyphs_px(&center[..idx], font_px, style);
471            }
472            let mut prefix = charge_glyphs(parts.charge, style);
473            prefix.extend(h_travel_glyphs(parts, style));
474            advance_glyphs_px(&prefix, font_px, style)
475        }
476    }
477}
478
479/// Ink AABB of the center glyph relative to the atom (SVG +Y down).
480fn center_glyph_ink_rel(
481    parts: &LabelParts,
482    side: LabelSide,
483    font_px: f64,
484    style: FaceStyle,
485) -> (f64, Option<(f64, f64, f64, f64)>) {
486    let anchor = center_anchor_glyphs(parts, side, style);
487    if anchor.is_empty() {
488        return (0.0, None);
489    }
490    // ``*`` uses the custom star metrics.
491    if anchor.len() == 1 && anchor[0].ch == '*' && anchor[0].role == ScriptRole::Normal {
492        let face = font::face_metrics(style);
493        let scale = font_px / face.upem;
494        let (shape, adv_em) = font::outline_star_em(style);
495        let advance = adv_em * scale;
496        let half = 0.5 * advance;
497        let base = baseline_offset(font_px, style);
498        let ink = shape.bounds().map(|(x0, y0, x1, y1)| {
499            (
500                x0 * scale - half,
501                x1 * scale - half,
502                base - y1 * scale,
503                base - y0 * scale,
504            )
505        });
506        return (advance, ink);
507    }
508    let face = font::face_metrics(style);
509    let scale = font_px / face.upem;
510    let (shape, adv_em) = font::outline_chem_run_em(&anchor, style);
511    let advance = adv_em * scale;
512    let half = 0.5 * advance;
513    let base = baseline_offset(font_px, style);
514    let ink = shape.and_then(|s| s.bounds()).map(|(x0, y0, x1, y1)| {
515        (
516            x0 * scale - half,
517            x1 * scale - half,
518            base - y1 * scale,
519            base - y0 * scale,
520        )
521    });
522    (advance, ink)
523}
524
525fn aabb_support(xmin: f64, xmax: f64, ymin: f64, ymax: f64, ux: f64, uy: f64) -> f64 {
526    let sx = if ux >= 0.0 { xmax } else { xmin };
527    let sy = if uy >= 0.0 { ymax } else { ymin };
528    sx * ux + sy * uy
529}
530
531fn clearance_toward(
532    advance: f64,
533    ink: Option<(f64, f64, f64, f64)>,
534    ux: f64,
535    uy: f64,
536    standoff: f64,
537) -> f64 {
538    let half = 0.5 * advance;
539    let ink_reach = ink
540        .map(|(x0, x1, y0, y1)| aabb_support(x0, x1, y0, y1, ux, uy).max(0.0))
541        .unwrap_or(0.0);
542    half.max(ink_reach) + LABEL_GAP_PX + standoff
543}
544
545fn clearance_isotropic(advance: f64, ink: Option<(f64, f64, f64, f64)>, standoff: f64) -> f64 {
546    let mut half = 0.5 * advance;
547    if let Some((x0, x1, _, _)) = ink {
548        half = half.max(0.5 * (x1 - x0));
549    }
550    half + LABEL_GAP_PX + standoff
551}
552
553/// Outline chem label glyphs at the placed origin (SVG +Y down).
554fn outline_placed(
555    parts: &LabelParts,
556    side: LabelSide,
557    origin_x: f64,
558    baseline_y: f64,
559    atom_x: f64,
560    _atom_y: f64,
561    font_px: f64,
562    style: FaceStyle,
563) -> Option<Shape> {
564    let face = font::face_metrics(style);
565    let scale = font_px / face.upem;
566    let line_gap = 1.1 * (face.cap_height / face.upem) * font_px;
567
568    match side {
569        LabelSide::East | LabelSide::West => {
570            let glyphs = glyphs_for_side(parts, side, style);
571            font::compile_chem_shapes(
572                &glyphs,
573                origin_x,
574                baseline_y,
575                font_px,
576                "start",
577                style,
578            )
579        }
580        LabelSide::North | LabelSide::South => {
581            // Stack: center on atom; travel (+ charge) above (N) or below (S).
582            let center = center_glyphs(parts, style);
583            let mut travel = h_travel_glyphs(parts, style);
584            travel.extend(charge_glyphs(parts.charge, style));
585            let (c_shape, c_adv_em) = font::outline_chem_run_em(&center, style);
586            let c_adv = c_adv_em * scale;
587            let c_origin_x = atom_x - 0.5 * c_adv;
588            let mut acc = c_shape.map(|s| {
589                s.scale(scale, -scale, 0.0, 0.0)
590                    .translate(c_origin_x, baseline_y)
591            });
592            if !travel.is_empty() {
593                let (t_shape, t_adv_em) = font::outline_chem_run_em(&travel, style);
594                let t_adv = t_adv_em * scale;
595                let t_origin_x = atom_x - 0.5 * t_adv;
596                let t_y = if side == LabelSide::North {
597                    baseline_y - line_gap
598                } else {
599                    baseline_y + line_gap
600                };
601                if let Some(ts) = t_shape {
602                    let placed = ts
603                        .scale(scale, -scale, 0.0, 0.0)
604                        .translate(t_origin_x, t_y);
605                    acc = Some(match acc.take() {
606                        Some(a) => a.union(&placed),
607                        None => placed,
608                    });
609                }
610            }
611            acc.filter(|s| !s.is_empty())
612        }
613    }
614}
615
616/// Place one label so the atom-center glyph sits on `(atom_x, atom_y)`.
617pub fn place_label(
618    raw: &str,
619    atom_x: f64,
620    atom_y: f64,
621    side: LabelSide,
622    font_px: f64,
623    style: FaceStyle,
624) -> PlacedLabel {
625    place_label_weighted(
626        raw,
627        atom_x,
628        atom_y,
629        side,
630        font_px,
631        style,
632        crate::metrics::WEIGHT_MIN,
633    )
634}
635
636/// Like [`place_label`], with mol ``weight`` increasing bond↔label clearance.
637pub fn place_label_weighted(
638    raw: &str,
639    atom_x: f64,
640    atom_y: f64,
641    side: LabelSide,
642    font_px: f64,
643    style: FaceStyle,
644    weight: f64,
645) -> PlacedLabel {
646    let parts = split_label(raw);
647    let text = compose_label(&parts, side);
648    let (center_adv, ink) = center_glyph_ink_rel(&parts, side, font_px, style);
649    let prefix = prefix_before_center(&parts, side, font_px, style);
650    let origin_x = atom_x - prefix - 0.5 * center_adv;
651    let y = atom_y + baseline_offset(font_px, style);
652    let standoff = label_weight_standoff_px(weight);
653    let clearance = clearance_isotropic(center_adv, ink, standoff);
654    let path_d = outline_placed(&parts, side, origin_x, y, atom_x, atom_y, font_px, style)
655        .map(|s| s.to_svg_d())
656        .unwrap_or_default();
657    PlacedLabel {
658        raw: raw.to_string(),
659        text,
660        origin_x,
661        y,
662        atom_x,
663        atom_y,
664        side,
665        clearance,
666        path_d,
667    }
668}
669
670/// Glyph ink for a placed label (halo / occupancy).
671pub fn label_ink_shape(pl: &PlacedLabel, font_px: f64, style: FaceStyle) -> Option<Shape> {
672    let parts = split_label(&pl.raw);
673    outline_placed(
674        &parts,
675        pl.side,
676        pl.origin_x,
677        pl.y,
678        pl.atom_x,
679        pl.atom_y,
680        font_px,
681        style,
682    )
683}
684
685/// Shorten a bond segment by end insets (same as Python `bonds.shorten`).
686pub fn shorten_bond(
687    x1: f64,
688    y1: f64,
689    x2: f64,
690    y2: f64,
691    gap1: f64,
692    gap2: f64,
693) -> (f64, f64, f64, f64) {
694    let dx = x2 - x1;
695    let dy = y2 - y1;
696    let length = (dx * dx + dy * dy).sqrt().max(1e-9);
697    if gap1 + gap2 >= length {
698        let mx = 0.5 * (x1 + x2);
699        let my = 0.5 * (y1 + y2);
700        return (mx, my, mx, my);
701    }
702    let ux = dx / length;
703    let uy = dy / length;
704    (
705        x1 + ux * gap1,
706        y1 + uy * gap1,
707        x2 - ux * gap2,
708        y2 - uy * gap2,
709    )
710}
711
712/// Atom input for backbone + label placement.
713#[derive(Debug, Clone)]
714pub struct AtomIn {
715    pub x: f64,
716    pub y: f64,
717    /// Display label (`None` / empty → no label).
718    pub label: Option<String>,
719}
720
721/// Bond as atom index pair into [`place_backbone`] atoms slice.
722#[derive(Debug, Clone, Copy)]
723pub struct BondIn {
724    pub begin: usize,
725    pub end: usize,
726}
727
728/// Shortened bond endpoints in SVG space.
729#[derive(Debug, Clone, Copy)]
730pub struct BondOut {
731    pub x1: f64,
732    pub y1: f64,
733    pub x2: f64,
734    pub y2: f64,
735}
736
737/// Place all atom labels and shorten bond endpoints into label clearances.
738///
739/// ``weight`` (≥ 1) thickens bond↔label standoff so fat ink clears fat stems.
740pub fn place_backbone(
741    atoms: &[AtomIn],
742    bonds: &[BondIn],
743    font_px: f64,
744    style: FaceStyle,
745    weight: f64,
746) -> (Vec<BondOut>, Vec<Option<PlacedLabel>>) {
747    let standoff = label_weight_standoff_px(weight);
748    let n = atoms.len();
749    let mut nbrs: Vec<Vec<(f64, f64)>> = vec![Vec::new(); n];
750    for b in bonds {
751        if b.begin < n && b.end < n {
752            nbrs[b.begin].push((atoms[b.end].x, atoms[b.end].y));
753            nbrs[b.end].push((atoms[b.begin].x, atoms[b.begin].y));
754        }
755    }
756
757    let mut metrics: Vec<Option<(f64, Option<(f64, f64, f64, f64)>)>> = Vec::with_capacity(n);
758    let mut labels: Vec<Option<PlacedLabel>> = Vec::with_capacity(n);
759    for (i, atom) in atoms.iter().enumerate() {
760        let raw = atom.label.as_deref().unwrap_or("").trim();
761        if raw.is_empty() {
762            labels.push(None);
763            metrics.push(None);
764            continue;
765        }
766        let parts = split_label(raw);
767        let elem = parts.center.as_str();
768        let side = label_side_for((atom.x, atom.y), &nbrs[i], Some(elem));
769        metrics.push(Some(center_glyph_ink_rel(&parts, side, font_px, style)));
770        labels.push(Some(place_label_weighted(
771            raw, atom.x, atom.y, side, font_px, style, weight,
772        )));
773    }
774
775    let mut out_bonds = Vec::with_capacity(bonds.len());
776    for b in bonds {
777        if b.begin >= n || b.end >= n {
778            out_bonds.push(BondOut {
779                x1: 0.0,
780                y1: 0.0,
781                x2: 0.0,
782                y2: 0.0,
783            });
784            continue;
785        }
786        let a = &atoms[b.begin];
787        let c = &atoms[b.end];
788        let dx = c.x - a.x;
789        let dy = c.y - a.y;
790        let len = (dx * dx + dy * dy).sqrt().max(1e-9);
791        let ux = dx / len;
792        let uy = dy / len;
793        let gap1 = match metrics[b.begin] {
794            Some((adv, ink)) => clearance_toward(adv, ink, ux, uy, standoff),
795            None => 0.0,
796        };
797        let gap2 = match metrics[b.end] {
798            Some((adv, ink)) => clearance_toward(adv, ink, -ux, -uy, standoff),
799            None => 0.0,
800        };
801        let (x1, y1, x2, y2) = shorten_bond(a.x, a.y, c.x, c.y, gap1, gap2);
802        out_bonds.push(BondOut { x1, y1, x2, y2 });
803    }
804
805    (out_bonds, labels)
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use crate::metrics::{FONT_PX, LABEL_GAP_PX};
812
813    #[test]
814    fn split_oh_and_nh2() {
815        let oh = split_label("OH");
816        assert_eq!(oh.center, "O");
817        assert_eq!(oh.traveling, "H");
818        let nh2 = split_label("NH2");
819        assert_eq!(nh2.center, "N");
820        assert_eq!(nh2.traveling, "H2");
821        let cl = split_label("Cl");
822        assert_eq!(cl.center, "Cl");
823        assert!(cl.traveling.is_empty());
824        let glc = split_label("GlcA");
825        assert_eq!(glc.center, "GlcA");
826        assert!(glc.traveling.is_empty());
827    }
828
829    #[test]
830    fn split_charge_and_ammonium() {
831        let n = split_label("NH4+");
832        assert_eq!(n.center, "N");
833        assert_eq!(n.traveling, "H4");
834        assert_eq!(n.charge, 1);
835        let o = split_label("O−");
836        assert_eq!(o.center, "O");
837        assert_eq!(o.charge, -1);
838        let star = split_label("*");
839        assert_eq!(star.center, "*");
840    }
841
842    #[test]
843    fn oh_flips_on_west() {
844        let parts = split_label("OH");
845        assert_eq!(compose_label(&parts, LabelSide::East), "OH");
846        assert_eq!(compose_label(&parts, LabelSide::West), "HO");
847    }
848
849    #[test]
850    fn nh2_uses_subscript() {
851        let parts = split_label("NH2");
852        assert_eq!(compose_label(&parts, LabelSide::East), "NH₂");
853        assert_eq!(compose_label(&parts, LabelSide::West), "H₂N");
854        // Same pathway as braced markup (bare ``H_2`` is literal outside ``$``).
855        let via_markup = markup::parse_label_markup("H_{2}", FaceStyle::Regular);
856        assert_eq!(via_markup.len(), 2);
857        assert_eq!(via_markup[0].ch, 'H');
858        assert_eq!(via_markup[1].role, ScriptRole::Subscript);
859    }
860
861    #[test]
862    fn star_label_scripts_via_markup() {
863        // Bare underscore is literal (``my_name``-safe).
864        assert_eq!(
865            compose_label(&split_label("R_1"), LabelSide::East),
866            "R_1"
867        );
868        assert_eq!(
869            compose_label(&split_label("my_name"), LabelSide::East),
870            "my_name"
871        );
872        assert_eq!(
873            compose_label(&split_label("$R_1$"), LabelSide::East),
874            "R₁"
875        );
876        assert_eq!(
877            compose_label(&split_label("R^2"), LabelSide::East),
878            "R²"
879        );
880        assert_eq!(
881            compose_label(&split_label("R^{2+}"), LabelSide::East),
882            "R²⁺"
883        );
884        assert_eq!(
885            compose_label(&split_label(r"$\alpha$"), LabelSide::East),
886            "α"
887        );
888        assert_eq!(
889            compose_label(&split_label("**R**"), LabelSide::East),
890            "R"
891        );
892        let pl = place_label("$R_1$", 0.0, 0.0, LabelSide::East, FONT_PX, FaceStyle::Regular);
893        assert_eq!(pl.text, "R₁");
894        assert!(!pl.path_d.is_empty());
895        // West anchors on R (not the subscript digit).
896        let west = place_label("$R_1$", 0.0, 0.0, LabelSide::West, FONT_PX, FaceStyle::Regular);
897        assert_eq!(west.text, "R₁");
898        assert!(west.origin_x < 0.0 || west.origin_x.abs() < FONT_PX);
899    }
900
901    #[test]
902    fn charge_is_superscript() {
903        let parts = split_label("NH4+");
904        assert_eq!(compose_label(&parts, LabelSide::East), "NH₄⁺");
905    }
906
907    #[test]
908    fn glca_does_not_flip() {
909        let parts = split_label("GlcA");
910        assert_eq!(compose_label(&parts, LabelSide::East), "GlcA");
911        assert_eq!(compose_label(&parts, LabelSide::West), "GlcA");
912    }
913
914    #[test]
915    fn side_from_neighbors() {
916        assert_eq!(
917            label_side((0.0, 0.0), &[(10.0, 0.0)]),
918            LabelSide::West
919        );
920        assert_eq!(
921            label_side((0.0, 0.0), &[(-10.0, 0.0)]),
922            LabelSide::East
923        );
924        // Steep vertical, degree > 1 → N/S.
925        assert_eq!(
926            label_side((0.0, 0.0), &[(1.0, 10.0), (-1.0, 10.0)]),
927            LabelSide::North
928        );
929    }
930
931    #[test]
932    fn oh_center_stays_on_atom() {
933        let style = FaceStyle::Regular;
934        let east = place_label("OH", 100.0, 50.0, LabelSide::East, FONT_PX, style);
935        let west = place_label("OH", 100.0, 50.0, LabelSide::West, FONT_PX, style);
936        assert_eq!(east.text, "OH");
937        assert_eq!(west.text, "HO");
938        assert!(!east.path_d.is_empty());
939        assert!(east.path_d.contains('M'));
940        let o_adv = advance_px("O", FONT_PX, style);
941        assert!((east.origin_x + 0.5 * o_adv - 100.0).abs() < 1e-6);
942        let h_adv = advance_px("H", FONT_PX, style);
943        assert!((west.origin_x + h_adv + 0.5 * o_adv - 100.0).abs() < 1e-6);
944        assert!((east.clearance - (0.5 * o_adv + LABEL_GAP_PX)).abs() < 1e-6);
945        assert!(east.clearance < advance_px("OH", FONT_PX, style) * 0.5 + LABEL_GAP_PX - 0.1);
946    }
947
948    #[test]
949    fn n_diagonal_clearance_uses_ink_support() {
950        let parts = split_label("N");
951        let (adv, ink) = center_glyph_ink_rel(&parts, LabelSide::East, FONT_PX, FaceStyle::Regular);
952        let horiz = clearance_toward(adv, ink, 1.0, 0.0, 0.0);
953        let diag = clearance_toward(
954            adv,
955            ink,
956            std::f64::consts::FRAC_1_SQRT_2,
957            std::f64::consts::FRAC_1_SQRT_2,
958            0.0,
959        );
960        assert!((horiz - (0.5 * adv + LABEL_GAP_PX)).abs() < 1e-6);
961        assert!(diag > horiz + 0.3);
962    }
963
964    #[test]
965    fn backbone_weight_increases_bond_label_standoff() {
966        use crate::metrics::label_weight_standoff_px;
967        let atoms = vec![
968            AtomIn {
969                x: 0.0,
970                y: 0.0,
971                label: None,
972            },
973            AtomIn {
974                x: 40.0,
975                y: 0.0,
976                label: Some("OH".into()),
977            },
978        ];
979        let bonds = vec![BondIn { begin: 0, end: 1 }];
980        let (out1, labs1) = place_backbone(&atoms, &bonds, FONT_PX, FaceStyle::Regular, 1.0);
981        let (out2, labs2) = place_backbone(&atoms, &bonds, FONT_PX, FaceStyle::Regular, 2.0);
982        let c1 = labs1[1].as_ref().unwrap().clearance;
983        let c2 = labs2[1].as_ref().unwrap().clearance;
984        let extra = label_weight_standoff_px(2.0) - label_weight_standoff_px(1.0);
985        assert!((c2 - c1 - extra).abs() < 1e-6);
986        assert!((40.0 - out1[0].x2) < (40.0 - out2[0].x2) - 0.5);
987    }
988
989    #[test]
990    fn backbone_shortens_into_oh() {
991        let atoms = vec![
992            AtomIn {
993                x: 0.0,
994                y: 0.0,
995                label: None,
996            },
997            AtomIn {
998                x: 40.0,
999                y: 0.0,
1000                label: Some("OH".into()),
1001            },
1002        ];
1003        let bonds = vec![BondIn { begin: 0, end: 1 }];
1004        let (out, labels) = place_backbone(
1005            &atoms,
1006            &bonds,
1007            FONT_PX,
1008            FaceStyle::Regular,
1009            crate::metrics::WEIGHT_MIN,
1010        );
1011        assert!(labels[1].is_some());
1012        let lab = labels[1].as_ref().unwrap();
1013        assert_eq!(lab.text, "OH");
1014        assert!(out[0].x2 < 40.0);
1015        assert!((40.0 - out[0].x2 - lab.clearance).abs() < 1e-6);
1016    }
1017
1018    #[test]
1019    fn backbone_n_diagonal_standoff_exceeds_half_advance() {
1020        let atoms = vec![
1021            AtomIn {
1022                x: 0.0,
1023                y: 0.0,
1024                label: None,
1025            },
1026            AtomIn {
1027                x: 30.0,
1028                y: 30.0,
1029                label: Some("N".into()),
1030            },
1031        ];
1032        let bonds = vec![BondIn { begin: 0, end: 1 }];
1033        let (out, labels) = place_backbone(
1034            &atoms,
1035            &bonds,
1036            FONT_PX,
1037            FaceStyle::Regular,
1038            crate::metrics::WEIGHT_MIN,
1039        );
1040        let lab = labels[1].as_ref().unwrap();
1041        let n_adv = advance_px("N", FONT_PX, FaceStyle::Regular);
1042        let gap = ((out[0].x2 - 30.0).powi(2) + (out[0].y2 - 30.0).powi(2)).sqrt();
1043        assert!(gap > 0.5 * n_adv + LABEL_GAP_PX + 0.3);
1044        assert!((lab.clearance - (0.5 * n_adv + LABEL_GAP_PX)).abs() < 1e-6);
1045    }
1046
1047    #[test]
1048    fn star_label_emits_path() {
1049        let pl = place_label("*", 10.0, 10.0, LabelSide::East, FONT_PX, FaceStyle::Regular);
1050        assert_eq!(pl.text, "*");
1051        assert!(!pl.path_d.is_empty());
1052    }
1053
1054    #[test]
1055    fn split_empty_charges_and_non_h_suffix() {
1056        let empty = split_label("  ");
1057        assert!(empty.center.is_empty());
1058        assert_eq!(empty.charge, 0);
1059
1060        let fe = split_label("Fe3+");
1061        assert_eq!(fe.center, "Fe");
1062        assert_eq!(fe.charge, 3);
1063        assert!(fe.traveling.is_empty());
1064
1065        let n2 = split_label("N2+");
1066        assert_eq!(n2.center, "N");
1067        assert_eq!(n2.charge, 2);
1068
1069        let multi = split_label("O++");
1070        assert_eq!(multi.center, "O");
1071        assert_eq!(multi.charge, 2);
1072
1073        // Trailing junk after H → whole body is center (NHAc).
1074        let nhac = split_label("NHAc");
1075        assert_eq!(nhac.center, "NHAc");
1076        assert!(nhac.traveling.is_empty());
1077    }
1078
1079    #[test]
1080    fn label_side_degree0_and_vertical_degree1() {
1081        assert_eq!(
1082            label_side_for((0.0, 0.0), &[], Some("O")),
1083            LabelSide::West
1084        );
1085        assert_eq!(
1086            label_side_for((0.0, 0.0), &[], Some("C")),
1087            LabelSide::East
1088        );
1089        // Degree-1 steep vertical → forced East (RDKit).
1090        assert_eq!(
1091            label_side((0.0, 0.0), &[(0.0, 20.0)]),
1092            LabelSide::East
1093        );
1094    }
1095
1096    #[test]
1097    fn label_side_degree3_keeps_north_south() {
1098        // Three neighbors with a near-vertical bond.
1099        let side = label_side(
1100            (0.0, 0.0),
1101            &[(10.0, 2.0), (-10.0, 2.0), (0.0, 15.0)],
1102        );
1103        assert_eq!(side, LabelSide::North);
1104
1105        let south = label_side(
1106            (0.0, 0.0),
1107            &[(10.0, -2.0), (-10.0, -2.0), (0.0, -15.0)],
1108        );
1109        assert_eq!(south, LabelSide::South);
1110    }
1111
1112    #[test]
1113    fn north_south_nh2_stacks_and_compose_charge() {
1114        let north = place_label("NH2", 50.0, 50.0, LabelSide::North, FONT_PX, FaceStyle::Regular);
1115        let south = place_label("NH2", 50.0, 50.0, LabelSide::South, FONT_PX, FaceStyle::Regular);
1116        assert!(north.path_d.contains('M'));
1117        assert!(south.path_d.contains('M'));
1118        assert_eq!(north.text, "NH₂");
1119        let ink = label_ink_shape(&north, FONT_PX, FaceStyle::Regular);
1120        assert!(ink.is_some());
1121
1122        let parts = split_label("Fe2+");
1123        assert_eq!(compose_label(&parts, LabelSide::East), "Fe²⁺");
1124        let west_abbr = place_label("GlcA", 0.0, 0.0, LabelSide::West, FONT_PX, FaceStyle::Regular);
1125        assert!(!west_abbr.path_d.is_empty());
1126    }
1127
1128    #[test]
1129    fn shorten_bond_collapse_and_oob_backbone() {
1130        let (x1, y1, x2, y2) = shorten_bond(0.0, 0.0, 5.0, 0.0, 4.0, 4.0);
1131        assert!((x1 - 2.5).abs() < 1e-9);
1132        assert!((x2 - 2.5).abs() < 1e-9);
1133        assert!((y1 - y2).abs() < 1e-9);
1134
1135        let atoms = vec![AtomIn {
1136            x: 0.0,
1137            y: 0.0,
1138            label: Some("O".into()),
1139        }];
1140        let bonds = vec![BondIn { begin: 0, end: 9 }];
1141        let (out, labels) = place_backbone(&atoms, &bonds, FONT_PX, FaceStyle::Regular, 1.0);
1142        assert!(labels[0].is_some());
1143        assert_eq!(out[0].x1, 0.0);
1144        assert_eq!(out[0].x2, 0.0);
1145    }
1146
1147    #[test]
1148    fn charged_west_oh_and_empty_label_skipped() {
1149        let pl = place_label("OH+", 10.0, 10.0, LabelSide::West, FONT_PX, FaceStyle::Regular);
1150        assert!(pl.text.contains('⁺') || pl.text.contains('+'));
1151        assert!(!pl.path_d.is_empty());
1152
1153        let atoms = vec![
1154            AtomIn {
1155                x: 0.0,
1156                y: 0.0,
1157                label: Some("   ".into()),
1158            },
1159            AtomIn {
1160                x: 20.0,
1161                y: 0.0,
1162                label: None,
1163            },
1164        ];
1165        let (_, labels) = place_backbone(
1166            &atoms,
1167            &[BondIn { begin: 0, end: 1 }],
1168            FONT_PX,
1169            FaceStyle::Regular,
1170            1.0,
1171        );
1172        assert!(labels[0].is_none());
1173        assert!(labels[1].is_none());
1174    }
1175
1176    #[test]
1177    fn script_digits_and_empty_place() {
1178        // Charge magnitude 10 → superscript digits 1,0; NH9 → subscript 9.
1179        let fe = split_label("Fe10+");
1180        assert_eq!(fe.charge, 10);
1181        let text = compose_label(&fe, LabelSide::East);
1182        assert!(text.contains('¹') && text.contains('⁰') && text.contains('⁺'));
1183
1184        let nh9 = compose_label(&split_label("NH9"), LabelSide::East);
1185        assert!(nh9.contains('₉'));
1186
1187        // Other script digits via high H-count / charge.
1188        let nh3 = compose_label(&split_label("NH3"), LabelSide::East);
1189        assert!(nh3.contains('₃'));
1190        let fe4 = compose_label(&split_label("Fe4+"), LabelSide::East);
1191        assert!(fe4.contains('⁴'));
1192
1193        // Sweep remaining script digit arms used by H-counts / charges.
1194        for (raw, needle) in [
1195            ("NH4", '₄'),
1196            ("NH5", '₅'),
1197            ("NH6", '₆'),
1198            ("NH7", '₇'),
1199            ("NH8", '₈'),
1200            ("Fe5+", '⁵'),
1201            ("Fe6+", '⁶'),
1202            ("Fe7+", '⁷'),
1203            ("Fe8+", '⁸'),
1204            ("Fe9+", '⁹'),
1205        ] {
1206            let t = compose_label(&split_label(raw), LabelSide::East);
1207            assert!(t.contains(needle), "{raw} → {t}");
1208        }
1209
1210        let empty = place_label("", 0.0, 0.0, LabelSide::East, FONT_PX, FaceStyle::Regular);
1211        assert!(empty.path_d.is_empty());
1212        assert!(empty.text.is_empty());
1213    }
1214
1215    #[test]
1216    fn degree3_near_vertical_bond_angles() {
1217        // ~85° and ~-85° bonds exercise the atan degree windows.
1218        let north = label_side(
1219            (0.0, 0.0),
1220            &[
1221                (1.0, 10.0),  // near vertical south neighbor sum → North
1222                (8.0, 1.0),
1223                (-8.0, 1.0),
1224            ],
1225        );
1226        assert_eq!(north, LabelSide::North);
1227        let south = label_side(
1228            (0.0, 0.0),
1229            &[
1230                (1.0, -10.0),
1231                (8.0, -1.0),
1232                (-8.0, -1.0),
1233            ],
1234        );
1235        assert_eq!(south, LabelSide::South);
1236    }
1237}