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
53impl VectorFrame {
54    /// Build a `VectorFrame` of the given canvas size with an empty root
55    /// group, no view box, no timestamp, and a `1/1` time base.
56    pub fn new(width: f32, height: f32) -> Self {
57        Self {
58            width,
59            height,
60            view_box: None,
61            root: Group::default(),
62            pts: None,
63            time_base: TimeBase::new(1, 1),
64        }
65    }
66
67    /// Replace the view box.
68    pub fn with_view_box(mut self, view_box: ViewBox) -> Self {
69        self.view_box = Some(view_box);
70        self
71    }
72
73    /// Replace the root group.
74    pub fn with_root(mut self, root: Group) -> Self {
75        self.root = root;
76        self
77    }
78
79    /// Set the presentation timestamp (in `time_base` units).
80    pub fn with_pts(mut self, pts: i64) -> Self {
81        self.pts = Some(pts);
82        self
83    }
84
85    /// Replace the time base.
86    pub fn with_time_base(mut self, time_base: TimeBase) -> Self {
87        self.time_base = time_base;
88        self
89    }
90}
91
92impl Default for VectorFrame {
93    /// An empty 0×0 frame with an empty root group, no view box, no
94    /// timestamp, and a `1/1` time base. Useful as a starting point for
95    /// builder-style construction or as a placeholder in
96    /// `std::mem::take`-style swaps.
97    fn default() -> Self {
98        Self::new(0.0, 0.0)
99    }
100}
101
102/// User-coordinate system rectangle. Mirrors the SVG `viewBox` attribute
103/// and the PDF `MediaBox` / `CropBox` rectangles.
104#[derive(Clone, Copy, Debug, PartialEq)]
105pub struct ViewBox {
106    /// Left edge of the user-coordinate rectangle.
107    pub min_x: f32,
108    /// Top edge of the user-coordinate rectangle.
109    pub min_y: f32,
110    /// Width of the user-coordinate rectangle, in user units.
111    pub width: f32,
112    /// Height of the user-coordinate rectangle, in user units.
113    pub height: f32,
114}
115
116impl ViewBox {
117    /// Build a `ViewBox` from its origin and size.
118    pub const fn new(min_x: f32, min_y: f32, width: f32, height: f32) -> Self {
119        Self {
120            min_x,
121            min_y,
122            width,
123            height,
124        }
125    }
126}
127
128/// One node in the scene tree.
129///
130/// Marked `#[non_exhaustive]` so future variants (text, filters) can
131/// be added without breaking downstream `match` arms.
132#[derive(Clone, Debug)]
133#[non_exhaustive]
134pub enum Node {
135    /// A drawn path with optional fill and stroke.
136    Path(PathNode),
137    /// A nested group applying transform / opacity / clip to its children.
138    Group(Group),
139    /// An embedded raster image painted into vector space.
140    Image(ImageRef),
141    /// A soft-mask composite. The `mask` subtree is rasterised and
142    /// converted to a per-pixel alpha multiplier (luminance or alpha,
143    /// per [`MaskKind`]), then applied to the rasterised `content`
144    /// subtree. Mirrors SVG `<mask>` and PDF `SMask` (subtype `Luminosity`
145    /// vs. `Alpha`).
146    SoftMask {
147        /// Subtree rasterised to produce the per-pixel opacity
148        /// modulator.
149        mask: Box<Node>,
150        /// How to convert the rasterised mask to a coverage value.
151        mask_kind: MaskKind,
152        /// Subtree whose pixels are modulated by the mask.
153        content: Box<Node>,
154    },
155}
156
157/// How to interpret a soft mask's rasterised pixels as a coverage
158/// modulator.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub enum MaskKind {
161    /// Convert the mask's RGB to luminance (ITU-R BT.709 coefficients
162    /// — Y = 0.2126·R + 0.7152·G + 0.0722·B) and use Y as the
163    /// per-pixel alpha multiplier. Matches SVG `<mask>` default
164    /// (`mask-type="luminance"`) and PDF `SMask` `/Luminosity`.
165    #[default]
166    Luminance,
167    /// Use the mask's own alpha channel as the multiplier. Matches
168    /// SVG `<mask mask-type="alpha">` and PDF `SMask` `/Alpha`.
169    Alpha,
170}
171
172/// A grouping node — applies a transform / opacity / optional clip path
173/// to all descendants. Mirrors SVG `<g>` and PDF `q ... Q` graphic-state
174/// blocks.
175#[derive(Clone, Debug)]
176pub struct Group {
177    /// Coordinate transform applied to children. Identity by default.
178    pub transform: Transform2D,
179    /// Group opacity in `0.0..=1.0`. `1.0` is fully opaque.
180    pub opacity: f32,
181    /// Optional clip path. Children are clipped to this path's interior
182    /// (using the path's own fill rule). `None` means "no clip".
183    pub clip: Option<Path>,
184    /// Child nodes, painted in order (later children over earlier ones).
185    pub children: Vec<Node>,
186    /// Opaque cache key. When `Some(k)`, a downstream rasterizer is free
187    /// to memoise the rendered bitmap of this group's content (after
188    /// `transform` is applied) under key `k`, so re-rendering the same
189    /// group at the same effective resolution returns the cached bitmap.
190    ///
191    /// Producers that emit cacheable content (e.g. scribe shaping a
192    /// glyph at `(face_id, glyph_id, size_q8, subpixel_x)`) compute a
193    /// deterministic hash of their identity tuple and put it here. The
194    /// rasterizer treats it as a black box — `oxideav-core` never
195    /// inspects the value, so each producer's namespace stays private.
196    ///
197    /// `None` (the default) means "do not cache; render fresh every
198    /// time". Most synthesised vector content (a one-off rectangle, a
199    /// gradient panel) leaves this `None`.
200    pub cache_key: Option<u64>,
201}
202
203impl Default for Group {
204    fn default() -> Self {
205        Self {
206            transform: Transform2D::identity(),
207            opacity: 1.0,
208            clip: None,
209            children: Vec::new(),
210            cache_key: None,
211        }
212    }
213}
214
215impl Group {
216    /// An empty group: identity transform, opacity `1.0`, no clip, no
217    /// children, no cache key. Same as [`Group::default`].
218    pub fn new() -> Self {
219        Self::default()
220    }
221
222    /// Replace the transform.
223    pub fn with_transform(mut self, transform: Transform2D) -> Self {
224        self.transform = transform;
225        self
226    }
227
228    /// Set the group opacity in `0.0..=1.0`.
229    pub fn with_opacity(mut self, opacity: f32) -> Self {
230        self.opacity = opacity;
231        self
232    }
233
234    /// Set the clip path.
235    pub fn with_clip(mut self, clip: Path) -> Self {
236        self.clip = Some(clip);
237        self
238    }
239
240    /// Append a child node.
241    pub fn with_child(mut self, child: Node) -> Self {
242        self.children.push(child);
243        self
244    }
245
246    /// Replace the children list wholesale.
247    pub fn with_children(mut self, children: Vec<Node>) -> Self {
248        self.children = children;
249        self
250    }
251
252    /// Set the rasterizer cache key. See [`Group::cache_key`].
253    pub fn with_cache_key(mut self, key: u64) -> Self {
254        self.cache_key = Some(key);
255        self
256    }
257}
258
259/// A drawn path with optional fill and stroke.
260///
261/// SVG `<path>` and PDF path-painting operators (`f`, `S`, `B`, `f*`,
262/// `B*`) both express "one path, optional fill, optional stroke", so a
263/// single struct covers both formats. At least one of `fill` / `stroke`
264/// would normally be `Some` to produce visible output.
265#[derive(Clone, Debug)]
266pub struct PathNode {
267    /// Path geometry, in the local user space of the enclosing group.
268    pub path: Path,
269    /// Paint for the path interior. `None` means "not filled".
270    pub fill: Option<Paint>,
271    /// Stroke style for the path outline. `None` means "not stroked".
272    pub stroke: Option<Stroke>,
273    /// Fill rule used for `fill` (and for hit-testing the interior).
274    pub fill_rule: FillRule,
275}
276
277impl PathNode {
278    /// Build a `PathNode` with `path`, no fill, no stroke, and
279    /// `FillRule::NonZero`.
280    pub fn new(path: Path) -> Self {
281        Self {
282            path,
283            fill: None,
284            stroke: None,
285            fill_rule: FillRule::NonZero,
286        }
287    }
288
289    /// Set the fill paint.
290    pub fn with_fill(mut self, fill: Paint) -> Self {
291        self.fill = Some(fill);
292        self
293    }
294
295    /// Set the stroke style.
296    pub fn with_stroke(mut self, stroke: Stroke) -> Self {
297        self.stroke = Some(stroke);
298        self
299    }
300
301    /// Set the fill rule.
302    pub fn with_fill_rule(mut self, fill_rule: FillRule) -> Self {
303        self.fill_rule = fill_rule;
304        self
305    }
306}
307
308/// A geometric path expressed as a sequence of drawing commands.
309///
310/// All coordinates are in the local user space of the enclosing group.
311#[derive(Clone, Debug, Default)]
312pub struct Path {
313    /// Drawing commands, executed in order.
314    pub commands: Vec<PathCommand>,
315}
316
317impl Path {
318    /// An empty path with no commands.
319    pub fn new() -> Self {
320        Self::default()
321    }
322
323    /// Append a [`PathCommand::MoveTo`] — start a new subpath at `p`.
324    pub fn move_to(&mut self, p: Point) -> &mut Self {
325        self.commands.push(PathCommand::MoveTo(p));
326        self
327    }
328
329    /// Append a [`PathCommand::LineTo`] — straight line to `p`.
330    pub fn line_to(&mut self, p: Point) -> &mut Self {
331        self.commands.push(PathCommand::LineTo(p));
332        self
333    }
334
335    /// Append a [`PathCommand::QuadCurveTo`] — quadratic Bezier to `end`
336    /// with control point `control`.
337    pub fn quad_to(&mut self, control: Point, end: Point) -> &mut Self {
338        self.commands
339            .push(PathCommand::QuadCurveTo { control, end });
340        self
341    }
342
343    /// Append a [`PathCommand::CubicCurveTo`] — cubic Bezier to `end`
344    /// with control points `c1` and `c2`.
345    pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
346        self.commands
347            .push(PathCommand::CubicCurveTo { c1, c2, end });
348        self
349    }
350
351    /// Append a [`PathCommand::Close`] — close the current subpath.
352    pub fn close(&mut self) -> &mut Self {
353        self.commands.push(PathCommand::Close);
354        self
355    }
356}
357
358/// A single path-construction command.
359///
360/// Marked `#[non_exhaustive]` so smooth-curve / Bezier-shorthand
361/// variants can be added later without breaking match arms.
362///
363/// Note on `ArcTo`: SVG and PDF both accept elliptic-arc segments in
364/// their path syntax (SVG `A` command, PDF via cubic approximation in
365/// the writer). We keep the variant in the round-1 IR — converting an
366/// arc to its spec-correct cubic-Bezier flattening is a pure function
367/// of the arc parameters that downstream rasterizers / writers can do
368/// independently.
369#[derive(Clone, Copy, Debug, PartialEq)]
370#[non_exhaustive]
371pub enum PathCommand {
372    /// Start a new subpath at the given point (SVG `M`).
373    MoveTo(Point),
374    /// Straight line from the current point to the given point (SVG `L`).
375    LineTo(Point),
376    /// Quadratic Bezier segment from the current point (SVG `Q`).
377    QuadCurveTo {
378        /// The single quadratic control point.
379        control: Point,
380        /// Segment end point.
381        end: Point,
382    },
383    /// Cubic Bezier segment from the current point (SVG `C`).
384    CubicCurveTo {
385        /// First control point (attached to the segment start).
386        c1: Point,
387        /// Second control point (attached to the segment end).
388        c2: Point,
389        /// Segment end point.
390        end: Point,
391    },
392    /// SVG `A`-style elliptic arc segment. `x_axis_rot` is in radians
393    /// (consistent with `Transform2D::rotate`); `large_arc` / `sweep`
394    /// match the SVG flag semantics.
395    ArcTo {
396        /// Ellipse radius along its X axis, in user units.
397        rx: f32,
398        /// Ellipse radius along its Y axis, in user units.
399        ry: f32,
400        /// Rotation of the ellipse's X axis relative to the user-space
401        /// X axis, in radians.
402        x_axis_rot: f32,
403        /// When `true`, pick the arc sweep of 180° or more (SVG
404        /// `large-arc-flag`).
405        large_arc: bool,
406        /// When `true`, draw the arc in the positive-angle direction
407        /// (SVG `sweep-flag`).
408        sweep: bool,
409        /// Arc end point.
410        end: Point,
411    },
412    /// Close the current subpath with a straight line back to its
413    /// starting point (SVG `Z`).
414    Close,
415}
416
417/// 2D point in user-space coordinates.
418#[derive(Clone, Copy, Debug, Default, PartialEq)]
419pub struct Point {
420    /// Horizontal coordinate in user units.
421    pub x: f32,
422    /// Vertical coordinate in user units (Y grows downward, per the
423    /// SVG / PDF device-space convention used throughout this module).
424    pub y: f32,
425}
426
427impl Point {
428    /// Build a point from its coordinates.
429    pub const fn new(x: f32, y: f32) -> Self {
430        Self { x, y }
431    }
432}
433
434impl From<[f32; 2]> for Point {
435    fn from([x, y]: [f32; 2]) -> Self {
436        Self { x, y }
437    }
438}
439
440impl From<(f32, f32)> for Point {
441    fn from((x, y): (f32, f32)) -> Self {
442        Self { x, y }
443    }
444}
445
446/// A paint server — what fills the inside of a path or strokes its
447/// outline. The variant set is the SVG/PDF intersection.
448#[derive(Clone, Debug)]
449#[non_exhaustive]
450pub enum Paint {
451    /// A single flat RGBA color.
452    Solid(Rgba),
453    /// Color stops swept along a straight line.
454    LinearGradient(LinearGradient),
455    /// Color stops swept outward from a focal point to a circle.
456    RadialGradient(RadialGradient),
457}
458
459/// 32-bit straight (non-premultiplied) RGBA color.
460///
461/// Matches SVG's `rgb()` + `opacity` model and PDF's `RGB` + `CA`/`ca`
462/// graphic-state model. Premultiplication is a rasterizer concern; this
463/// IR carries straight alpha to avoid lossy round-trips.
464#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
465pub struct Rgba {
466    /// Red channel, `0..=255`.
467    pub r: u8,
468    /// Green channel, `0..=255`.
469    pub g: u8,
470    /// Blue channel, `0..=255`.
471    pub b: u8,
472    /// Straight (non-premultiplied) alpha, `0` transparent to `255` opaque.
473    pub a: u8,
474}
475
476impl Rgba {
477    /// Build a color from its four channels (straight alpha).
478    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
479        Self { r, g, b, a }
480    }
481
482    /// Fully-opaque color with the given RGB triple.
483    pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
484        Self { r, g, b, a: 255 }
485    }
486}
487
488impl From<(u8, u8, u8, u8)> for Rgba {
489    fn from((r, g, b, a): (u8, u8, u8, u8)) -> Self {
490        Self { r, g, b, a }
491    }
492}
493
494impl From<(u8, u8, u8)> for Rgba {
495    /// Fully-opaque color with the given RGB triple.
496    fn from((r, g, b): (u8, u8, u8)) -> Self {
497        Self { r, g, b, a: 255 }
498    }
499}
500
501impl From<[u8; 4]> for Rgba {
502    fn from([r, g, b, a]: [u8; 4]) -> Self {
503        Self { r, g, b, a }
504    }
505}
506
507impl From<Rgba> for Paint {
508    /// Wrap an [`Rgba`] in a `Paint::Solid`.
509    fn from(color: Rgba) -> Self {
510        Paint::Solid(color)
511    }
512}
513
514/// A linear gradient: color stops sweep along the line `start` → `end`.
515#[derive(Clone, Debug)]
516pub struct LinearGradient {
517    /// Gradient axis start point (offset `0.0`), in user space.
518    pub start: Point,
519    /// Gradient axis end point (offset `1.0`), in user space.
520    pub end: Point,
521    /// Color stops, ordered by ascending `offset`.
522    pub stops: Vec<GradientStop>,
523    /// What to paint past the axis endpoints.
524    pub spread: SpreadMethod,
525}
526
527impl LinearGradient {
528    /// Build a `LinearGradient` from `start` → `end` with no stops and
529    /// `SpreadMethod::Pad`.
530    pub fn new(start: Point, end: Point) -> Self {
531        Self {
532            start,
533            end,
534            stops: Vec::new(),
535            spread: SpreadMethod::Pad,
536        }
537    }
538
539    /// Replace the gradient stops.
540    pub fn with_stops(mut self, stops: Vec<GradientStop>) -> Self {
541        self.stops = stops;
542        self
543    }
544
545    /// Append a single stop.
546    pub fn with_stop(mut self, stop: GradientStop) -> Self {
547        self.stops.push(stop);
548        self
549    }
550
551    /// Set the spread method.
552    pub fn with_spread(mut self, spread: SpreadMethod) -> Self {
553        self.spread = spread;
554        self
555    }
556}
557
558/// A radial gradient: color stops sweep from `focal` outward to a
559/// circle of radius `radius` centered on `center`. When `focal` is
560/// `None`, it defaults to `center` (the common case).
561#[derive(Clone, Debug)]
562pub struct RadialGradient {
563    /// Center of the outer circle (offset `1.0`), in user space.
564    pub center: Point,
565    /// Radius of the outer circle, in user units.
566    pub radius: f32,
567    /// Focal point the stops sweep outward from (offset `0.0`).
568    /// `None` defaults to `center`.
569    pub focal: Option<Point>,
570    /// Color stops, ordered by ascending `offset`.
571    pub stops: Vec<GradientStop>,
572    /// What to paint outside the outer circle.
573    pub spread: SpreadMethod,
574}
575
576impl RadialGradient {
577    /// Build a `RadialGradient` centered at `center` with `radius`, no
578    /// focal point, no stops, and `SpreadMethod::Pad`.
579    pub fn new(center: Point, radius: f32) -> Self {
580        Self {
581            center,
582            radius,
583            focal: None,
584            stops: Vec::new(),
585            spread: SpreadMethod::Pad,
586        }
587    }
588
589    /// Set the focal point (defaults to `center` when `None`).
590    pub fn with_focal(mut self, focal: Point) -> Self {
591        self.focal = Some(focal);
592        self
593    }
594
595    /// Replace the gradient stops.
596    pub fn with_stops(mut self, stops: Vec<GradientStop>) -> Self {
597        self.stops = stops;
598        self
599    }
600
601    /// Append a single stop.
602    pub fn with_stop(mut self, stop: GradientStop) -> Self {
603        self.stops.push(stop);
604        self
605    }
606
607    /// Set the spread method.
608    pub fn with_spread(mut self, spread: SpreadMethod) -> Self {
609        self.spread = spread;
610        self
611    }
612}
613
614/// One color stop along a gradient. `offset` is in `0.0..=1.0`.
615#[derive(Clone, Copy, Debug, PartialEq)]
616pub struct GradientStop {
617    /// Position of the stop along the gradient axis. `0.0` is the
618    /// start, `1.0` is the end.
619    pub offset: f32,
620    /// Color at this stop.
621    pub color: Rgba,
622}
623
624impl GradientStop {
625    /// Build a stop at `offset` (`0.0..=1.0`) with the given color.
626    pub const fn new(offset: f32, color: Rgba) -> Self {
627        Self { offset, color }
628    }
629}
630
631/// What happens past the gradient endpoints. Mirrors SVG
632/// `spreadMethod="pad|reflect|repeat"` and PDF gradient `Extend` arrays.
633#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
634pub enum SpreadMethod {
635    /// Final stop colors extend forever. SVG default.
636    #[default]
637    Pad,
638    /// Gradient mirrors at each boundary.
639    Reflect,
640    /// Gradient repeats periodically.
641    Repeat,
642}
643
644/// Stroke style for a path's outline.
645#[derive(Clone, Debug)]
646pub struct Stroke {
647    /// Stroke width in user units, centered on the path.
648    pub width: f32,
649    /// Paint applied to the stroked outline.
650    pub paint: Paint,
651    /// How open-subpath endpoints are drawn.
652    pub cap: LineCap,
653    /// How segment corners are drawn.
654    pub join: LineJoin,
655    /// Miter limit ratio. SVG / PDF default is `4.0`.
656    pub miter_limit: f32,
657    /// Optional dash pattern. `None` means a solid (undashed) stroke.
658    pub dash: Option<DashPattern>,
659}
660
661impl Stroke {
662    /// Build a default solid-paint stroke with width `width`.
663    pub fn solid(width: f32, color: Rgba) -> Self {
664        Self {
665            width,
666            paint: Paint::Solid(color),
667            cap: LineCap::Butt,
668            join: LineJoin::Miter,
669            miter_limit: 4.0,
670            dash: None,
671        }
672    }
673
674    /// Build a stroke with the given `width` and `paint`, and SVG/PDF
675    /// default cap (`Butt`), join (`Miter`), miter limit (`4.0`), and
676    /// no dash pattern.
677    pub fn new(width: f32, paint: Paint) -> Self {
678        Self {
679            width,
680            paint,
681            cap: LineCap::Butt,
682            join: LineJoin::Miter,
683            miter_limit: 4.0,
684            dash: None,
685        }
686    }
687
688    /// Replace the stroke paint.
689    pub fn with_paint(mut self, paint: Paint) -> Self {
690        self.paint = paint;
691        self
692    }
693
694    /// Set the line cap style.
695    pub fn with_cap(mut self, cap: LineCap) -> Self {
696        self.cap = cap;
697        self
698    }
699
700    /// Set the line join style.
701    pub fn with_join(mut self, join: LineJoin) -> Self {
702        self.join = join;
703        self
704    }
705
706    /// Set the miter limit ratio (SVG/PDF default is `4.0`).
707    pub fn with_miter_limit(mut self, miter_limit: f32) -> Self {
708        self.miter_limit = miter_limit;
709        self
710    }
711
712    /// Set the dash pattern.
713    pub fn with_dash(mut self, dash: DashPattern) -> Self {
714        self.dash = Some(dash);
715        self
716    }
717}
718
719/// How an open path's endpoints are drawn.
720#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
721pub enum LineCap {
722    /// Squared-off end flush with the endpoint. SVG / PDF default.
723    #[default]
724    Butt,
725    /// Semicircular end of radius `width / 2` centered on the endpoint.
726    Round,
727    /// Squared-off end extending `width / 2` past the endpoint.
728    Square,
729}
730
731/// How two stroke segments meet at a corner.
732#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
733pub enum LineJoin {
734    /// Sharp corner extended to a point, subject to the miter limit.
735    /// SVG / PDF default.
736    #[default]
737    Miter,
738    /// Corner rounded with a circular arc of radius `width / 2`.
739    Round,
740    /// Corner cut off with a straight edge across the outer angle.
741    Bevel,
742}
743
744/// Dash pattern for a stroke. `array` is an alternating
745/// dash-on / dash-off length list (in user units); `offset` is the
746/// phase offset from the path start.
747#[derive(Clone, Debug, Default)]
748pub struct DashPattern {
749    /// Alternating dash-on / dash-off lengths, in user units. An empty
750    /// array means a solid stroke.
751    pub array: Vec<f32>,
752    /// Phase offset from the path start, in user units.
753    pub offset: f32,
754}
755
756impl DashPattern {
757    /// Build a dash pattern with the given lengths and a `0.0` phase
758    /// offset.
759    pub fn new(array: Vec<f32>) -> Self {
760        Self { array, offset: 0.0 }
761    }
762
763    /// Set the phase offset from the path start.
764    pub fn with_offset(mut self, offset: f32) -> Self {
765        self.offset = offset;
766        self
767    }
768}
769
770/// Fill rule for self-intersecting and compound paths. Matches SVG's
771/// `fill-rule` attribute and PDF's `f` (non-zero) vs. `f*` (even-odd)
772/// painting operators.
773#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
774pub enum FillRule {
775    /// A point is inside when the winding number of the path around it
776    /// is non-zero. SVG `fill-rule="nonzero"` (the default) / PDF `f`.
777    #[default]
778    NonZero,
779    /// A point is inside when a ray from it crosses the path an odd
780    /// number of times. SVG `fill-rule="evenodd"` / PDF `f*`.
781    EvenOdd,
782}
783
784/// A 2D affine transform stored as the column-major matrix
785///
786/// ```text
787/// | a c e |   | x |
788/// | b d f | * | y |
789/// | 0 0 1 |   | 1 |
790/// ```
791///
792/// — i.e. `(x', y') = (a*x + c*y + e, b*x + d*y + f)`. The layout
793/// matches SVG's `matrix(a, b, c, d, e, f)` and PDF's `cm` operator
794/// argument order, so emitters can serialize fields directly.
795#[derive(Clone, Copy, Debug, PartialEq)]
796pub struct Transform2D {
797    /// X-scale term: contribution of input `x` to output `x`.
798    pub a: f32,
799    /// Y-skew term: contribution of input `x` to output `y`.
800    pub b: f32,
801    /// X-skew term: contribution of input `y` to output `x`.
802    pub c: f32,
803    /// Y-scale term: contribution of input `y` to output `y`.
804    pub d: f32,
805    /// X translation, in user units.
806    pub e: f32,
807    /// Y translation, in user units.
808    pub f: f32,
809}
810
811impl Transform2D {
812    /// The identity transform. `compose(identity, x) == x`.
813    pub const fn identity() -> Self {
814        Self {
815            a: 1.0,
816            b: 0.0,
817            c: 0.0,
818            d: 1.0,
819            e: 0.0,
820            f: 0.0,
821        }
822    }
823
824    /// Build a translation by `(tx, ty)`.
825    pub const fn translate(tx: f32, ty: f32) -> Self {
826        Self {
827            a: 1.0,
828            b: 0.0,
829            c: 0.0,
830            d: 1.0,
831            e: tx,
832            f: ty,
833        }
834    }
835
836    /// Build a non-uniform scale by `(sx, sy)` about the origin.
837    pub const fn scale(sx: f32, sy: f32) -> Self {
838        Self {
839            a: sx,
840            b: 0.0,
841            c: 0.0,
842            d: sy,
843            e: 0.0,
844            f: 0.0,
845        }
846    }
847
848    /// Build a rotation by `angle_radians` about the origin
849    /// (counter-clockwise in a Y-up system, clockwise visually under
850    /// the SVG / PDF Y-down convention — this matches both formats).
851    pub fn rotate(angle_radians: f32) -> Self {
852        let (s, c) = angle_radians.sin_cos();
853        Self {
854            a: c,
855            b: s,
856            c: -s,
857            d: c,
858            e: 0.0,
859            f: 0.0,
860        }
861    }
862
863    /// Build a horizontal skew (shear along X) by `angle_radians`.
864    pub fn skew_x(angle_radians: f32) -> Self {
865        Self {
866            a: 1.0,
867            b: 0.0,
868            c: angle_radians.tan(),
869            d: 1.0,
870            e: 0.0,
871            f: 0.0,
872        }
873    }
874
875    /// Build a vertical skew (shear along Y) by `angle_radians`.
876    pub fn skew_y(angle_radians: f32) -> Self {
877        Self {
878            a: 1.0,
879            b: angle_radians.tan(),
880            c: 0.0,
881            d: 1.0,
882            e: 0.0,
883            f: 0.0,
884        }
885    }
886
887    /// Compose `self ∘ other` — the resulting transform applies
888    /// `other` first, then `self`, to a point. Equivalent to
889    /// `self.matrix() * other.matrix()` in column-vector form.
890    pub fn compose(&self, other: &Self) -> Self {
891        Self {
892            a: self.a * other.a + self.c * other.b,
893            b: self.b * other.a + self.d * other.b,
894            c: self.a * other.c + self.c * other.d,
895            d: self.b * other.c + self.d * other.d,
896            e: self.a * other.e + self.c * other.f + self.e,
897            f: self.b * other.e + self.d * other.f + self.f,
898        }
899    }
900
901    /// Apply this transform to a point.
902    pub fn apply(&self, p: Point) -> Point {
903        Point {
904            x: self.a * p.x + self.c * p.y + self.e,
905            y: self.b * p.x + self.d * p.y + self.f,
906        }
907    }
908
909    /// `true` when this transform is bit-identical to the identity.
910    /// Useful for emitters that want to skip a no-op `matrix(...)` /
911    /// `cm` write.
912    pub fn is_identity(&self) -> bool {
913        *self == Self::identity()
914    }
915}
916
917impl Default for Transform2D {
918    fn default() -> Self {
919        Self::identity()
920    }
921}
922
923/// An embedded raster image painted into vector space.
924///
925/// `bounds` is the axis-aligned rectangle (in the local user space,
926/// before `transform`) that the image is painted into; SVG `<image>`
927/// `x/y/width/height` and PDF `Do` with a matrix-pre-positioned
928/// `Image` XObject both reduce to this shape.
929#[derive(Clone, Debug)]
930pub struct ImageRef {
931    /// Embedded raster payload. Boxed so a `Node::Image` variant
932    /// doesn't bloat every other [`Node`] case.
933    pub frame: Box<crate::VideoFrame>,
934    /// Destination rectangle the image is scaled into, in the local
935    /// user space (before `transform`).
936    pub bounds: Rect,
937    /// Additional transform applied to the placed image, on top of the
938    /// enclosing group's transform.
939    pub transform: Transform2D,
940}
941
942/// Axis-aligned rectangle in user-space coordinates.
943#[derive(Clone, Copy, Debug, Default, PartialEq)]
944pub struct Rect {
945    /// Left edge.
946    pub x: f32,
947    /// Top edge.
948    pub y: f32,
949    /// Rectangle width, in user units.
950    pub width: f32,
951    /// Rectangle height, in user units.
952    pub height: f32,
953}
954
955impl Rect {
956    /// Build a rectangle from its top-left corner and size.
957    pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
958        Self {
959            x,
960            y,
961            width,
962            height,
963        }
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use crate::time::TimeBase;
971
972    fn approx_point(a: Point, b: Point) -> bool {
973        (a.x - b.x).abs() < 1e-5 && (a.y - b.y).abs() < 1e-5
974    }
975
976    #[test]
977    fn path_builder_produces_command_sequence() {
978        let mut p = Path::new();
979        p.move_to(Point::new(0.0, 0.0))
980            .line_to(Point::new(10.0, 0.0))
981            .quad_to(Point::new(15.0, 5.0), Point::new(10.0, 10.0))
982            .cubic_to(
983                Point::new(5.0, 15.0),
984                Point::new(0.0, 10.0),
985                Point::new(0.0, 0.0),
986            )
987            .close();
988        assert_eq!(p.commands.len(), 5);
989        assert_eq!(p.commands[0], PathCommand::MoveTo(Point::new(0.0, 0.0)));
990        assert_eq!(p.commands[4], PathCommand::Close);
991    }
992
993    #[test]
994    fn transform_identity_round_trips() {
995        let id = Transform2D::identity();
996        assert!(id.is_identity());
997        let p = Point::new(3.5, -2.25);
998        assert_eq!(id.apply(p), p);
999    }
1000
1001    #[test]
1002    fn transform_translate_round_trip() {
1003        let t = Transform2D::translate(10.0, -5.0);
1004        assert_eq!(t.apply(Point::new(0.0, 0.0)), Point::new(10.0, -5.0));
1005        assert_eq!(t.apply(Point::new(1.0, 1.0)), Point::new(11.0, -4.0));
1006    }
1007
1008    #[test]
1009    fn transform_scale_round_trip() {
1010        let s = Transform2D::scale(2.0, 3.0);
1011        assert_eq!(s.apply(Point::new(1.0, 1.0)), Point::new(2.0, 3.0));
1012        assert_eq!(s.apply(Point::new(0.0, 0.0)), Point::new(0.0, 0.0));
1013    }
1014
1015    #[test]
1016    fn transform_rotate_quarter_turn() {
1017        let r = Transform2D::rotate(std::f32::consts::FRAC_PI_2);
1018        // Under SVG/PDF Y-down with matrix(c,s,-s,c,0,0):
1019        // (1, 0) rotates to (cos, sin) = (0, 1).
1020        assert!(approx_point(
1021            r.apply(Point::new(1.0, 0.0)),
1022            Point::new(0.0, 1.0)
1023        ));
1024        // (0, 1) rotates to (-sin, cos) = (-1, 0).
1025        assert!(approx_point(
1026            r.apply(Point::new(0.0, 1.0)),
1027            Point::new(-1.0, 0.0)
1028        ));
1029    }
1030
1031    #[test]
1032    fn transform_compose_identity_is_left_and_right_unit() {
1033        let t = Transform2D::translate(7.0, 11.0);
1034        let id = Transform2D::identity();
1035        assert_eq!(id.compose(&t), t);
1036        assert_eq!(t.compose(&id), t);
1037    }
1038
1039    #[test]
1040    fn transform_compose_translate_then_scale() {
1041        // Apply translate(2,3) first, then scale(10,10):
1042        //   p -> p + (2,3) -> 10*(p+(2,3)) = 10p + (20,30).
1043        let scale = Transform2D::scale(10.0, 10.0);
1044        let translate = Transform2D::translate(2.0, 3.0);
1045        let composed = scale.compose(&translate);
1046        let result = composed.apply(Point::new(1.0, 1.0));
1047        assert!(approx_point(result, Point::new(30.0, 40.0)));
1048    }
1049
1050    #[test]
1051    fn transform_compose_matches_sequential_apply() {
1052        // Composition equivalence: composed.apply(p) == a.apply(b.apply(p)).
1053        let a = Transform2D::rotate(0.5);
1054        let b = Transform2D::translate(3.0, -1.0);
1055        let composed = a.compose(&b);
1056        let p = Point::new(2.0, 5.0);
1057        let direct = composed.apply(p);
1058        let stepwise = a.apply(b.apply(p));
1059        assert!(approx_point(direct, stepwise));
1060    }
1061
1062    #[test]
1063    fn group_default_is_identity_opacity_one_no_clip() {
1064        let g = Group::default();
1065        assert!(g.transform.is_identity());
1066        assert_eq!(g.opacity, 1.0);
1067        assert!(g.clip.is_none());
1068        assert!(g.children.is_empty());
1069    }
1070
1071    #[test]
1072    fn group_nesting_with_transforms() {
1073        // Outer group translates by (10, 10); inner group scales by 2.
1074        // A point (1, 1) drawn at the inner level should land at
1075        // (12, 12) after the outer transform is also applied — but the
1076        // tree itself only stores the local transforms. This test
1077        // pins down that the nested data is preserved verbatim, since
1078        // composing transforms is a rasterizer responsibility.
1079        let inner = Group {
1080            transform: Transform2D::scale(2.0, 2.0),
1081            children: vec![Node::Path(PathNode {
1082                path: {
1083                    let mut p = Path::new();
1084                    p.move_to(Point::new(1.0, 1.0));
1085                    p
1086                },
1087                fill: Some(Paint::Solid(Rgba::opaque(255, 0, 0))),
1088                stroke: None,
1089                fill_rule: FillRule::NonZero,
1090            })],
1091            ..Group::default()
1092        };
1093        let outer = Group {
1094            transform: Transform2D::translate(10.0, 10.0),
1095            children: vec![Node::Group(inner)],
1096            ..Group::default()
1097        };
1098        match &outer.children[0] {
1099            Node::Group(g) => {
1100                assert_eq!(g.transform, Transform2D::scale(2.0, 2.0));
1101                assert_eq!(g.children.len(), 1);
1102            }
1103            _ => panic!("expected a Group child"),
1104        }
1105        assert_eq!(outer.transform, Transform2D::translate(10.0, 10.0));
1106    }
1107
1108    #[test]
1109    fn vector_frame_construction() {
1110        let frame = VectorFrame {
1111            width: 100.0,
1112            height: 50.0,
1113            view_box: Some(ViewBox {
1114                min_x: 0.0,
1115                min_y: 0.0,
1116                width: 100.0,
1117                height: 50.0,
1118            }),
1119            root: Group::default(),
1120            pts: Some(0),
1121            time_base: TimeBase::new(1, 1000),
1122        };
1123        assert_eq!(frame.width, 100.0);
1124        assert_eq!(frame.height, 50.0);
1125        assert!(frame.view_box.is_some());
1126        assert_eq!(frame.pts, Some(0));
1127    }
1128
1129    #[test]
1130    fn rgba_constructors() {
1131        let c = Rgba::opaque(10, 20, 30);
1132        assert_eq!(c.a, 255);
1133        let c2 = Rgba::new(10, 20, 30, 128);
1134        assert_eq!(c2.a, 128);
1135    }
1136
1137    #[test]
1138    fn gradient_stop_round_trips() {
1139        let s = GradientStop::new(0.5, Rgba::opaque(255, 0, 0));
1140        assert_eq!(s.offset, 0.5);
1141        let s2 = GradientStop::new(0.5, Rgba::opaque(255, 0, 0));
1142        assert_eq!(s, s2);
1143    }
1144
1145    #[test]
1146    fn stroke_solid_defaults() {
1147        let s = Stroke::solid(2.0, Rgba::opaque(0, 0, 0));
1148        assert_eq!(s.width, 2.0);
1149        assert_eq!(s.cap, LineCap::Butt);
1150        assert_eq!(s.join, LineJoin::Miter);
1151        assert_eq!(s.miter_limit, 4.0);
1152        assert!(s.dash.is_none());
1153    }
1154
1155    #[test]
1156    fn soft_mask_construction_and_inspection() {
1157        // Wrap a path in a SoftMask node with a luminance mask. Round-
1158        // trips both children verbatim through clone + match.
1159        fn rect_path() -> PathNode {
1160            let mut p = Path::new();
1161            p.move_to(Point::new(0.0, 0.0))
1162                .line_to(Point::new(10.0, 0.0))
1163                .line_to(Point::new(10.0, 10.0))
1164                .line_to(Point::new(0.0, 10.0))
1165                .close();
1166            PathNode {
1167                path: p,
1168                fill: Some(Paint::Solid(Rgba::opaque(255, 255, 255))),
1169                stroke: None,
1170                fill_rule: FillRule::NonZero,
1171            }
1172        }
1173        let n = Node::SoftMask {
1174            mask: Box::new(Node::Path(rect_path())),
1175            mask_kind: MaskKind::Luminance,
1176            content: Box::new(Node::Path(rect_path())),
1177        };
1178        match &n {
1179            Node::SoftMask {
1180                mask_kind, content, ..
1181            } => {
1182                assert_eq!(*mask_kind, MaskKind::Luminance);
1183                match content.as_ref() {
1184                    Node::Path(_) => {}
1185                    _ => panic!("expected Path content"),
1186                }
1187            }
1188            _ => panic!("expected SoftMask"),
1189        }
1190    }
1191
1192    #[test]
1193    fn mask_kind_default_is_luminance() {
1194        assert_eq!(MaskKind::default(), MaskKind::Luminance);
1195    }
1196
1197    #[test]
1198    fn vector_frame_default_is_empty_zero_size() {
1199        let f = VectorFrame::default();
1200        assert_eq!(f.width, 0.0);
1201        assert_eq!(f.height, 0.0);
1202        assert!(f.view_box.is_none());
1203        assert!(f.root.children.is_empty());
1204        assert!(f.pts.is_none());
1205        assert_eq!(f.time_base, TimeBase::new(1, 1));
1206    }
1207
1208    #[test]
1209    fn vector_frame_new_sets_canvas_size() {
1210        let f = VectorFrame::new(640.0, 480.0);
1211        assert_eq!(f.width, 640.0);
1212        assert_eq!(f.height, 480.0);
1213        assert!(f.view_box.is_none());
1214        assert!(f.root.children.is_empty());
1215        assert!(f.pts.is_none());
1216    }
1217
1218    #[test]
1219    fn vector_frame_builder_chain() {
1220        let vb = ViewBox::new(0.0, 0.0, 100.0, 100.0);
1221        let f = VectorFrame::new(100.0, 100.0)
1222            .with_view_box(vb)
1223            .with_pts(42)
1224            .with_time_base(TimeBase::new(1, 90_000));
1225        assert_eq!(f.view_box, Some(vb));
1226        assert_eq!(f.pts, Some(42));
1227        assert_eq!(f.time_base, TimeBase::new(1, 90_000));
1228    }
1229
1230    #[test]
1231    fn vector_frame_with_root_replaces_root() {
1232        let root = Group::new().with_opacity(0.5);
1233        let f = VectorFrame::new(10.0, 10.0).with_root(root);
1234        assert_eq!(f.root.opacity, 0.5);
1235    }
1236
1237    #[test]
1238    fn view_box_new_round_trips_fields() {
1239        let vb = ViewBox::new(1.0, 2.0, 3.0, 4.0);
1240        assert_eq!(vb.min_x, 1.0);
1241        assert_eq!(vb.min_y, 2.0);
1242        assert_eq!(vb.width, 3.0);
1243        assert_eq!(vb.height, 4.0);
1244    }
1245
1246    #[test]
1247    fn rect_new_round_trips_fields() {
1248        let r = Rect::new(1.0, 2.0, 3.0, 4.0);
1249        assert_eq!(r.x, 1.0);
1250        assert_eq!(r.y, 2.0);
1251        assert_eq!(r.width, 3.0);
1252        assert_eq!(r.height, 4.0);
1253    }
1254
1255    #[test]
1256    fn group_new_matches_default() {
1257        let a = Group::new();
1258        let b = Group::default();
1259        assert!(a.transform.is_identity());
1260        assert_eq!(a.opacity, b.opacity);
1261        assert!(a.clip.is_none());
1262        assert_eq!(a.children.len(), b.children.len());
1263        assert_eq!(a.cache_key, b.cache_key);
1264    }
1265
1266    #[test]
1267    fn group_builder_chain() {
1268        let mut clip = Path::new();
1269        clip.move_to(Point::new(0.0, 0.0))
1270            .line_to(Point::new(1.0, 1.0))
1271            .close();
1272        let g = Group::new()
1273            .with_transform(Transform2D::translate(5.0, 7.0))
1274            .with_opacity(0.25)
1275            .with_clip(clip)
1276            .with_cache_key(0xdead_beef);
1277        assert_eq!(g.transform, Transform2D::translate(5.0, 7.0));
1278        assert_eq!(g.opacity, 0.25);
1279        assert!(g.clip.is_some());
1280        assert_eq!(g.cache_key, Some(0xdead_beef));
1281    }
1282
1283    #[test]
1284    fn group_with_child_appends() {
1285        let g = Group::new()
1286            .with_child(Node::Group(Group::new()))
1287            .with_child(Node::Group(Group::new().with_opacity(0.5)));
1288        assert_eq!(g.children.len(), 2);
1289        match &g.children[1] {
1290            Node::Group(inner) => assert_eq!(inner.opacity, 0.5),
1291            _ => panic!("expected Group child"),
1292        }
1293    }
1294
1295    #[test]
1296    fn group_with_children_replaces_list() {
1297        let g = Group::new()
1298            .with_child(Node::Group(Group::new()))
1299            .with_children(vec![Node::Group(Group::new().with_opacity(0.1))]);
1300        assert_eq!(g.children.len(), 1);
1301        match &g.children[0] {
1302            Node::Group(inner) => assert_eq!(inner.opacity, 0.1),
1303            _ => panic!("expected Group child"),
1304        }
1305    }
1306
1307    #[test]
1308    fn path_node_new_then_builder() {
1309        let mut p = Path::new();
1310        p.move_to(Point::new(0.0, 0.0))
1311            .line_to(Point::new(10.0, 0.0));
1312        let n = PathNode::new(p)
1313            .with_fill(Paint::Solid(Rgba::opaque(255, 0, 0)))
1314            .with_stroke(Stroke::solid(1.0, Rgba::opaque(0, 0, 0)))
1315            .with_fill_rule(FillRule::EvenOdd);
1316        assert!(n.fill.is_some());
1317        assert!(n.stroke.is_some());
1318        assert_eq!(n.fill_rule, FillRule::EvenOdd);
1319    }
1320
1321    #[test]
1322    fn path_node_new_defaults() {
1323        let n = PathNode::new(Path::new());
1324        assert!(n.fill.is_none());
1325        assert!(n.stroke.is_none());
1326        assert_eq!(n.fill_rule, FillRule::NonZero);
1327    }
1328
1329    #[test]
1330    fn point_from_array_and_tuple() {
1331        let p1: Point = [1.0_f32, 2.0_f32].into();
1332        let p2: Point = (3.0_f32, 4.0_f32).into();
1333        assert_eq!(p1, Point::new(1.0, 2.0));
1334        assert_eq!(p2, Point::new(3.0, 4.0));
1335    }
1336
1337    #[test]
1338    fn rgba_from_tuples_and_array() {
1339        let a: Rgba = (10u8, 20u8, 30u8, 40u8).into();
1340        let b: Rgba = (50u8, 60u8, 70u8).into();
1341        let c: Rgba = [1u8, 2u8, 3u8, 4u8].into();
1342        assert_eq!(a, Rgba::new(10, 20, 30, 40));
1343        assert_eq!(b, Rgba::opaque(50, 60, 70));
1344        assert_eq!(c, Rgba::new(1, 2, 3, 4));
1345    }
1346
1347    #[test]
1348    fn paint_from_rgba_wraps_solid() {
1349        let p: Paint = Rgba::opaque(1, 2, 3).into();
1350        match p {
1351            Paint::Solid(c) => assert_eq!(c, Rgba::opaque(1, 2, 3)),
1352            _ => panic!("expected Paint::Solid"),
1353        }
1354    }
1355
1356    #[test]
1357    fn linear_gradient_new_then_builder() {
1358        let g = LinearGradient::new(Point::new(0.0, 0.0), Point::new(1.0, 0.0))
1359            .with_stop(GradientStop::new(0.0, Rgba::opaque(0, 0, 0)))
1360            .with_stop(GradientStop::new(1.0, Rgba::opaque(255, 255, 255)))
1361            .with_spread(SpreadMethod::Reflect);
1362        assert_eq!(g.start, Point::new(0.0, 0.0));
1363        assert_eq!(g.end, Point::new(1.0, 0.0));
1364        assert_eq!(g.stops.len(), 2);
1365        assert_eq!(g.spread, SpreadMethod::Reflect);
1366    }
1367
1368    #[test]
1369    fn linear_gradient_with_stops_replaces() {
1370        let g = LinearGradient::new(Point::new(0.0, 0.0), Point::new(1.0, 0.0))
1371            .with_stop(GradientStop::new(0.5, Rgba::opaque(0, 0, 0)))
1372            .with_stops(vec![GradientStop::new(0.0, Rgba::opaque(1, 1, 1))]);
1373        assert_eq!(g.stops.len(), 1);
1374        assert_eq!(g.stops[0].offset, 0.0);
1375    }
1376
1377    #[test]
1378    fn radial_gradient_new_then_builder() {
1379        let g = RadialGradient::new(Point::new(5.0, 5.0), 10.0)
1380            .with_focal(Point::new(4.0, 4.0))
1381            .with_stop(GradientStop::new(0.0, Rgba::opaque(0, 0, 0)))
1382            .with_spread(SpreadMethod::Repeat);
1383        assert_eq!(g.center, Point::new(5.0, 5.0));
1384        assert_eq!(g.radius, 10.0);
1385        assert_eq!(g.focal, Some(Point::new(4.0, 4.0)));
1386        assert_eq!(g.stops.len(), 1);
1387        assert_eq!(g.spread, SpreadMethod::Repeat);
1388    }
1389
1390    #[test]
1391    fn radial_gradient_with_stops_replaces() {
1392        let g = RadialGradient::new(Point::new(0.0, 0.0), 1.0)
1393            .with_stop(GradientStop::new(0.5, Rgba::opaque(0, 0, 0)))
1394            .with_stops(vec![GradientStop::new(1.0, Rgba::opaque(1, 1, 1))]);
1395        assert_eq!(g.stops.len(), 1);
1396        assert_eq!(g.stops[0].offset, 1.0);
1397    }
1398
1399    #[test]
1400    fn stroke_new_defaults() {
1401        let s = Stroke::new(3.0, Paint::Solid(Rgba::opaque(0, 0, 0)));
1402        assert_eq!(s.width, 3.0);
1403        assert_eq!(s.cap, LineCap::Butt);
1404        assert_eq!(s.join, LineJoin::Miter);
1405        assert_eq!(s.miter_limit, 4.0);
1406        assert!(s.dash.is_none());
1407    }
1408
1409    #[test]
1410    fn stroke_builder_chain() {
1411        let s = Stroke::solid(1.0, Rgba::opaque(0, 0, 0))
1412            .with_cap(LineCap::Round)
1413            .with_join(LineJoin::Bevel)
1414            .with_miter_limit(10.0)
1415            .with_dash(DashPattern::new(vec![2.0, 1.0]).with_offset(0.5))
1416            .with_paint(Paint::Solid(Rgba::opaque(128, 128, 128)));
1417        assert_eq!(s.cap, LineCap::Round);
1418        assert_eq!(s.join, LineJoin::Bevel);
1419        assert_eq!(s.miter_limit, 10.0);
1420        let d = s.dash.expect("dash set");
1421        assert_eq!(d.array, vec![2.0, 1.0]);
1422        assert_eq!(d.offset, 0.5);
1423        match s.paint {
1424            Paint::Solid(c) => assert_eq!(c, Rgba::opaque(128, 128, 128)),
1425            _ => panic!("expected Paint::Solid"),
1426        }
1427    }
1428
1429    #[test]
1430    fn dash_pattern_new_zero_offset() {
1431        let d = DashPattern::new(vec![1.0, 2.0, 3.0]);
1432        assert_eq!(d.array, vec![1.0, 2.0, 3.0]);
1433        assert_eq!(d.offset, 0.0);
1434    }
1435
1436    #[test]
1437    fn dash_pattern_with_offset_sets_phase() {
1438        let d = DashPattern::new(vec![1.0]).with_offset(0.25);
1439        assert_eq!(d.offset, 0.25);
1440    }
1441}