rasterrocket-render 1.0.1

Software rasterizer — path fill, compositing, and AVX-512/AVX2/NEON SIMD for the rasterrocket PDF renderer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
//! Flattened, matrix-transformed path edge table.
//!
//! [`XPath`] is the Rust equivalent of `SplashXPath` from `splash/SplashXPath.h/.cc`.
//! It converts a [`Path`] (in user space) into a sorted sequence of line segments
//! in device space, ready for scan conversion by [`crate::XPathScanner`].
//!
//! ## Typical usage pipeline
//!
//! ```text
//! XPath::new(path, matrix, flatness, close_subpaths)   ← construction
//!     │  internally calls add_segment for every flattened edge
//!//! XPath                                                 ← device-space edge table
//!     │  optionally:
//!//! xpath.aa_scale()                                      ← scale coords × AA_SIZE
//!     │  must be called AT MOST ONCE, before handing to XPathScanner
//!//! XPathScanner                                          ← scan conversion
//! ```
//!
//! ## Key invariants (established by `XPath::add_segment` — internal)
//!
//! - For every non-horizontal segment, `y0 ≤ y1` after construction (swapped
//!   if necessary; [`XPathFlags::FLIPPED`] is set when a swap occurred).
//! - [`XPathFlags::HORIZ`] is set when `y0 == y1` (despite the misleading
//!   "vertical" comment in the original C++ header — trust the code).
//! - [`XPathFlags::VERT`] is set when `x0 == x1`.
//! - `dxdy = (x1-x0)/(y1-y0)` for sloped segments; 0.0 for horizontal/vertical.
//!   Division is safe because the HORIZ early-return guarantees `y0 ≠ y1` for
//!   any segment that reaches the slope computation.
//!
//! ## Affine transform convention
//!
//! ```text
//! x_out = x_in * m[0] + y_in * m[2] + m[4]
//! y_out = x_in * m[1] + y_in * m[3] + m[5]
//! ```
//! (column-vector convention matching `SplashXPath::transform`.)

use crate::path::adjust::{XPathAdjust, stroke_adjust};
use crate::path::flatten::{CurveData, flatten_curve};
use crate::path::{Path, PathFlags, PathPoint, StrokeAdjustHint};
use crate::types::AA_SIZE;
use bitflags::bitflags;

// ── XPathFlags ────────────────────────────────────────────────────────────────

bitflags! {
    /// Per-segment flags for [`XPathSeg`].
    #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
    pub struct XPathFlags: u32 {
        /// Horizontal segment: y0 == y1. (`splashXPathHoriz`)
        /// NOTE: the C++ header comment says "vertical" — this is wrong.
        const HORIZ   = 0x01;
        /// Vertical segment: x0 == x1. (`splashXPathVert`)
        const VERT    = 0x02;
        /// Segment was flipped (original y0 > y1) to enforce y0 ≤ y1.
        const FLIPPED = 0x04;
    }
}

// ── XPathSeg ──────────────────────────────────────────────────────────────────

/// A single line segment in the edge table, in device space.
///
/// Matches `SplashXPathSeg` from `splash/SplashXPath.h`.
///
/// # Invariant
///
/// After construction by `XPath::add_segment` (internal), every non-horizontal
/// segment satisfies `y0 ≤ y1`. Horizontal segments (`HORIZ` flag set) are exempt.
#[derive(Clone, Debug)]
pub struct XPathSeg {
    /// X coordinate of the segment start point, in device space.
    pub x0: f64,
    /// Y coordinate of the segment start point, in device space.
    ///
    /// **Invariant**: `y0 ≤ y1` for all non-horizontal segments after
    /// construction. [`XPathFlags::FLIPPED`] is set when the original input had
    /// `y0 > y1` and the endpoints were swapped to enforce this invariant.
    pub y0: f64,
    /// X coordinate of the segment end point, in device space.
    pub x1: f64,
    /// Y coordinate of the segment end point, in device space.
    ///
    /// Always `y0 ≤ y1` for non-horizontal segments after construction.
    pub y1: f64,
    /// Slope `(x1-x0)/(y1-y0)`, stored as both `f64` (for initialising the
    /// fixed-point accumulator) and as a **16.16 fixed-point `i32`** (`dxdy_fp`)
    /// for the per-scanline stepping hot loop.
    ///
    /// Set to `0.0` / `0` for horizontal and vertical segments.
    pub dxdy: f64,
    /// Slope in 16.16 fixed-point: `(dxdy * 65536.0).round() as i32`.
    ///
    /// Used by the scanner's incremental x-accumulator: `xx_fp += dxdy_fp` per
    /// scanline.  Integer addition instead of f64 addition eliminates floating-
    /// point dependency chains in the inner loop and is trivially vectorizable.
    ///
    /// Precision: 1/65536 ≈ 1.5 × 10⁻⁵ device pixels per scanline — sufficient
    /// for any realistic document (the error accumulates as at most one pixel per
    /// ~65536 scanlines, far beyond any real page height).
    pub dxdy_fp: i32,
    /// Orientation and flip flags; see [`XPathFlags`] for the set of valid bits.
    pub flags: XPathFlags,
}

// ── XPath ─────────────────────────────────────────────────────────────────────

/// Matrix-transformed, flattened edge table derived from a [`Path`].
///
/// # Construction pipeline
///
/// 1. Call [`XPath::new`] to build the edge table from a [`Path`].
/// 2. Optionally call [`XPath::aa_scale`] **once** to scale coordinates by
///    [`AA_SIZE`] for supersampled anti-aliasing.
/// 3. Hand the resulting `XPath` to `XPathScanner` for scan conversion.
///
/// Calling `aa_scale` more than once will multiply coordinates by `AA_SIZE`
/// again, producing incorrect results. This is not checked at runtime.
pub struct XPath {
    /// The flattened, transformed edge segments making up this path, in insertion order.
    pub segs: Vec<XPathSeg>,
    /// Lazily allocated (~25 KB) scratch for Bezier subdivision.
    curve_data: Option<Box<CurveData>>,
}

impl XPath {
    /// Create an empty `XPath` (for tests and internal use).
    #[cfg(test)]
    pub(crate) const fn empty() -> Self {
        Self {
            segs: Vec::new(),
            curve_data: None,
        }
    }

    /// Build an `XPath` from a [`Path`] by applying `matrix` and flattening curves.
    ///
    /// # Arguments
    ///
    /// - `path`: the source path in user (pre-transform) space.
    /// - `matrix`: a 2-D affine transform `[a, b, c, d, e, f]` mapping user
    ///   space to device space (column-vector convention; see module docs).
    /// - `flatness`: maximum chord deviation (in device pixels) for Bezier
    ///   subdivision. Smaller values produce more accurate curves but more
    ///   segments. Typical range: `0.1`–`1.0`.
    /// - `close_subpaths`: if `true`, an implicit closing segment is added from
    ///   the last point of each subpath back to its first point when they do not
    ///   already coincide (matches `SplashXPath` constructor behaviour).
    ///
    /// # Ordering constraints
    ///
    /// After this call, `segs` is in insertion order (one entry per flattened
    /// edge). No further sorting is performed here; callers that need a sorted
    /// edge table must sort `segs` themselves or use `XPathScanner`.
    ///
    /// Calling [`XPath::aa_scale`] after this method scales all coordinates by
    /// [`AA_SIZE`]. It must be called at most once.
    #[must_use]
    pub fn new(path: &Path, matrix: &[f64; 6], flatness: f64, close_subpaths: bool) -> Self {
        let flatness_sq = flatness * flatness;
        let mut xpath = Self {
            segs: Vec::new(),
            curve_data: None,
        };

        // Transform every path point into device space.
        let tpts: Vec<PathPoint> = path
            .pts
            .iter()
            .map(|p| transform(matrix, p.x, p.y))
            .collect();

        // Thin-line stroke adjustment (the `adjust_lines=true` branch of
        // `XPathAdjust::new`) has no caller today; pass the no-op values.
        let adjusts = build_adjusts(&path.hints, &tpts, false, 0);

        // Apply stroke adjustments to the transformed points.
        let mut tpts = tpts;
        for adj in &adjusts {
            // Safety: build_adjusts validates that first_pt and last_pt are
            // within bounds before constructing the XPathAdjust records, so
            // this slice index cannot panic.
            debug_assert!(
                adj.last_pt < tpts.len(),
                "adj.last_pt ({}) out of bounds (tpts.len() = {})",
                adj.last_pt,
                tpts.len()
            );
            for pt in &mut tpts[adj.first_pt..=adj.last_pt] {
                let (x, y) = (&mut pt.x, &mut pt.y);
                stroke_adjust(adj, x, y);
            }
        }

        // Walk the path and emit segments.
        let n = path.pts.len();
        let mut i = 0usize;
        while i < n {
            if path.flags[i].contains(PathFlags::FIRST) {
                // Start of a new subpath.
                let sp_x = tpts[i].x;
                let sp_y = tpts[i].y;
                let mut cur_x = sp_x;
                let mut cur_y = sp_y;
                i += 1;
                while i < n {
                    if path.flags[i].contains(PathFlags::CURVE) {
                        // Cubic Bezier: consume 3 points (2 control + 1 endpoint).
                        if i + 2 >= n {
                            break;
                        }
                        let p0 = PathPoint::new(cur_x, cur_y);
                        let p1 = tpts[i];
                        let p2 = tpts[i + 1];
                        let p3 = tpts[i + 2];
                        let mut flat_pts = Vec::new();
                        flatten_curve(
                            p0,
                            p1,
                            p2,
                            p3,
                            flatness_sq,
                            &mut flat_pts,
                            &mut xpath.curve_data,
                        );
                        for fp in &flat_pts {
                            xpath.add_segment(cur_x, cur_y, fp.x, fp.y);
                            cur_x = fp.x;
                            cur_y = fp.y;
                        }
                        i += 3;
                    } else {
                        // Line segment.
                        let nx = tpts[i].x;
                        let ny = tpts[i].y;
                        xpath.add_segment(cur_x, cur_y, nx, ny);
                        cur_x = nx;
                        cur_y = ny;
                        let is_last = path.flags[i].contains(PathFlags::LAST);
                        i += 1;
                        if is_last {
                            break;
                        }
                    }
                }
                // Closing segment if requested and the subpath is not already closed.
                if close_subpaths && ((cur_x - sp_x).abs() > 1e-10 || (cur_y - sp_y).abs() > 1e-10)
                {
                    xpath.add_segment(cur_x, cur_y, sp_x, sp_y);
                }
            } else {
                i += 1;
            }
        }

        xpath
    }

    /// Scale all segment coordinates by [`AA_SIZE`] for supersampled anti-aliasing.
    ///
    /// `dxdy` (the slope) is invariant under uniform scaling and is **not** modified —
    /// a uniform scale cancels out in `(x1-x0)/(y1-y0)`.
    ///
    /// Matches `SplashXPath::aaScale()`.
    ///
    /// # Ordering constraint
    ///
    /// This method must be called **after** [`XPath::new`] and **at most once**.
    /// Calling it a second time multiplies coordinates by [`AA_SIZE`] again,
    /// which will produce incorrect scan-conversion results. There is no runtime
    /// guard against double-scaling.
    ///
    /// # Panics
    ///
    /// Does not panic in practice. However, if any coordinate is so large that
    /// multiplying by [`AA_SIZE`] would overflow to `f64::INFINITY`, subsequent
    /// scan-conversion arithmetic will silently produce wrong results. A
    /// `debug_assert!` fires in debug builds if any coordinate is non-finite
    /// before scaling.
    pub fn aa_scale(&mut self) {
        let s = f64::from(AA_SIZE);
        for seg in &mut self.segs {
            debug_assert!(
                seg.x0.is_finite()
                    && seg.y0.is_finite()
                    && seg.x1.is_finite()
                    && seg.y1.is_finite(),
                "aa_scale: segment coordinates must be finite before scaling \
                 (x0={}, y0={}, x1={}, y1={})",
                seg.x0,
                seg.y0,
                seg.x1,
                seg.y1,
            );
            seg.x0 *= s;
            seg.y0 *= s;
            seg.x1 *= s;
            seg.y1 *= s;
        }
    }

    // ── Private helpers ───────────────────────────────────────────────────────

    /// Append one line segment to the edge table, enforcing `y0 ≤ y1` for
    /// non-horizontal segments and computing `dxdy`.
    ///
    /// # y0 ≤ y1 invariant
    ///
    /// If the supplied `y0 > y1`, the endpoints are swapped and
    /// [`XPathFlags::FLIPPED`] is set. Horizontal segments (`y0 == y1`) are
    /// never flipped.
    ///
    /// # Division safety
    ///
    /// `dxdy = (x1-x0)/(y1-y0)` is computed only for segments that are neither
    /// horizontal nor vertical. The HORIZ early-return guarantees `y0 ≠ y1` for
    /// any segment that reaches this computation, so division by zero cannot
    /// occur. A `debug_assert!` enforces this contract in debug builds.
    ///
    /// Matches `SplashXPath::addSegment` in `SplashXPath.cc`.
    fn add_segment(&mut self, mut x0: f64, mut y0: f64, mut x1: f64, mut y1: f64) {
        let mut flags = XPathFlags::empty();

        // Exact bit-equality is intentional: checking for axis-aligned segments
        // that were constructed with the same coordinate value.
        if y0.to_bits() == y1.to_bits() {
            // Horizontal segment: y0 == y1, dxdy is undefined; set to 0.0.
            flags.insert(XPathFlags::HORIZ);
            if x0.to_bits() == x1.to_bits() {
                flags.insert(XPathFlags::VERT);
            }
            self.segs.push(XPathSeg {
                x0,
                y0,
                x1,
                y1,
                dxdy: 0.0,
                dxdy_fp: 0,
                flags,
            });
            return; // Horizontal segments are NOT flipped.
        }

        // Non-horizontal: y0 ≠ y1 is guaranteed by the early return above.
        if x0.to_bits() == x1.to_bits() {
            flags.insert(XPathFlags::VERT);
        }

        // Compute slope before the potential swap so that the sign is
        // consistent with the *original* orientation. After the swap below,
        // the stored dxdy is the slope in the y0-ascending direction.
        let dxdy = if flags.contains(XPathFlags::VERT) {
            0.0
        } else {
            // Division is safe: HORIZ guard above guarantees y1 - y0 ≠ 0.0.
            debug_assert_ne!(
                y1.to_bits(),
                y0.to_bits(),
                "add_segment: y0 == y1 must be caught by the HORIZ branch"
            );
            (x1 - x0) / (y1 - y0)
        };

        if y0 > y1 {
            std::mem::swap(&mut x0, &mut x1);
            std::mem::swap(&mut y0, &mut y1);
            flags.insert(XPathFlags::FLIPPED);
        }

        // Clamp dxdy_fp to i32 range.  The only way `dxdy * 65536` overflows i32
        // is if the segment spans more than ~32768 pixels horizontally per pixel
        // vertically — physically impossible for any realistic page.
        let dxdy_fp = {
            let fp = dxdy * 65536.0;
            if fp >= f64::from(i32::MAX) {
                i32::MAX
            } else if fp <= f64::from(i32::MIN) {
                i32::MIN
            } else {
                #[expect(
                    clippy::cast_possible_truncation,
                    reason = "fp is bounds-checked to [i32::MIN, i32::MAX] by the branches above"
                )]
                {
                    fp.round() as i32
                }
            }
        };

        self.segs.push(XPathSeg {
            x0,
            y0,
            x1,
            y1,
            dxdy,
            dxdy_fp,
            flags,
        });
    }
}

// ── Affine transform ──────────────────────────────────────────────────────────

/// Apply a 2-D affine matrix to point `(xi, yi)`, returning the transformed
/// [`PathPoint`] in device space.
///
/// Column-vector convention matching `SplashXPath::transform`:
///
/// ```text
/// x_out = xi*m[0] + yi*m[2] + m[4]
/// y_out = xi*m[1] + yi*m[3] + m[5]
/// ```
///
/// Uses `f64::mul_add` for fused multiply-add, giving one rounding error per
/// term rather than two.
#[inline]
#[must_use]
pub const fn transform(m: &[f64; 6], xi: f64, yi: f64) -> PathPoint {
    PathPoint::new(
        xi.mul_add(m[0], yi.mul_add(m[2], m[4])),
        xi.mul_add(m[1], yi.mul_add(m[3], m[5])),
    )
}

// ── Stroke adjust record construction ────────────────────────────────────────

/// Build [`XPathAdjust`] records from path hints and transformed points.
///
/// Mirrors the hint-processing loop in the `SplashXPath` constructor.
///
/// Only axis-aligned hint pairs (both edges strictly horizontal or both
/// strictly vertical after transformation) are converted to adjust records;
/// skewed pairs are silently dropped, matching the C++ behaviour.
///
/// Hints whose control-point indices are out of range for `tpts` are also
/// silently dropped rather than panicking, so that malformed PDF content
/// cannot cause crashes.
///
/// # Parameters `adjust_lines` and `line_pos_i`
///
/// Forwarded verbatim to [`XPathAdjust::new`], where they drive the thin-line
/// snap branch (when both rounded endpoints coincide and `adjust_lines` is
/// true, the span is expanded to `[line_pos_i, line_pos_i + 1]`). Every
/// caller in the tree passes `(false, 0)`, which is the no-op configuration;
/// the assert below pins that contract so an unexpected caller surfaces in
/// debug builds rather than silently producing thin-line-snapped output.
fn build_adjusts(
    hints: &[StrokeAdjustHint],
    tpts: &[PathPoint],
    adjust_lines: bool,
    line_pos_i: i32,
) -> Vec<XPathAdjust> {
    debug_assert!(
        !adjust_lines && line_pos_i == 0,
        "build_adjusts: only the no-op configuration (false, 0) is reachable today; \
         got adjust_lines={adjust_lines} line_pos_i={line_pos_i}"
    );
    let mut adjusts = Vec::with_capacity(hints.len());
    for h in hints {
        // Validate indices: each hint references two consecutive point pairs.
        // ctrl0+1 and ctrl1+1 must both be valid indices into tpts.
        if h.ctrl0 + 1 >= tpts.len() || h.ctrl1 + 1 >= tpts.len() {
            continue;
        }
        let p00 = tpts[h.ctrl0];
        let p01 = tpts[h.ctrl0 + 1];
        let p10 = tpts[h.ctrl1];
        let p11 = tpts[h.ctrl1 + 1];
        // Determine orientation using bit-exact comparison (axis-aligned check).
        let vert = (p00.x.to_bits() == p01.x.to_bits()) && (p10.x.to_bits() == p11.x.to_bits());
        let horiz = (p00.y.to_bits() == p01.y.to_bits()) && (p10.y.to_bits() == p11.y.to_bits());
        if !vert && !horiz {
            continue;
        }
        // The two coordinates to snap: take min/max so adj0 ≤ adj1 always.
        let (a0, a1) = if vert {
            (p00.x.min(p10.x), p00.x.max(p10.x))
        } else {
            (p00.y.min(p10.y), p00.y.max(p10.y))
        };
        adjusts.push(XPathAdjust::new(
            h.first_pt,
            h.last_pt,
            vert,
            a0,
            a1,
            adjust_lines,
            line_pos_i,
        ));
    }
    adjusts
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::path::PathBuilder;

    fn identity() -> [f64; 6] {
        [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
    }

    #[test]
    fn horizontal_segment_not_flipped() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(0.0, 5.0, 10.0, 5.0);
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::HORIZ));
        assert!(!s.flags.contains(XPathFlags::FLIPPED));
        assert!((s.y0 - 5.0).abs() < f64::EPSILON);
        assert!((s.y1 - 5.0).abs() < f64::EPSILON);
    }

    #[test]
    fn downward_segment_flipped() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(0.0, 10.0, 0.0, 0.0); // y0 > y1 → flip
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::FLIPPED));
        assert!(s.y0 <= s.y1, "y0={} y1={}", s.y0, s.y1);
    }

    #[test]
    fn aa_scale_multiplies_coords() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(1.0, 0.0, 3.0, 2.0);
        let orig_dxdy = xpath.segs[0].dxdy;
        xpath.aa_scale();
        let s = &xpath.segs[0];
        assert!((s.x0 - 4.0).abs() < 1e-10);
        assert!((s.y0 - 0.0).abs() < 1e-10);
        assert!((s.x1 - 12.0).abs() < 1e-10);
        assert!((s.y1 - 8.0).abs() < 1e-10);
        assert!(
            (s.dxdy - orig_dxdy).abs() < 1e-10,
            "dxdy should be unchanged"
        );
    }

    #[test]
    fn vertical_segment_dxdy_zero() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(5.0, 0.0, 5.0, 10.0);
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::VERT));
        assert!(s.dxdy.abs() < f64::EPSILON);
    }

    #[test]
    fn triangle_from_path() {
        let mut b = PathBuilder::new();
        b.move_to(0.0, 0.0).unwrap();
        b.line_to(4.0, 0.0).unwrap();
        b.line_to(2.0, 4.0).unwrap();
        b.close(false).unwrap();
        let path = b.build();
        let xpath = XPath::new(&path, &identity(), 1.0, false);
        // 3 explicit segments + 1 closing → but PathBuilder's close() already
        // adds the closing lineTo, so 3 segments total.
        assert_eq!(xpath.segs.len(), 3);
    }

    /// A degenerate point segment (x0==x1, y0==y1) should set both HORIZ and
    /// VERT flags and not panic.
    #[test]
    fn degenerate_point_segment() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(3.0, 7.0, 3.0, 7.0);
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::HORIZ));
        assert!(s.flags.contains(XPathFlags::VERT));
        assert!(!s.flags.contains(XPathFlags::FLIPPED));
        assert_eq!(s.dxdy.to_bits(), 0.0_f64.to_bits());
    }

    /// `dxdy` must equal `(x1-x0)/(y1-y0)` for a sloped segment.
    #[test]
    fn sloped_segment_dxdy() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        // y0 < y1, not vertical → dxdy = (6-2)/(5-1) = 1.0
        xpath.add_segment(2.0, 1.0, 6.0, 5.0);
        let s = &xpath.segs[0];
        assert!(!s.flags.contains(XPathFlags::HORIZ));
        assert!(!s.flags.contains(XPathFlags::VERT));
        assert!(!s.flags.contains(XPathFlags::FLIPPED));
        assert!((s.dxdy - 1.0).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
    }

    /// A flipped sloped segment must have the same absolute dxdy value as its
    /// unflipped counterpart, and must satisfy y0 ≤ y1 after construction.
    #[test]
    fn flipped_sloped_segment_dxdy_consistent() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        // Supply (x0=6, y0=5) → (x1=2, y1=1): y0 > y1, so it will be flipped.
        xpath.add_segment(6.0, 5.0, 2.0, 1.0);
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::FLIPPED));
        assert!(
            s.y0 <= s.y1,
            "y0 ≤ y1 invariant violated: y0={} y1={}",
            s.y0,
            s.y1
        );
        // dxdy = (x1_orig - x0_orig)/(y1_orig - y0_orig) = (2-6)/(1-5) = 1.0
        assert!((s.dxdy - 1.0).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
    }

    #[test]
    fn dxdy_fp_matches_dxdy_for_slope_one() {
        // Slope = 1.0 → dxdy_fp should be 65536.
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(0.0, 0.0, 4.0, 4.0);
        let s = &xpath.segs[0];
        assert!((s.dxdy - 1.0).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
        assert_eq!(s.dxdy_fp, 65536, "dxdy_fp={}", s.dxdy_fp);
    }

    #[test]
    fn dxdy_fp_matches_dxdy_for_half_slope() {
        // Slope = 0.5 → dxdy_fp should be 32768.
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(0.0, 0.0, 2.0, 4.0);
        let s = &xpath.segs[0];
        assert!((s.dxdy - 0.5).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
        assert_eq!(s.dxdy_fp, 32768, "dxdy_fp={}", s.dxdy_fp);
    }

    #[test]
    fn dxdy_fp_zero_for_horizontal() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(0.0, 2.0, 5.0, 2.0);
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::HORIZ));
        assert_eq!(s.dxdy_fp, 0);
    }

    #[test]
    fn dxdy_fp_zero_for_vertical() {
        let mut xpath = XPath {
            segs: Vec::new(),
            curve_data: None,
        };
        xpath.add_segment(3.0, 0.0, 3.0, 5.0);
        let s = &xpath.segs[0];
        assert!(s.flags.contains(XPathFlags::VERT));
        assert_eq!(s.dxdy_fp, 0);
    }
}