Skip to main content

xpict_core/
metrics.rs

1//! Depiction proportions (xenopict / RDKit house style).
2//!
3//! Keep in sync with `python/xpict/draw/metrics.py`.
4
5/// xenopict `Xenopict.scale` / xenosite depiction `"scale": 20`.
6pub const SCALE: f64 = 20.0;
7
8/// Bond length in drawing / CSS-px units (alias of [`SCALE`]).
9pub const BOND_PX: f64 = SCALE;
10
11/// Atom-label em as a fraction of bond length.
12///
13/// RDKit documents `baseFontSize = 0.6`, but MolDraw2D outlined heteroatom
14/// glyphs measure ~0.29 × bond in cap height (~0.45 em with Liberation Sans).
15pub const FONT_FRAC: f64 = 0.45;
16
17/// Liberation Sans Regular vertical stem width in em.
18pub const FONT_STEM_EM: f64 = 0.0933;
19
20/// Liberation Sans Bold vertical stem width in em (measured on H).
21///
22/// Reference measurement only — mol ink weight uses Regular + ``Shape::buffer``.
23pub const FONT_STEM_EM_BOLD: f64 = 0.144;
24
25/// Bond stroke as a fraction of bond length — tracks the label stem so ink
26/// weight matches letter stems when [`FONT_FRAC`] changes.
27pub const STROKE_FRAC: f64 = 0.042; // FONT_STEM_EM * FONT_FRAC, rounded
28
29/// RDKit `multipleBondOffset`.
30pub const OFFSET_FRAC: f64 = 0.15;
31
32/// Full wedge width at the fat end, as a fraction of bond length.
33pub const WEDGE_WIDTH_FRAC: f64 = 0.30;
34
35pub const PAD_FRAC: f64 = 1.0;
36pub const END_GAP_FRAC: f64 = 0.13;
37pub const CHAIN_END_GAP_FRAC: f64 = 0.0;
38pub const HASH_PER_BOND: i32 = 8;
39pub const HALO_FRAC: f64 = 2.0 * STROKE_FRAC;
40pub const LABEL_GAP_FRAC: f64 = 0.05;
41pub const SHADE_FRAC: f64 = 0.90;
42pub const MARK_FRAC: f64 = 1.0;
43/// xenopict mark layer: `stroke-width: scale * 0.1`, opacity 0.7.
44///
45/// Kept absolute (not stem-keyed): xenopict bonds and marks both used
46/// ``scale * 0.1``, but xpict bond ink is thinner (Liberation stem). Marks
47/// still match xenopict's publication ring weight.
48pub const MARK_STROKE_FRAC: f64 = 0.1;
49/// xenopict mark halo (`<use>`): `stroke-width: scale * 0.2`, opacity 0.45.
50pub const MARK_HALO_STROKE_FRAC: f64 = 0.2;
51pub const MARK_OPACITY: f64 = 0.7;
52pub const MARK_HALO_OPACITY: f64 = 0.45;
53/// xenopict mark halo `<use stroke="#555">`.
54///
55/// Mark ink has stroke-width/opacity but **no** stroke color (xenopict leaves
56/// that for host CSS on `.mark`). Xpict does not style by class: the visible
57/// ring is this baked halo attribute; ink stays uncolored.
58pub const MARK_HALO_COLOR: &str = "#555";
59/// RDKit ``DrawText::SUBS_SCALE`` / ``SUPER_SCALE`` for H-counts and charges.
60pub const SCRIPT_SCALE: f64 = 0.66;
61/// Dummy / R-group ``*`` height as a multiple of Liberation cap-height.
62///
63/// Custom five-point star (not the Liberation asterisk); ~2× letter height so
64/// attachment points read clearly next to heteroatom labels.
65pub const STAR_FRAC: f64 = 2.0;
66
67pub const STROKE_PX: f64 = STROKE_FRAC * BOND_PX;
68pub const OFFSET_PX: f64 = OFFSET_FRAC * BOND_PX;
69pub const WEDGE_HALF_PX: f64 = WEDGE_WIDTH_FRAC * BOND_PX * 0.5;
70pub const FONT_PX: f64 = FONT_FRAC * BOND_PX;
71pub const PAD_PX: f64 = PAD_FRAC * BOND_PX;
72pub const END_GAP_PX: f64 = END_GAP_FRAC * BOND_PX;
73pub const CHAIN_END_GAP_PX: f64 = CHAIN_END_GAP_FRAC * BOND_PX;
74pub const HALO_STROKE: f64 = HALO_FRAC * BOND_PX;
75pub const LABEL_GAP_PX: f64 = LABEL_GAP_FRAC * BOND_PX;
76/// Halo outline grow (half the label-gap air) + soft white fill.
77pub const HALO_GAP_PX: f64 = 0.5 * LABEL_GAP_PX;
78pub const HALO_OPACITY: f64 = 0.5;
79pub const MARK_STROKE_PX: f64 = MARK_STROKE_FRAC * BOND_PX;
80pub const MARK_HALO_STROKE_PX: f64 = MARK_HALO_STROKE_FRAC * BOND_PX;
81
82/// Absolute ink multiplier at user-facing ``weight = 1`` (house look).
83///
84/// Public ``weight`` is relative to this: default ``1`` → this multiplier;
85/// absolute floor stays ``1`` (Regular stem), so the user minimum is
86/// [`WEIGHT_MIN`] (= ``1 / WEIGHT_AT_ONE``).
87pub const WEIGHT_AT_ONE: f64 = 1.5;
88
89/// Lowest user-facing ``weight`` (maps to absolute ink ``1`` = Regular stem).
90pub const WEIGHT_MIN: f64 = 1.0 / WEIGHT_AT_ONE;
91
92/// Map user-facing mol ``weight`` → absolute ink multiplier (≥ 1).
93///
94/// Call this (or helpers that call it) with the public ``weight`` field — never
95/// with an already-converted absolute value.
96///
97/// # Panics
98/// Panics if ``weight`` is non-finite or ``< WEIGHT_MIN``.
99pub fn diagram_weight(weight: f64) -> f64 {
100    assert!(
101        weight.is_finite() && weight >= WEIGHT_MIN - 1e-12,
102        "mol weight must be finite and >= {WEIGHT_MIN}, got {weight}"
103    );
104    weight * WEIGHT_AT_ONE
105}
106
107/// Outward glyph buffer (px) so absolute ink stems grow past Regular.
108///
109/// ``grow = 0.5 × FONT_STEM_EM × FONT_PX × (ink - 1)`` where ``ink`` is
110/// [`diagram_weight`]; zero at [`WEIGHT_MIN`].
111///
112/// # Panics
113/// Panics if ``weight`` is invalid (see [`diagram_weight`]).
114pub fn label_weight_grow_px(weight: f64) -> f64 {
115    let ink = diagram_weight(weight);
116    0.5 * FONT_STEM_EM * FONT_PX * (ink - 1.0)
117}
118
119/// Extra bond↔label standoff (px) when absolute ink exceeds Regular.
120///
121/// Label buffer grow + half the extra bond stroke vs [`STROKE_PX`].
122///
123/// # Panics
124/// Panics if ``weight`` is invalid (see [`diagram_weight`]).
125pub fn label_weight_standoff_px(weight: f64) -> f64 {
126    label_weight_grow_px(weight) + 0.5 * (stroke_px_for_weight(weight) - STROKE_PX)
127}
128
129/// Bond stroke in drawing px for user-facing mol ``weight``.
130///
131/// # Panics
132/// Panics if ``weight`` is invalid (see [`diagram_weight`]).
133pub fn stroke_px_for_weight(weight: f64) -> f64 {
134    stroke_px_from_stem(FONT_STEM_EM) * diagram_weight(weight)
135}
136
137/// Bond stroke fraction from a stem width in em (rounded like [`STROKE_FRAC`]).
138pub fn stroke_frac_from_stem(stem_em: f64) -> f64 {
139    (stem_em * FONT_FRAC * 1000.0).round() / 1000.0
140}
141
142/// Bond stroke in drawing px keyed to a label stem.
143pub fn stroke_px_from_stem(stem_em: f64) -> f64 {
144    stroke_frac_from_stem(stem_em) * BOND_PX
145}
146
147/// Halo stroke width tracks active bond ink (2×, same ratio as [`HALO_FRAC`]).
148pub fn halo_stroke_from_stroke(stroke_px: f64) -> f64 {
149    2.0 * stroke_px
150}
151
152/// Halo stroke for mol ``weight``: [`HALO_STROKE`] × √ink (sublinear vs ink).
153///
154/// # Panics
155/// Panics if ``weight`` is invalid (see [`diagram_weight`]).
156pub fn halo_stroke_for_weight(weight: f64) -> f64 {
157    HALO_STROKE * diagram_weight(weight).sqrt()
158}
159
160/// Outer halo buffer for mol ``weight``: [`HALO_GAP_PX`] × √ink.
161///
162/// # Panics
163/// Panics if ``weight`` is invalid (see [`diagram_weight`]).
164pub fn halo_gap_for_weight(weight: f64) -> f64 {
165    HALO_GAP_PX * diagram_weight(weight).sqrt()
166}
167
168/// Dash count scaling with drawn bond length (hashed wedges).
169pub fn hash_count(length: f64) -> usize {
170    let n = (f64::from(HASH_PER_BOND) * length / BOND_PX).round() as i32;
171    n.clamp(5, 12) as usize
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn offset_matches_python_house_style() {
180        assert!((OFFSET_PX - 3.0).abs() < 1e-9);
181        assert!((STROKE_PX - 0.84).abs() < 1e-9);
182        assert!((FONT_PX - 9.0).abs() < 1e-9);
183        assert!((SHADE_FRAC - 0.90).abs() < 1e-9);
184    }
185
186    #[test]
187    fn stroke_tracks_font_stem() {
188        let expected = (FONT_STEM_EM * FONT_FRAC * 1000.0).round() / 1000.0;
189        assert!((STROKE_FRAC - expected).abs() < 1e-9);
190        assert!((stroke_px_from_stem(FONT_STEM_EM) - STROKE_PX).abs() < 1e-9);
191    }
192
193    #[test]
194    fn weight_scales_stroke_and_grow() {
195        // User weight 1 → house ink (WEIGHT_AT_ONE); WEIGHT_MIN → Regular stem.
196        assert!((diagram_weight(1.0) - WEIGHT_AT_ONE).abs() < 1e-9);
197        assert!((diagram_weight(WEIGHT_MIN) - 1.0).abs() < 1e-9);
198        assert!((stroke_px_for_weight(WEIGHT_MIN) - STROKE_PX).abs() < 1e-9);
199        assert!((stroke_px_for_weight(1.0) - WEIGHT_AT_ONE * STROKE_PX).abs() < 1e-9);
200        assert!((stroke_px_for_weight(2.0) - 2.0 * WEIGHT_AT_ONE * STROKE_PX).abs() < 1e-9);
201        assert!(label_weight_grow_px(WEIGHT_MIN).abs() < 1e-12);
202        let grow1 = label_weight_grow_px(1.0);
203        assert!((grow1 - 0.5 * FONT_STEM_EM * FONT_PX * (WEIGHT_AT_ONE - 1.0)).abs() < 1e-9);
204        assert!((halo_stroke_from_stroke(STROKE_PX) - 2.0 * STROKE_PX).abs() < 1e-9);
205        assert!((halo_stroke_for_weight(WEIGHT_MIN) - HALO_STROKE).abs() < 1e-9);
206        assert!((halo_stroke_for_weight(1.0) - HALO_STROKE * WEIGHT_AT_ONE.sqrt()).abs() < 1e-9);
207        // User weight whose absolute ink is 4 → halo ×2.
208        let w_abs4 = 4.0 / WEIGHT_AT_ONE;
209        assert!((halo_stroke_for_weight(w_abs4) - 2.0 * HALO_STROKE).abs() < 1e-9);
210        assert!((halo_gap_for_weight(w_abs4) - 2.0 * HALO_GAP_PX).abs() < 1e-9);
211        assert!(halo_stroke_for_weight(2.0) < halo_stroke_from_stroke(stroke_px_for_weight(2.0)));
212        assert!(label_weight_standoff_px(WEIGHT_MIN).abs() < 1e-12);
213        let stand1 = label_weight_standoff_px(1.0);
214        assert!(stand1 > label_weight_grow_px(1.0));
215        assert!(
216            (stand1
217                - (label_weight_grow_px(1.0)
218                    + 0.5 * (stroke_px_for_weight(1.0) - STROKE_PX)))
219                .abs()
220                < 1e-9
221        );
222    }
223
224    #[test]
225    #[should_panic(expected = "mol weight must be finite and >=")]
226    fn diagram_weight_rejects_below_min() {
227        let _ = diagram_weight(0.5);
228    }
229}