Skip to main content

oxideav_core/
vector.rs

1//! Vector graphics frame and primitive types.
2//!
3//! This module models a resolution-independent, scene-graph-style vector
4//! frame so the same [`VectorFrame`] can round-trip through both SVG 1.1
5//! and PDF 1.4 without lossy conversion. The primitive set is the
6//! intersection of what those two formats represent natively:
7//!
8//! * paths built from move / line / quadratic / cubic / elliptic-arc / close
9//!   commands,
10//! * solid + linear-gradient + radial-gradient paints,
11//! * stroke style (width, cap, join, miter limit, dash),
12//! * even-odd / non-zero fill rules,
13//! * 2D affine transforms,
14//! * group nodes (transform, opacity, optional clip),
15//! * embedded raster passthrough via [`ImageRef`] (carries a child
16//!   [`VideoFrame`](crate::VideoFrame) — the rasterizer paints the image
17//!   into vector space).
18//!
19//! Text nodes are intentionally **deferred to round 2** — text needs
20//! font handling and tight scribe coupling that will land alongside the
21//! `oxideav-svg` parser (#349). Round 1 is shape-only.
22//!
23//! No rasterizer / SVG parser / PDF writer lives in `oxideav-core`; those
24//! are downstream tasks (#349 / #350 / #351). This module ships only the
25//! data types every consumer of the vector pipeline needs to agree on.
26
27use crate::time::TimeBase;
28
29/// A decoded vector-graphics frame.
30///
31/// The `width` / `height` define the natural rendering canvas size in
32/// user units. `view_box` lets a producer separate the user-coordinate
33/// system from the canvas (an SVG `viewBox` attribute, or the PDF
34/// `MediaBox` vs. `CropBox`); when `None`, callers should treat it as
35/// `(0, 0, width, height)`.
36#[derive(Clone, Debug)]
37pub struct VectorFrame {
38    /// Viewport width in user units.
39    pub width: f32,
40    /// Viewport height in user units.
41    pub height: f32,
42    /// Optional view box. `None` defaults to `(0, 0, width, height)`.
43    pub view_box: Option<ViewBox>,
44    /// Root group of the scene.
45    pub root: Group,
46    /// Presentation timestamp in `time_base` units, or `None` if unknown.
47    pub pts: Option<i64>,
48    /// Time base for `pts`. Consumers that don't care about timing
49    /// (e.g. a one-shot SVG render) can use `TimeBase::new(1, 1)`.
50    pub time_base: TimeBase,
51}
52
53/// User-coordinate system rectangle. Mirrors the SVG `viewBox` attribute
54/// and the PDF `MediaBox` / `CropBox` rectangles.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct ViewBox {
57    pub min_x: f32,
58    pub min_y: f32,
59    pub width: f32,
60    pub height: f32,
61}
62
63/// One node in the scene tree.
64///
65/// Marked `#[non_exhaustive]` so future variants (text, filters) can
66/// be added without breaking downstream `match` arms.
67#[derive(Clone, Debug)]
68#[non_exhaustive]
69pub enum Node {
70    Path(PathNode),
71    Group(Group),
72    /// An embedded raster image painted into vector space.
73    Image(ImageRef),
74    /// A soft-mask composite. The `mask` subtree is rasterised and
75    /// converted to a per-pixel alpha multiplier (luminance or alpha,
76    /// per [`MaskKind`]), then applied to the rasterised `content`
77    /// subtree. Mirrors SVG `<mask>` and PDF `SMask` (subtype `Luminosity`
78    /// vs. `Alpha`).
79    SoftMask {
80        /// Subtree rasterised to produce the per-pixel opacity
81        /// modulator.
82        mask: Box<Node>,
83        /// How to convert the rasterised mask to a coverage value.
84        mask_kind: MaskKind,
85        /// Subtree whose pixels are modulated by the mask.
86        content: Box<Node>,
87    },
88}
89
90/// How to interpret a soft mask's rasterised pixels as a coverage
91/// modulator.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum MaskKind {
94    /// Convert the mask's RGB to luminance (ITU-R BT.709 coefficients
95    /// — Y = 0.2126·R + 0.7152·G + 0.0722·B) and use Y as the
96    /// per-pixel alpha multiplier. Matches SVG `<mask>` default
97    /// (`mask-type="luminance"`) and PDF `SMask` `/Luminosity`.
98    #[default]
99    Luminance,
100    /// Use the mask's own alpha channel as the multiplier. Matches
101    /// SVG `<mask mask-type="alpha">` and PDF `SMask` `/Alpha`.
102    Alpha,
103}
104
105/// A grouping node — applies a transform / opacity / optional clip path
106/// to all descendants. Mirrors SVG `<g>` and PDF `q ... Q` graphic-state
107/// blocks.
108#[derive(Clone, Debug)]
109pub struct Group {
110    /// Coordinate transform applied to children. Identity by default.
111    pub transform: Transform2D,
112    /// Group opacity in `0.0..=1.0`. `1.0` is fully opaque.
113    pub opacity: f32,
114    /// Optional clip path. Children are clipped to this path's interior
115    /// (using the path's own fill rule). `None` means "no clip".
116    pub clip: Option<Path>,
117    pub children: Vec<Node>,
118    /// Opaque cache key. When `Some(k)`, a downstream rasterizer is free
119    /// to memoise the rendered bitmap of this group's content (after
120    /// `transform` is applied) under key `k`, so re-rendering the same
121    /// group at the same effective resolution returns the cached bitmap.
122    ///
123    /// Producers that emit cacheable content (e.g. scribe shaping a
124    /// glyph at `(face_id, glyph_id, size_q8, subpixel_x)`) compute a
125    /// deterministic hash of their identity tuple and put it here. The
126    /// rasterizer treats it as a black box — `oxideav-core` never
127    /// inspects the value, so each producer's namespace stays private.
128    ///
129    /// `None` (the default) means "do not cache; render fresh every
130    /// time". Most synthesised vector content (a one-off rectangle, a
131    /// gradient panel) leaves this `None`.
132    pub cache_key: Option<u64>,
133}
134
135impl Default for Group {
136    fn default() -> Self {
137        Self {
138            transform: Transform2D::identity(),
139            opacity: 1.0,
140            clip: None,
141            children: Vec::new(),
142            cache_key: None,
143        }
144    }
145}
146
147/// A drawn path with optional fill and stroke.
148///
149/// SVG `<path>` and PDF path-painting operators (`f`, `S`, `B`, `f*`,
150/// `B*`) both express "one path, optional fill, optional stroke", so a
151/// single struct covers both formats. At least one of `fill` / `stroke`
152/// would normally be `Some` to produce visible output.
153#[derive(Clone, Debug)]
154pub struct PathNode {
155    pub path: Path,
156    pub fill: Option<Paint>,
157    pub stroke: Option<Stroke>,
158    pub fill_rule: FillRule,
159}
160
161/// A geometric path expressed as a sequence of drawing commands.
162///
163/// All coordinates are in the local user space of the enclosing group.
164#[derive(Clone, Debug, Default)]
165pub struct Path {
166    pub commands: Vec<PathCommand>,
167}
168
169impl Path {
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    pub fn move_to(&mut self, p: Point) -> &mut Self {
175        self.commands.push(PathCommand::MoveTo(p));
176        self
177    }
178
179    pub fn line_to(&mut self, p: Point) -> &mut Self {
180        self.commands.push(PathCommand::LineTo(p));
181        self
182    }
183
184    pub fn quad_to(&mut self, control: Point, end: Point) -> &mut Self {
185        self.commands
186            .push(PathCommand::QuadCurveTo { control, end });
187        self
188    }
189
190    pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
191        self.commands
192            .push(PathCommand::CubicCurveTo { c1, c2, end });
193        self
194    }
195
196    pub fn close(&mut self) -> &mut Self {
197        self.commands.push(PathCommand::Close);
198        self
199    }
200}
201
202/// A single path-construction command.
203///
204/// Marked `#[non_exhaustive]` so smooth-curve / Bezier-shorthand
205/// variants can be added later without breaking match arms.
206///
207/// Note on `ArcTo`: SVG and PDF both accept elliptic-arc segments in
208/// their path syntax (SVG `A` command, PDF via cubic approximation in
209/// the writer). We keep the variant in the round-1 IR — converting an
210/// arc to its spec-correct cubic-Bezier flattening is a pure function
211/// of the arc parameters that downstream rasterizers / writers can do
212/// independently.
213#[derive(Clone, Copy, Debug, PartialEq)]
214#[non_exhaustive]
215pub enum PathCommand {
216    MoveTo(Point),
217    LineTo(Point),
218    QuadCurveTo {
219        control: Point,
220        end: Point,
221    },
222    CubicCurveTo {
223        c1: Point,
224        c2: Point,
225        end: Point,
226    },
227    /// SVG `A`-style elliptic arc segment. `x_axis_rot` is in radians
228    /// (consistent with `Transform2D::rotate`); `large_arc` / `sweep`
229    /// match the SVG flag semantics.
230    ArcTo {
231        rx: f32,
232        ry: f32,
233        x_axis_rot: f32,
234        large_arc: bool,
235        sweep: bool,
236        end: Point,
237    },
238    Close,
239}
240
241/// 2D point in user-space coordinates.
242#[derive(Clone, Copy, Debug, Default, PartialEq)]
243pub struct Point {
244    pub x: f32,
245    pub y: f32,
246}
247
248impl Point {
249    pub const fn new(x: f32, y: f32) -> Self {
250        Self { x, y }
251    }
252}
253
254/// A paint server — what fills the inside of a path or strokes its
255/// outline. The variant set is the SVG/PDF intersection.
256#[derive(Clone, Debug)]
257#[non_exhaustive]
258pub enum Paint {
259    Solid(Rgba),
260    LinearGradient(LinearGradient),
261    RadialGradient(RadialGradient),
262}
263
264/// 32-bit straight (non-premultiplied) RGBA color.
265///
266/// Matches SVG's `rgb()` + `opacity` model and PDF's `RGB` + `CA`/`ca`
267/// graphic-state model. Premultiplication is a rasterizer concern; this
268/// IR carries straight alpha to avoid lossy round-trips.
269#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
270pub struct Rgba {
271    pub r: u8,
272    pub g: u8,
273    pub b: u8,
274    pub a: u8,
275}
276
277impl Rgba {
278    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
279        Self { r, g, b, a }
280    }
281
282    /// Fully-opaque color with the given RGB triple.
283    pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
284        Self { r, g, b, a: 255 }
285    }
286}
287
288/// A linear gradient: color stops sweep along the line `start` → `end`.
289#[derive(Clone, Debug)]
290pub struct LinearGradient {
291    pub start: Point,
292    pub end: Point,
293    pub stops: Vec<GradientStop>,
294    pub spread: SpreadMethod,
295}
296
297/// A radial gradient: color stops sweep from `focal` outward to a
298/// circle of radius `radius` centered on `center`. When `focal` is
299/// `None`, it defaults to `center` (the common case).
300#[derive(Clone, Debug)]
301pub struct RadialGradient {
302    pub center: Point,
303    pub radius: f32,
304    pub focal: Option<Point>,
305    pub stops: Vec<GradientStop>,
306    pub spread: SpreadMethod,
307}
308
309/// One color stop along a gradient. `offset` is in `0.0..=1.0`.
310#[derive(Clone, Copy, Debug, PartialEq)]
311pub struct GradientStop {
312    /// Position of the stop along the gradient axis. `0.0` is the
313    /// start, `1.0` is the end.
314    pub offset: f32,
315    pub color: Rgba,
316}
317
318impl GradientStop {
319    pub const fn new(offset: f32, color: Rgba) -> Self {
320        Self { offset, color }
321    }
322}
323
324/// What happens past the gradient endpoints. Mirrors SVG
325/// `spreadMethod="pad|reflect|repeat"` and PDF gradient `Extend` arrays.
326#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
327pub enum SpreadMethod {
328    /// Final stop colors extend forever. SVG default.
329    #[default]
330    Pad,
331    /// Gradient mirrors at each boundary.
332    Reflect,
333    /// Gradient repeats periodically.
334    Repeat,
335}
336
337/// Stroke style for a path's outline.
338#[derive(Clone, Debug)]
339pub struct Stroke {
340    pub width: f32,
341    pub paint: Paint,
342    pub cap: LineCap,
343    pub join: LineJoin,
344    /// Miter limit ratio. SVG / PDF default is `4.0`.
345    pub miter_limit: f32,
346    pub dash: Option<DashPattern>,
347}
348
349impl Stroke {
350    /// Build a default solid-paint stroke with width `width`.
351    pub fn solid(width: f32, color: Rgba) -> Self {
352        Self {
353            width,
354            paint: Paint::Solid(color),
355            cap: LineCap::Butt,
356            join: LineJoin::Miter,
357            miter_limit: 4.0,
358            dash: None,
359        }
360    }
361}
362
363/// How an open path's endpoints are drawn.
364#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
365pub enum LineCap {
366    #[default]
367    Butt,
368    Round,
369    Square,
370}
371
372/// How two stroke segments meet at a corner.
373#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
374pub enum LineJoin {
375    #[default]
376    Miter,
377    Round,
378    Bevel,
379}
380
381/// Dash pattern for a stroke. `array` is an alternating
382/// dash-on / dash-off length list (in user units); `offset` is the
383/// phase offset from the path start.
384#[derive(Clone, Debug, Default)]
385pub struct DashPattern {
386    pub array: Vec<f32>,
387    pub offset: f32,
388}
389
390/// Fill rule for self-intersecting and compound paths. Matches SVG's
391/// `fill-rule` attribute and PDF's `f` (non-zero) vs. `f*` (even-odd)
392/// painting operators.
393#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
394pub enum FillRule {
395    #[default]
396    NonZero,
397    EvenOdd,
398}
399
400/// A 2D affine transform stored as the column-major matrix
401///
402/// ```text
403/// | a c e |   | x |
404/// | b d f | * | y |
405/// | 0 0 1 |   | 1 |
406/// ```
407///
408/// — i.e. `(x', y') = (a*x + c*y + e, b*x + d*y + f)`. The layout
409/// matches SVG's `matrix(a, b, c, d, e, f)` and PDF's `cm` operator
410/// argument order, so emitters can serialize fields directly.
411#[derive(Clone, Copy, Debug, PartialEq)]
412pub struct Transform2D {
413    pub a: f32,
414    pub b: f32,
415    pub c: f32,
416    pub d: f32,
417    pub e: f32,
418    pub f: f32,
419}
420
421impl Transform2D {
422    /// The identity transform. `compose(identity, x) == x`.
423    pub const fn identity() -> Self {
424        Self {
425            a: 1.0,
426            b: 0.0,
427            c: 0.0,
428            d: 1.0,
429            e: 0.0,
430            f: 0.0,
431        }
432    }
433
434    /// Build a translation by `(tx, ty)`.
435    pub const fn translate(tx: f32, ty: f32) -> Self {
436        Self {
437            a: 1.0,
438            b: 0.0,
439            c: 0.0,
440            d: 1.0,
441            e: tx,
442            f: ty,
443        }
444    }
445
446    /// Build a non-uniform scale by `(sx, sy)` about the origin.
447    pub const fn scale(sx: f32, sy: f32) -> Self {
448        Self {
449            a: sx,
450            b: 0.0,
451            c: 0.0,
452            d: sy,
453            e: 0.0,
454            f: 0.0,
455        }
456    }
457
458    /// Build a rotation by `angle_radians` about the origin
459    /// (counter-clockwise in a Y-up system, clockwise visually under
460    /// the SVG / PDF Y-down convention — this matches both formats).
461    pub fn rotate(angle_radians: f32) -> Self {
462        let (s, c) = angle_radians.sin_cos();
463        Self {
464            a: c,
465            b: s,
466            c: -s,
467            d: c,
468            e: 0.0,
469            f: 0.0,
470        }
471    }
472
473    /// Build a horizontal skew (shear along X) by `angle_radians`.
474    pub fn skew_x(angle_radians: f32) -> Self {
475        Self {
476            a: 1.0,
477            b: 0.0,
478            c: angle_radians.tan(),
479            d: 1.0,
480            e: 0.0,
481            f: 0.0,
482        }
483    }
484
485    /// Build a vertical skew (shear along Y) by `angle_radians`.
486    pub fn skew_y(angle_radians: f32) -> Self {
487        Self {
488            a: 1.0,
489            b: angle_radians.tan(),
490            c: 0.0,
491            d: 1.0,
492            e: 0.0,
493            f: 0.0,
494        }
495    }
496
497    /// Compose `self ∘ other` — the resulting transform applies
498    /// `other` first, then `self`, to a point. Equivalent to
499    /// `self.matrix() * other.matrix()` in column-vector form.
500    pub fn compose(&self, other: &Self) -> Self {
501        Self {
502            a: self.a * other.a + self.c * other.b,
503            b: self.b * other.a + self.d * other.b,
504            c: self.a * other.c + self.c * other.d,
505            d: self.b * other.c + self.d * other.d,
506            e: self.a * other.e + self.c * other.f + self.e,
507            f: self.b * other.e + self.d * other.f + self.f,
508        }
509    }
510
511    /// Apply this transform to a point.
512    pub fn apply(&self, p: Point) -> Point {
513        Point {
514            x: self.a * p.x + self.c * p.y + self.e,
515            y: self.b * p.x + self.d * p.y + self.f,
516        }
517    }
518
519    /// `true` when this transform is bit-identical to the identity.
520    /// Useful for emitters that want to skip a no-op `matrix(...)` /
521    /// `cm` write.
522    pub fn is_identity(&self) -> bool {
523        *self == Self::identity()
524    }
525}
526
527impl Default for Transform2D {
528    fn default() -> Self {
529        Self::identity()
530    }
531}
532
533/// An embedded raster image painted into vector space.
534///
535/// `bounds` is the axis-aligned rectangle (in the local user space,
536/// before `transform`) that the image is painted into; SVG `<image>`
537/// `x/y/width/height` and PDF `Do` with a matrix-pre-positioned
538/// `Image` XObject both reduce to this shape.
539#[derive(Clone, Debug)]
540pub struct ImageRef {
541    /// Embedded raster payload. Boxed so a `Node::Image` variant
542    /// doesn't bloat every other [`Node`] case.
543    pub frame: Box<crate::VideoFrame>,
544    pub bounds: Rect,
545    pub transform: Transform2D,
546}
547
548/// Axis-aligned rectangle in user-space coordinates.
549#[derive(Clone, Copy, Debug, Default, PartialEq)]
550pub struct Rect {
551    pub x: f32,
552    pub y: f32,
553    pub width: f32,
554    pub height: f32,
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::time::TimeBase;
561
562    fn approx_point(a: Point, b: Point) -> bool {
563        (a.x - b.x).abs() < 1e-5 && (a.y - b.y).abs() < 1e-5
564    }
565
566    #[test]
567    fn path_builder_produces_command_sequence() {
568        let mut p = Path::new();
569        p.move_to(Point::new(0.0, 0.0))
570            .line_to(Point::new(10.0, 0.0))
571            .quad_to(Point::new(15.0, 5.0), Point::new(10.0, 10.0))
572            .cubic_to(
573                Point::new(5.0, 15.0),
574                Point::new(0.0, 10.0),
575                Point::new(0.0, 0.0),
576            )
577            .close();
578        assert_eq!(p.commands.len(), 5);
579        assert_eq!(p.commands[0], PathCommand::MoveTo(Point::new(0.0, 0.0)));
580        assert_eq!(p.commands[4], PathCommand::Close);
581    }
582
583    #[test]
584    fn transform_identity_round_trips() {
585        let id = Transform2D::identity();
586        assert!(id.is_identity());
587        let p = Point::new(3.5, -2.25);
588        assert_eq!(id.apply(p), p);
589    }
590
591    #[test]
592    fn transform_translate_round_trip() {
593        let t = Transform2D::translate(10.0, -5.0);
594        assert_eq!(t.apply(Point::new(0.0, 0.0)), Point::new(10.0, -5.0));
595        assert_eq!(t.apply(Point::new(1.0, 1.0)), Point::new(11.0, -4.0));
596    }
597
598    #[test]
599    fn transform_scale_round_trip() {
600        let s = Transform2D::scale(2.0, 3.0);
601        assert_eq!(s.apply(Point::new(1.0, 1.0)), Point::new(2.0, 3.0));
602        assert_eq!(s.apply(Point::new(0.0, 0.0)), Point::new(0.0, 0.0));
603    }
604
605    #[test]
606    fn transform_rotate_quarter_turn() {
607        let r = Transform2D::rotate(std::f32::consts::FRAC_PI_2);
608        // Under SVG/PDF Y-down with matrix(c,s,-s,c,0,0):
609        // (1, 0) rotates to (cos, sin) = (0, 1).
610        assert!(approx_point(
611            r.apply(Point::new(1.0, 0.0)),
612            Point::new(0.0, 1.0)
613        ));
614        // (0, 1) rotates to (-sin, cos) = (-1, 0).
615        assert!(approx_point(
616            r.apply(Point::new(0.0, 1.0)),
617            Point::new(-1.0, 0.0)
618        ));
619    }
620
621    #[test]
622    fn transform_compose_identity_is_left_and_right_unit() {
623        let t = Transform2D::translate(7.0, 11.0);
624        let id = Transform2D::identity();
625        assert_eq!(id.compose(&t), t);
626        assert_eq!(t.compose(&id), t);
627    }
628
629    #[test]
630    fn transform_compose_translate_then_scale() {
631        // Apply translate(2,3) first, then scale(10,10):
632        //   p -> p + (2,3) -> 10*(p+(2,3)) = 10p + (20,30).
633        let scale = Transform2D::scale(10.0, 10.0);
634        let translate = Transform2D::translate(2.0, 3.0);
635        let composed = scale.compose(&translate);
636        let result = composed.apply(Point::new(1.0, 1.0));
637        assert!(approx_point(result, Point::new(30.0, 40.0)));
638    }
639
640    #[test]
641    fn transform_compose_matches_sequential_apply() {
642        // Composition equivalence: composed.apply(p) == a.apply(b.apply(p)).
643        let a = Transform2D::rotate(0.5);
644        let b = Transform2D::translate(3.0, -1.0);
645        let composed = a.compose(&b);
646        let p = Point::new(2.0, 5.0);
647        let direct = composed.apply(p);
648        let stepwise = a.apply(b.apply(p));
649        assert!(approx_point(direct, stepwise));
650    }
651
652    #[test]
653    fn group_default_is_identity_opacity_one_no_clip() {
654        let g = Group::default();
655        assert!(g.transform.is_identity());
656        assert_eq!(g.opacity, 1.0);
657        assert!(g.clip.is_none());
658        assert!(g.children.is_empty());
659    }
660
661    #[test]
662    fn group_nesting_with_transforms() {
663        // Outer group translates by (10, 10); inner group scales by 2.
664        // A point (1, 1) drawn at the inner level should land at
665        // (12, 12) after the outer transform is also applied — but the
666        // tree itself only stores the local transforms. This test
667        // pins down that the nested data is preserved verbatim, since
668        // composing transforms is a rasterizer responsibility.
669        let inner = Group {
670            transform: Transform2D::scale(2.0, 2.0),
671            children: vec![Node::Path(PathNode {
672                path: {
673                    let mut p = Path::new();
674                    p.move_to(Point::new(1.0, 1.0));
675                    p
676                },
677                fill: Some(Paint::Solid(Rgba::opaque(255, 0, 0))),
678                stroke: None,
679                fill_rule: FillRule::NonZero,
680            })],
681            ..Group::default()
682        };
683        let outer = Group {
684            transform: Transform2D::translate(10.0, 10.0),
685            children: vec![Node::Group(inner)],
686            ..Group::default()
687        };
688        match &outer.children[0] {
689            Node::Group(g) => {
690                assert_eq!(g.transform, Transform2D::scale(2.0, 2.0));
691                assert_eq!(g.children.len(), 1);
692            }
693            _ => panic!("expected a Group child"),
694        }
695        assert_eq!(outer.transform, Transform2D::translate(10.0, 10.0));
696    }
697
698    #[test]
699    fn vector_frame_construction() {
700        let frame = VectorFrame {
701            width: 100.0,
702            height: 50.0,
703            view_box: Some(ViewBox {
704                min_x: 0.0,
705                min_y: 0.0,
706                width: 100.0,
707                height: 50.0,
708            }),
709            root: Group::default(),
710            pts: Some(0),
711            time_base: TimeBase::new(1, 1000),
712        };
713        assert_eq!(frame.width, 100.0);
714        assert_eq!(frame.height, 50.0);
715        assert!(frame.view_box.is_some());
716        assert_eq!(frame.pts, Some(0));
717    }
718
719    #[test]
720    fn rgba_constructors() {
721        let c = Rgba::opaque(10, 20, 30);
722        assert_eq!(c.a, 255);
723        let c2 = Rgba::new(10, 20, 30, 128);
724        assert_eq!(c2.a, 128);
725    }
726
727    #[test]
728    fn gradient_stop_round_trips() {
729        let s = GradientStop::new(0.5, Rgba::opaque(255, 0, 0));
730        assert_eq!(s.offset, 0.5);
731        let s2 = GradientStop::new(0.5, Rgba::opaque(255, 0, 0));
732        assert_eq!(s, s2);
733    }
734
735    #[test]
736    fn stroke_solid_defaults() {
737        let s = Stroke::solid(2.0, Rgba::opaque(0, 0, 0));
738        assert_eq!(s.width, 2.0);
739        assert_eq!(s.cap, LineCap::Butt);
740        assert_eq!(s.join, LineJoin::Miter);
741        assert_eq!(s.miter_limit, 4.0);
742        assert!(s.dash.is_none());
743    }
744
745    #[test]
746    fn soft_mask_construction_and_inspection() {
747        // Wrap a path in a SoftMask node with a luminance mask. Round-
748        // trips both children verbatim through clone + match.
749        fn rect_path() -> PathNode {
750            let mut p = Path::new();
751            p.move_to(Point::new(0.0, 0.0))
752                .line_to(Point::new(10.0, 0.0))
753                .line_to(Point::new(10.0, 10.0))
754                .line_to(Point::new(0.0, 10.0))
755                .close();
756            PathNode {
757                path: p,
758                fill: Some(Paint::Solid(Rgba::opaque(255, 255, 255))),
759                stroke: None,
760                fill_rule: FillRule::NonZero,
761            }
762        }
763        let n = Node::SoftMask {
764            mask: Box::new(Node::Path(rect_path())),
765            mask_kind: MaskKind::Luminance,
766            content: Box::new(Node::Path(rect_path())),
767        };
768        match &n {
769            Node::SoftMask {
770                mask_kind, content, ..
771            } => {
772                assert_eq!(*mask_kind, MaskKind::Luminance);
773                match content.as_ref() {
774                    Node::Path(_) => {}
775                    _ => panic!("expected Path content"),
776                }
777            }
778            _ => panic!("expected SoftMask"),
779        }
780    }
781
782    #[test]
783    fn mask_kind_default_is_luminance() {
784        assert_eq!(MaskKind::default(), MaskKind::Luminance);
785    }
786}