1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LabelSide {
23 East,
25 West,
27 North,
29 South,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LabelParts {
36 pub center: String,
37 pub traveling: String,
39 pub charge: i32,
41}
42
43#[derive(Debug, Clone)]
45pub struct PlacedLabel {
46 pub raw: String,
48 pub text: String,
50 pub origin_x: f64,
52 pub y: f64,
53 pub atom_x: f64,
54 pub atom_y: f64,
55 pub side: LabelSide,
56 pub clearance: f64,
60 pub path_d: String,
62}
63
64pub 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 let (body, charge) = if s.starts_with('$') && s.ends_with('$') && s.len() >= 2 {
81 (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 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 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 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 if without_sign.contains('H') {
159 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 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
207pub fn label_side(atom: (f64, f64), neighbors: &[(f64, f64)]) -> LabelSide {
212 label_side_for(atom, neighbors, None)
213}
214
215pub 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; if neighbors.is_empty() {
223 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 LabelSide::North
247 } else {
248 LabelSide::South
249 };
250
251 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 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 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 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
321fn 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 let mut g = charge;
336 g.extend(travel);
337 g.extend(center);
338 g
339 }
340 }
341}
342
343pub 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
425fn 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
450fn 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(¢er[..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
479fn 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 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
553fn 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 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(¢er, 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
616pub 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
636pub 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
670pub 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
685pub 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#[derive(Debug, Clone)]
714pub struct AtomIn {
715 pub x: f64,
716 pub y: f64,
717 pub label: Option<String>,
719}
720
721#[derive(Debug, Clone, Copy)]
723pub struct BondIn {
724 pub begin: usize,
725 pub end: usize,
726}
727
728#[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
737pub 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 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 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 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 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 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 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 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 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 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 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 let north = label_side(
1219 (0.0, 0.0),
1220 &[
1221 (1.0, 10.0), (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}