Skip to main content

xpict_core/
scene.rs

1//! Drawable scene document — engine-neutral primitives shared by Python & JS.
2//!
3//! **MVP:** one molecule → one [`Viewport`] inside a [`Scene`]. Marks (atom/bond
4//! circles) and shade disks are layers on that viewport. Multi-mol diagrams /
5//! ELK are out of scope for the first shared paint path.
6//!
7//! 1. Language edge supplies **coords + chem metadata** (RDKit/Indigo/native
8//!    layout + align stay outside this crate).
9//! 2. Rust builds a [`Scene`] of typed primitives (paths, circles, …).
10//! 3. Thin Python / JS serializers emit SVG / data-URI `<img>`.
11//!
12//! Do not invent new depiction rules here — port proven Python behavior.
13
14use serde::{Deserialize, Serialize};
15
16#[cfg(feature = "codegen")]
17use schemars::JsonSchema;
18#[cfg(feature = "codegen")]
19use ts_rs::TS;
20
21/// Horizontal text anchor (SVG `text-anchor`).
22#[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/// Layer name inside a molecule viewport.
33#[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/// One drawable primitive in SVG space (+Y down).
47#[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        /// Plain label string for ``data-text`` (glyph paths only).
74        #[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    /// Logical text; serializers outline to glyph paths (Liberation) or emit
97    /// `<text>` when the host prefers.
98    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/// One molecule viewport in a diagram (after ELK/grid placement).
140#[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/// Full drawable document before SVG/HTML serialization.
158///
159/// Python `contracts.scene.Scene` and JS consumers should mirror this shape
160/// (JSON round-trip is the ABI).
161#[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    /// Document-space primitives (reaction arrows) above viewports.
169    #[serde(default)]
170    pub overlays: Vec<Primitive>,
171    /// Single document-space unioned knockout under molecule ink.
172    #[serde(default)]
173    pub halo: Vec<Primitive>,
174}
175
176/// Atom input for a molecule depict call (SVG-space coords from the caller).
177///
178/// Identity: pass ``element`` and/or atomic number ``z``. If only ``z`` is
179/// set, the symbol comes from [`crate::element_symbol`].
180#[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    /// Atomic number (`0` = ``*``). Used when ``element`` is omitted.
189    #[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    /// Resolved element symbol (`element` if set, else from ``z``, else ``"C"``).
203    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/// Bond input (indices into [`AtomIn::index`]).
217#[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    /// Unit normal toward ring interior (ring doubles); omit for centered.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    #[cfg_attr(feature = "codegen", ts(optional))]
231    pub interior: Option<(f64, f64)>,
232}
233
234/// One molecule ready to paint (coords already in SVG / ``SCALE`` space).
235///
236/// MVP extras: optional per-atom / per-bond shade scores and mark indices.
237#[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    /// Ink color for backbone bonds and atom labels (CSS). Default ``#111``.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    #[cfg_attr(feature = "codegen", ts(optional))]
249    pub color: Option<String>,
250    /// Per-atom shade scores (same order as [`Self::atoms`]); omit if unshaded.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    #[cfg_attr(feature = "codegen", ts(optional))]
253    pub atom_shade: Option<Vec<f64>>,
254    /// Per-bond shade scores (same order as [`Self::bonds`]).
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    #[cfg_attr(feature = "codegen", ts(optional))]
257    pub bond_shade: Option<Vec<f64>>,
258    /// Shade colormap window low (default ``0``). Not inferred from data.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    #[cfg_attr(feature = "codegen", ts(optional))]
261    pub shade_vmin: Option<f64>,
262    /// Shade colormap window high (default ``1``). Not inferred from data.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    #[cfg_attr(feature = "codegen", ts(optional))]
265    pub shade_vmax: Option<f64>,
266    /// Atom indices to circle (publication marks).
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub mark_atoms: Vec<i32>,
269    /// Bond endpoint index pairs to circle/stroke-mark.
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub mark_bonds: Vec<(i32, i32)>,
272    /// Uniform diagram scale (font, stroke, pad, geometry). ``1.0`` = house size.
273    #[serde(default = "default_mol_scale", skip_serializing_if = "is_default_mol_scale")]
274    pub scale: f64,
275    /// Ink weight for backbone stroke and label glyph thicken. ``1.0`` = house;
276    /// may go down to ~``2/3`` (Regular stem); typical thicken up to ~2.
277    #[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    /// Effective diagram scale; non-positive values fall back to ``1.0``.
299    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    /// Absolute ink multiplier for this mol (user ``weight`` × house base).
308    ///
309    /// # Panics
310    /// Panics if [`Self::weight`] is invalid (see [`crate::metrics::diagram_weight`]).
311    pub fn diagram_weight(&self) -> f64 {
312        crate::metrics::diagram_weight(self.weight)
313    }
314}
315
316/// Multiply every numeric token in an SVG path ``d`` (M/L/Z polygons).
317pub 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    /// Uniform scale about the origin (stroke widths and path coords).
377    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    /// Scale the whole diagram about the origin (equiv. to scaled font_px / metrics).
484    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        // serde default fns (stroke_width / opacity / fill / font_size / anchor).
548        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        // Omitted fields → serde defaults (covers default_mol_weight / scale).
650        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        // skip_serializing_if: defaults omitted; non-defaults kept.
670        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        // Signed ints, decimals, scientific notation.
686        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        // Non-numeric token preserved.
694        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        // Identity early-outs.
731        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}