1use serde::{Deserialize, Serialize};
15
16#[cfg(feature = "codegen")]
17use schemars::JsonSchema;
18#[cfg(feature = "codegen")]
19use ts_rs::TS;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
25#[cfg_attr(feature = "codegen", ts(export))]
26pub enum TextAnchor {
27 Start,
28 Middle,
29 End,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
36#[cfg_attr(feature = "codegen", ts(export))]
37pub enum LayerName {
38 Shading,
39 Halo,
40 Bonds,
41 Labels,
42 Marks,
43 Overlay,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(tag = "kind", rename_all = "lowercase")]
49#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
50#[cfg_attr(feature = "codegen", ts(export))]
51pub enum Primitive {
52 Path {
53 d: String,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 #[cfg_attr(feature = "codegen", ts(optional))]
56 stroke: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 #[cfg_attr(feature = "codegen", ts(optional))]
59 fill: Option<String>,
60 #[serde(default = "default_stroke_width")]
61 stroke_width: f64,
62 #[serde(default = "default_opacity")]
63 opacity: f64,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 #[cfg_attr(feature = "codegen", ts(optional))]
66 stroke_dasharray: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 #[cfg_attr(feature = "codegen", ts(optional))]
69 stroke_linecap: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none", rename = "cls")]
71 #[cfg_attr(feature = "codegen", ts(optional, rename = "cls"))]
72 class: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 #[cfg_attr(feature = "codegen", ts(optional))]
76 data_text: Option<String>,
77 },
78 Circle {
79 cx: f64,
80 cy: f64,
81 r: f64,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 #[cfg_attr(feature = "codegen", ts(optional))]
84 fill: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 #[cfg_attr(feature = "codegen", ts(optional))]
87 stroke: Option<String>,
88 #[serde(default = "default_stroke_width")]
89 stroke_width: f64,
90 #[serde(default = "default_opacity")]
91 opacity: f64,
92 #[serde(default, skip_serializing_if = "Option::is_none", rename = "cls")]
93 #[cfg_attr(feature = "codegen", ts(optional, rename = "cls"))]
94 class: Option<String>,
95 },
96 Text {
99 x: f64,
100 y: f64,
101 text: String,
102 #[serde(default = "default_fill")]
103 fill: String,
104 #[serde(default = "default_font_size")]
105 font_size: f64,
106 #[serde(default = "default_anchor")]
107 anchor: TextAnchor,
108 #[serde(default, skip_serializing_if = "Option::is_none", rename = "cls")]
109 #[cfg_attr(feature = "codegen", ts(optional, rename = "cls"))]
110 class: Option<String>,
111 },
112}
113
114fn default_stroke_width() -> f64 {
115 1.5
116}
117fn default_opacity() -> f64 {
118 1.0
119}
120fn default_fill() -> String {
121 "#000".into()
122}
123fn default_font_size() -> f64 {
124 12.0
125}
126fn default_anchor() -> TextAnchor {
127 TextAnchor::Middle
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
132#[cfg_attr(feature = "codegen", ts(export))]
133pub struct Layer {
134 pub name: LayerName,
135 #[serde(default)]
136 pub primitives: Vec<Primitive>,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
142#[cfg_attr(feature = "codegen", ts(export))]
143pub struct Viewport {
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 #[cfg_attr(feature = "codegen", ts(optional))]
146 pub id: Option<String>,
147 #[serde(default)]
148 pub x: f64,
149 #[serde(default)]
150 pub y: f64,
151 pub width: f64,
152 pub height: f64,
153 #[serde(default)]
154 pub layers: Vec<Layer>,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
163#[cfg_attr(feature = "codegen", ts(export))]
164pub struct Scene {
165 pub width: f64,
166 pub height: f64,
167 pub viewports: Vec<Viewport>,
168 #[serde(default)]
170 pub overlays: Vec<Primitive>,
171 #[serde(default)]
173 pub halo: Vec<Primitive>,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
182#[cfg_attr(feature = "codegen", ts(export))]
183pub struct AtomIn {
184 pub index: i32,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 #[cfg_attr(feature = "codegen", ts(optional))]
187 pub element: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 #[cfg_attr(feature = "codegen", ts(optional))]
191 pub z: Option<u32>,
192 pub x: f64,
193 pub y: f64,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 #[cfg_attr(feature = "codegen", ts(optional))]
196 pub label: Option<String>,
197 #[serde(default)]
198 pub charge: i32,
199}
200
201impl AtomIn {
202 pub fn symbol(&self) -> &str {
204 if let Some(ref el) = self.element {
205 if !el.is_empty() {
206 return el.as_str();
207 }
208 }
209 match self.z {
210 Some(z) => crate::element_symbol(z),
211 None => "C",
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
219#[cfg_attr(feature = "codegen", ts(export))]
220pub struct BondIn {
221 pub index: i32,
222 pub begin: i32,
223 pub end: i32,
224 pub order: f64,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 #[cfg_attr(feature = "codegen", ts(optional))]
227 pub stereo: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 #[cfg_attr(feature = "codegen", ts(optional))]
231 pub interior: Option<(f64, f64)>,
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[cfg_attr(feature = "codegen", derive(JsonSchema, TS))]
239#[cfg_attr(feature = "codegen", ts(export))]
240pub struct MoleculeIn {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 #[cfg_attr(feature = "codegen", ts(optional))]
243 pub id: Option<String>,
244 pub atoms: Vec<AtomIn>,
245 pub bonds: Vec<BondIn>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 #[cfg_attr(feature = "codegen", ts(optional))]
249 pub color: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 #[cfg_attr(feature = "codegen", ts(optional))]
253 pub atom_shade: Option<Vec<f64>>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 #[cfg_attr(feature = "codegen", ts(optional))]
257 pub bond_shade: Option<Vec<f64>>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 #[cfg_attr(feature = "codegen", ts(optional))]
261 pub shade_vmin: Option<f64>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 #[cfg_attr(feature = "codegen", ts(optional))]
265 pub shade_vmax: Option<f64>,
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
268 pub mark_atoms: Vec<i32>,
269 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub mark_bonds: Vec<(i32, i32)>,
272 #[serde(default = "default_mol_scale", skip_serializing_if = "is_default_mol_scale")]
274 pub scale: f64,
275 #[serde(default = "default_mol_weight", skip_serializing_if = "is_default_mol_weight")]
278 pub weight: f64,
279}
280
281fn default_mol_scale() -> f64 {
282 1.0
283}
284
285fn is_default_mol_scale(s: &f64) -> bool {
286 (*s - 1.0).abs() < 1e-12
287}
288
289fn default_mol_weight() -> f64 {
290 1.0
291}
292
293fn is_default_mol_weight(s: &f64) -> bool {
294 (*s - 1.0).abs() < 1e-12
295}
296
297impl MoleculeIn {
298 pub fn diagram_scale(&self) -> f64 {
300 if self.scale > 0.0 && self.scale.is_finite() {
301 self.scale
302 } else {
303 1.0
304 }
305 }
306
307 pub fn diagram_weight(&self) -> f64 {
312 crate::metrics::diagram_weight(self.weight)
313 }
314}
315
316pub fn scale_path_d(d: &str, s: f64) -> String {
318 if (s - 1.0).abs() < 1e-12 || d.is_empty() {
319 return d.to_string();
320 }
321 let bytes = d.as_bytes();
322 let mut out = String::with_capacity(d.len());
323 let mut i = 0;
324 while i < bytes.len() {
325 let c = bytes[i] as char;
326 if c == '-' || c == '+' || c == '.' || c.is_ascii_digit() {
327 let start = i;
328 if c == '-' || c == '+' {
329 i += 1;
330 }
331 while i < bytes.len() && (bytes[i] as char).is_ascii_digit() {
332 i += 1;
333 }
334 if i < bytes.len() && bytes[i] == b'.' {
335 i += 1;
336 while i < bytes.len() && (bytes[i] as char).is_ascii_digit() {
337 i += 1;
338 }
339 }
340 if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
341 i += 1;
342 if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
343 i += 1;
344 }
345 while i < bytes.len() && (bytes[i] as char).is_ascii_digit() {
346 i += 1;
347 }
348 }
349 let raw = std::str::from_utf8(&bytes[start..i]).unwrap_or("0");
350 let num: f64 = raw.parse().unwrap_or(0.0);
351 out.push_str(&format!("{:.2}", num * s));
352 } else {
353 out.push(c);
354 i += 1;
355 }
356 }
357 out
358}
359
360fn scale_dasharray(dash: &str, s: f64) -> String {
361 if (s - 1.0).abs() < 1e-12 {
362 return dash.to_string();
363 }
364 dash.split(|c: char| c == ',' || c.is_whitespace())
365 .filter(|t| !t.is_empty())
366 .map(|t| {
367 t.parse::<f64>()
368 .map(|n| format!("{:.2}", n * s))
369 .unwrap_or_else(|_| t.to_string())
370 })
371 .collect::<Vec<_>>()
372 .join(" ")
373}
374
375impl Primitive {
376 pub fn scale_uniform(&self, s: f64) -> Self {
378 if (s - 1.0).abs() < 1e-12 {
379 return self.clone();
380 }
381 match self {
382 Primitive::Path {
383 d,
384 stroke,
385 fill,
386 stroke_width,
387 opacity,
388 stroke_dasharray,
389 stroke_linecap,
390 class,
391 data_text,
392 } => Primitive::Path {
393 d: scale_path_d(d, s),
394 stroke: stroke.clone(),
395 fill: fill.clone(),
396 stroke_width: *stroke_width * s,
397 opacity: *opacity,
398 stroke_dasharray: stroke_dasharray
399 .as_ref()
400 .map(|d| scale_dasharray(d, s)),
401 stroke_linecap: stroke_linecap.clone(),
402 class: class.clone(),
403 data_text: data_text.clone(),
404 },
405 Primitive::Circle {
406 cx,
407 cy,
408 r,
409 fill,
410 stroke,
411 stroke_width,
412 opacity,
413 class,
414 } => Primitive::Circle {
415 cx: *cx * s,
416 cy: *cy * s,
417 r: *r * s,
418 fill: fill.clone(),
419 stroke: stroke.clone(),
420 stroke_width: *stroke_width * s,
421 opacity: *opacity,
422 class: class.clone(),
423 },
424 Primitive::Text {
425 x,
426 y,
427 text,
428 fill,
429 font_size,
430 anchor,
431 class,
432 } => Primitive::Text {
433 x: *x * s,
434 y: *y * s,
435 text: text.clone(),
436 fill: fill.clone(),
437 font_size: *font_size * s,
438 anchor: *anchor,
439 class: class.clone(),
440 },
441 }
442 }
443}
444
445impl Viewport {
446 pub fn scale_uniform(&self, s: f64) -> Self {
447 if (s - 1.0).abs() < 1e-12 {
448 return self.clone();
449 }
450 Self {
451 id: self.id.clone(),
452 x: self.x * s,
453 y: self.y * s,
454 width: self.width * s,
455 height: self.height * s,
456 layers: self
457 .layers
458 .iter()
459 .map(|layer| Layer {
460 name: layer.name,
461 primitives: layer
462 .primitives
463 .iter()
464 .map(|p| p.scale_uniform(s))
465 .collect(),
466 })
467 .collect(),
468 }
469 }
470}
471
472impl Scene {
473 pub fn empty(width: f64, height: f64) -> Self {
474 Self {
475 width,
476 height,
477 viewports: Vec::new(),
478 overlays: Vec::new(),
479 halo: Vec::new(),
480 }
481 }
482
483 pub fn scale_uniform(&self, s: f64) -> Self {
485 if (s - 1.0).abs() < 1e-12 {
486 return self.clone();
487 }
488 Self {
489 width: self.width * s,
490 height: self.height * s,
491 viewports: self
492 .viewports
493 .iter()
494 .map(|vp| vp.scale_uniform(s))
495 .collect(),
496 overlays: self
497 .overlays
498 .iter()
499 .map(|p| p.scale_uniform(s))
500 .collect(),
501 halo: self.halo.iter().map(|p| p.scale_uniform(s)).collect(),
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn scene_json_roundtrip_minimal() {
512 let scene = Scene {
513 width: 70.0,
514 height: 75.0,
515 viewports: vec![Viewport {
516 id: Some("m0".into()),
517 x: 0.0,
518 y: 0.0,
519 width: 70.0,
520 height: 75.0,
521 layers: vec![Layer {
522 name: LayerName::Bonds,
523 primitives: vec![Primitive::Path {
524 d: "M 0 0 L 20 0".into(),
525 stroke: Some("#111".into()),
526 fill: Some("none".into()),
527 stroke_width: 0.84,
528 opacity: 1.0,
529 stroke_dasharray: None,
530 stroke_linecap: Some("round".into()),
531 class: Some("bond bond-skeleton".into()),
532 data_text: None,
533 }],
534 }],
535 }],
536 overlays: vec![],
537 halo: vec![],
538 };
539 let json = serde_json::to_string(&scene).expect("serialize");
540 let back: Scene = serde_json::from_str(&json).expect("deserialize");
541 assert_eq!(back.width, 70.0);
542 assert_eq!(back.viewports[0].layers[0].primitives.len(), 1);
543 }
544
545 #[test]
546 fn primitive_defaults_via_partial_json() {
547 let path: Primitive = serde_json::from_str(r#"{"kind":"path","d":"M 0 0 L 1 0"}"#).unwrap();
549 match path {
550 Primitive::Path {
551 stroke_width,
552 opacity,
553 ..
554 } => {
555 assert!((stroke_width - 1.5).abs() < 1e-12);
556 assert!((opacity - 1.0).abs() < 1e-12);
557 }
558 _ => panic!("expected path"),
559 }
560 let circle: Primitive =
561 serde_json::from_str(r#"{"kind":"circle","cx":0,"cy":0,"r":1}"#).unwrap();
562 match circle {
563 Primitive::Circle {
564 stroke_width,
565 opacity,
566 ..
567 } => {
568 assert!((stroke_width - 1.5).abs() < 1e-12);
569 assert!((opacity - 1.0).abs() < 1e-12);
570 }
571 _ => panic!("expected circle"),
572 }
573 let text: Primitive =
574 serde_json::from_str(r#"{"kind":"text","x":0,"y":0,"text":"C"}"#).unwrap();
575 match text {
576 Primitive::Text {
577 fill,
578 font_size,
579 anchor,
580 ..
581 } => {
582 assert_eq!(fill, "#000");
583 assert!((font_size - 12.0).abs() < 1e-12);
584 assert_eq!(anchor, TextAnchor::Middle);
585 }
586 _ => panic!("expected text"),
587 }
588 }
589
590 #[test]
591 fn atom_symbol_resolves_element_z_and_fallback() {
592 let with_el = AtomIn {
593 index: 0,
594 element: Some("N".into()),
595 z: Some(6),
596 x: 0.0,
597 y: 0.0,
598 label: None,
599 charge: 0,
600 };
601 assert_eq!(with_el.symbol(), "N");
602
603 let empty_el = AtomIn {
604 index: 1,
605 element: Some(String::new()),
606 z: Some(8),
607 x: 0.0,
608 y: 0.0,
609 label: None,
610 charge: 0,
611 };
612 assert_eq!(empty_el.symbol(), "O");
613
614 let z_only = AtomIn {
615 index: 2,
616 element: None,
617 z: Some(7),
618 x: 0.0,
619 y: 0.0,
620 label: None,
621 charge: 0,
622 };
623 assert_eq!(z_only.symbol(), "N");
624
625 let bare = AtomIn {
626 index: 3,
627 element: None,
628 z: None,
629 x: 0.0,
630 y: 0.0,
631 label: None,
632 charge: 0,
633 };
634 assert_eq!(bare.symbol(), "C");
635 }
636
637 #[test]
638 fn scene_empty_has_no_viewports() {
639 let s = Scene::empty(100.0, 80.0);
640 assert_eq!(s.width, 100.0);
641 assert_eq!(s.height, 80.0);
642 assert!(s.viewports.is_empty());
643 assert!(s.overlays.is_empty());
644 assert!(s.halo.is_empty());
645 }
646
647 #[test]
648 fn mol_scale_weight_defaults_and_fallbacks() {
649 let mol: MoleculeIn = serde_json::from_str(
651 r#"{"id":"m","atoms":[],"bonds":[]}"#,
652 )
653 .unwrap();
654 assert!((mol.scale - 1.0).abs() < 1e-12);
655 assert!((mol.weight - 1.0).abs() < 1e-12);
656 assert!((mol.diagram_scale() - 1.0).abs() < 1e-12);
657 assert!((mol.diagram_weight() - crate::metrics::WEIGHT_AT_ONE).abs() < 1e-12);
658
659 let mut bad = mol.clone();
660 bad.scale = 0.0;
661 assert!((bad.diagram_scale() - 1.0).abs() < 1e-12);
662 bad.scale = f64::NAN;
663 assert!((bad.diagram_scale() - 1.0).abs() < 1e-12);
664 bad.scale = -2.0;
665 assert!((bad.diagram_scale() - 1.0).abs() < 1e-12);
666 bad.scale = 1.5;
667 assert!((bad.diagram_scale() - 1.5).abs() < 1e-12);
668
669 let json_default = serde_json::to_value(&mol).unwrap();
671 assert!(json_default.get("scale").is_none());
672 assert!(json_default.get("weight").is_none());
673 let mut thick = mol;
674 thick.weight = 2.0;
675 thick.scale = 1.5;
676 let json_thick = serde_json::to_value(&thick).unwrap();
677 assert_eq!(json_thick["weight"], 2.0);
678 assert_eq!(json_thick["scale"], 1.5);
679 }
680
681 #[test]
682 fn scale_path_d_and_dasharray_cover_token_shapes() {
683 assert_eq!(scale_path_d("M 0 0", 1.0), "M 0 0");
684 assert_eq!(scale_path_d("", 2.0), "");
685 let d = scale_path_d("M -1.5e1 +2.0E+1 L 3 4Z", 2.0);
687 assert!(d.contains("-30.00") || d.contains("-30"), "{d}");
688 assert!(d.contains("40.00") || d.contains("40"), "{d}");
689 assert!(d.ends_with('Z') || d.contains('Z'), "{d}");
690
691 assert_eq!(scale_dasharray("1 2", 1.0), "1 2");
692 assert_eq!(scale_dasharray("1,2 3", 2.0), "2.00 4.00 6.00");
693 assert_eq!(scale_dasharray("1 foo", 2.0), "2.00 foo");
695 }
696
697 #[test]
698 fn scale_uniform_covers_path_circle_text_and_identity() {
699 let path = Primitive::Path {
700 d: "M 10 0 L 20 0".into(),
701 stroke: Some("#111".into()),
702 fill: Some("none".into()),
703 stroke_width: 1.0,
704 opacity: 1.0,
705 stroke_dasharray: Some("2 1".into()),
706 stroke_linecap: Some("round".into()),
707 class: Some("bond".into()),
708 data_text: None,
709 };
710 let circle = Primitive::Circle {
711 cx: 5.0,
712 cy: 6.0,
713 r: 2.0,
714 fill: None,
715 stroke: Some("#f00".into()),
716 stroke_width: 0.5,
717 opacity: 0.7,
718 class: Some("mark".into()),
719 };
720 let text = Primitive::Text {
721 x: 1.0,
722 y: 2.0,
723 text: "C".into(),
724 fill: "#000".into(),
725 font_size: 12.0,
726 anchor: TextAnchor::Middle,
727 class: None,
728 };
729
730 assert_eq!(path.scale_uniform(1.0), path);
732 assert_eq!(circle.scale_uniform(1.0), circle);
733 assert_eq!(text.scale_uniform(1.0), text);
734
735 match path.scale_uniform(2.0) {
736 Primitive::Path {
737 d,
738 stroke_width,
739 stroke_dasharray,
740 ..
741 } => {
742 assert!(d.contains("20.00") && d.contains("40.00"), "{d}");
743 assert!((stroke_width - 2.0).abs() < 1e-12);
744 assert_eq!(stroke_dasharray.as_deref(), Some("4.00 2.00"));
745 }
746 _ => panic!("path"),
747 }
748 match circle.scale_uniform(2.0) {
749 Primitive::Circle {
750 cx,
751 cy,
752 r,
753 stroke_width,
754 ..
755 } => {
756 assert!((cx - 10.0).abs() < 1e-12);
757 assert!((cy - 12.0).abs() < 1e-12);
758 assert!((r - 4.0).abs() < 1e-12);
759 assert!((stroke_width - 1.0).abs() < 1e-12);
760 }
761 _ => panic!("circle"),
762 }
763 match text.scale_uniform(2.0) {
764 Primitive::Text {
765 x,
766 y,
767 font_size,
768 ..
769 } => {
770 assert!((x - 2.0).abs() < 1e-12);
771 assert!((y - 4.0).abs() < 1e-12);
772 assert!((font_size - 24.0).abs() < 1e-12);
773 }
774 _ => panic!("text"),
775 }
776
777 let vp = Viewport {
778 id: Some("m0".into()),
779 x: 1.0,
780 y: 2.0,
781 width: 10.0,
782 height: 20.0,
783 layers: vec![Layer {
784 name: LayerName::Marks,
785 primitives: vec![circle.clone()],
786 }],
787 };
788 assert_eq!(vp.scale_uniform(1.0), vp);
789 let vp2 = vp.scale_uniform(2.0);
790 assert!((vp2.x - 2.0).abs() < 1e-12);
791 assert!((vp2.width - 20.0).abs() < 1e-12);
792
793 let scene = Scene {
794 width: 10.0,
795 height: 20.0,
796 viewports: vec![vp],
797 overlays: vec![text],
798 halo: vec![path],
799 };
800 assert_eq!(scene.scale_uniform(1.0), scene);
801 let s2 = scene.scale_uniform(2.0);
802 assert!((s2.width - 20.0).abs() < 1e-12);
803 assert!((s2.height - 40.0).abs() < 1e-12);
804 assert_eq!(s2.overlays.len(), 1);
805 assert_eq!(s2.halo.len(), 1);
806 }
807}