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