1use 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
12pub 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
24pub 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
37pub 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#[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#[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
183pub 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
209pub 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
254pub 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
301pub 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
340const JOIN_PARALLEL_EPS: f64 = 1e-9;
342const JOIN_MAX_T_FRAC: f64 = 0.9;
344
345#[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
370pub type EndTrims = (Vec<f64>, Vec<f64>);
372
373#[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 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
454pub 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 let mut moves: HashMap<(i32, i32), (f64, f64, f64)> = HashMap::new();
475 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#[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
809pub 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 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 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 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 #[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 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 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 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 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]); 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}