Skip to main content

rasterrocket_render/
xpath.rs

1//! Flattened, matrix-transformed path edge table.
2//!
3//! [`XPath`] is the Rust equivalent of `SplashXPath` from `splash/SplashXPath.h/.cc`.
4//! It converts a [`Path`] (in user space) into a sorted sequence of line segments
5//! in device space, ready for scan conversion by [`crate::XPathScanner`].
6//!
7//! ## Typical usage pipeline
8//!
9//! ```text
10//! XPath::new(path, matrix, flatness, close_subpaths)   ← construction
11//!     │  internally calls add_segment for every flattened edge
12//!     ▼
13//! XPath                                                 ← device-space edge table
14//!     │  optionally:
15//!     ▼
16//! xpath.aa_scale()                                      ← scale coords × AA_SIZE
17//!     │  must be called AT MOST ONCE, before handing to XPathScanner
18//!     ▼
19//! XPathScanner                                          ← scan conversion
20//! ```
21//!
22//! ## Key invariants (established by `XPath::add_segment` — internal)
23//!
24//! - For every non-horizontal segment, `y0 ≤ y1` after construction (swapped
25//!   if necessary; [`XPathFlags::FLIPPED`] is set when a swap occurred).
26//! - [`XPathFlags::HORIZ`] is set when `y0 == y1` (despite the misleading
27//!   "vertical" comment in the original C++ header — trust the code).
28//! - [`XPathFlags::VERT`] is set when `x0 == x1`.
29//! - `dxdy = (x1-x0)/(y1-y0)` for sloped segments; 0.0 for horizontal/vertical.
30//!   Division is safe because the HORIZ early-return guarantees `y0 ≠ y1` for
31//!   any segment that reaches the slope computation.
32//!
33//! ## Affine transform convention
34//!
35//! ```text
36//! x_out = x_in * m[0] + y_in * m[2] + m[4]
37//! y_out = x_in * m[1] + y_in * m[3] + m[5]
38//! ```
39//! (column-vector convention matching `SplashXPath::transform`.)
40
41use crate::path::adjust::{XPathAdjust, stroke_adjust};
42use crate::path::flatten::{CurveData, flatten_curve};
43use crate::path::{Path, PathFlags, PathPoint, StrokeAdjustHint};
44use crate::types::AA_SIZE;
45use bitflags::bitflags;
46
47// ── XPathFlags ────────────────────────────────────────────────────────────────
48
49bitflags! {
50    /// Per-segment flags for [`XPathSeg`].
51    #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
52    pub struct XPathFlags: u32 {
53        /// Horizontal segment: y0 == y1. (`splashXPathHoriz`)
54        /// NOTE: the C++ header comment says "vertical" — this is wrong.
55        const HORIZ   = 0x01;
56        /// Vertical segment: x0 == x1. (`splashXPathVert`)
57        const VERT    = 0x02;
58        /// Segment was flipped (original y0 > y1) to enforce y0 ≤ y1.
59        const FLIPPED = 0x04;
60    }
61}
62
63// ── XPathSeg ──────────────────────────────────────────────────────────────────
64
65/// A single line segment in the edge table, in device space.
66///
67/// Matches `SplashXPathSeg` from `splash/SplashXPath.h`.
68///
69/// # Invariant
70///
71/// After construction by `XPath::add_segment` (internal), every non-horizontal
72/// segment satisfies `y0 ≤ y1`. Horizontal segments (`HORIZ` flag set) are exempt.
73#[derive(Clone, Debug)]
74pub struct XPathSeg {
75    /// X coordinate of the segment start point, in device space.
76    pub x0: f64,
77    /// Y coordinate of the segment start point, in device space.
78    ///
79    /// **Invariant**: `y0 ≤ y1` for all non-horizontal segments after
80    /// construction. [`XPathFlags::FLIPPED`] is set when the original input had
81    /// `y0 > y1` and the endpoints were swapped to enforce this invariant.
82    pub y0: f64,
83    /// X coordinate of the segment end point, in device space.
84    pub x1: f64,
85    /// Y coordinate of the segment end point, in device space.
86    ///
87    /// Always `y0 ≤ y1` for non-horizontal segments after construction.
88    pub y1: f64,
89    /// Slope `(x1-x0)/(y1-y0)`, stored as both `f64` (for initialising the
90    /// fixed-point accumulator) and as a **16.16 fixed-point `i32`** (`dxdy_fp`)
91    /// for the per-scanline stepping hot loop.
92    ///
93    /// Set to `0.0` / `0` for horizontal and vertical segments.
94    pub dxdy: f64,
95    /// Slope in 16.16 fixed-point: `(dxdy * 65536.0).round() as i32`.
96    ///
97    /// Used by the scanner's incremental x-accumulator: `xx_fp += dxdy_fp` per
98    /// scanline.  Integer addition instead of f64 addition eliminates floating-
99    /// point dependency chains in the inner loop and is trivially vectorizable.
100    ///
101    /// Precision: 1/65536 ≈ 1.5 × 10⁻⁵ device pixels per scanline — sufficient
102    /// for any realistic document (the error accumulates as at most one pixel per
103    /// ~65536 scanlines, far beyond any real page height).
104    pub dxdy_fp: i32,
105    /// Orientation and flip flags; see [`XPathFlags`] for the set of valid bits.
106    pub flags: XPathFlags,
107}
108
109// ── XPath ─────────────────────────────────────────────────────────────────────
110
111/// Matrix-transformed, flattened edge table derived from a [`Path`].
112///
113/// # Construction pipeline
114///
115/// 1. Call [`XPath::new`] to build the edge table from a [`Path`].
116/// 2. Optionally call [`XPath::aa_scale`] **once** to scale coordinates by
117///    [`AA_SIZE`] for supersampled anti-aliasing.
118/// 3. Hand the resulting `XPath` to `XPathScanner` for scan conversion.
119///
120/// Calling `aa_scale` more than once will multiply coordinates by `AA_SIZE`
121/// again, producing incorrect results. This is not checked at runtime.
122pub struct XPath {
123    /// The flattened, transformed edge segments making up this path, in insertion order.
124    pub segs: Vec<XPathSeg>,
125    /// Lazily allocated (~25 KB) scratch for Bezier subdivision.
126    curve_data: Option<Box<CurveData>>,
127}
128
129impl XPath {
130    /// Create an empty `XPath` (for tests and internal use).
131    #[cfg(test)]
132    pub(crate) const fn empty() -> Self {
133        Self {
134            segs: Vec::new(),
135            curve_data: None,
136        }
137    }
138
139    /// Build an `XPath` from a [`Path`] by applying `matrix` and flattening curves.
140    ///
141    /// # Arguments
142    ///
143    /// - `path`: the source path in user (pre-transform) space.
144    /// - `matrix`: a 2-D affine transform `[a, b, c, d, e, f]` mapping user
145    ///   space to device space (column-vector convention; see module docs).
146    /// - `flatness`: maximum chord deviation (in device pixels) for Bezier
147    ///   subdivision. Smaller values produce more accurate curves but more
148    ///   segments. Typical range: `0.1`–`1.0`.
149    /// - `close_subpaths`: if `true`, an implicit closing segment is added from
150    ///   the last point of each subpath back to its first point when they do not
151    ///   already coincide (matches `SplashXPath` constructor behaviour).
152    ///
153    /// # Ordering constraints
154    ///
155    /// After this call, `segs` is in insertion order (one entry per flattened
156    /// edge). No further sorting is performed here; callers that need a sorted
157    /// edge table must sort `segs` themselves or use `XPathScanner`.
158    ///
159    /// Calling [`XPath::aa_scale`] after this method scales all coordinates by
160    /// [`AA_SIZE`]. It must be called at most once.
161    #[must_use]
162    pub fn new(path: &Path, matrix: &[f64; 6], flatness: f64, close_subpaths: bool) -> Self {
163        let flatness_sq = flatness * flatness;
164        let mut xpath = Self {
165            segs: Vec::new(),
166            curve_data: None,
167        };
168
169        // Transform every path point into device space.
170        let tpts: Vec<PathPoint> = path
171            .pts
172            .iter()
173            .map(|p| transform(matrix, p.x, p.y))
174            .collect();
175
176        // Thin-line stroke adjustment (the `adjust_lines=true` branch of
177        // `XPathAdjust::new`) has no caller today; pass the no-op values.
178        let adjusts = build_adjusts(&path.hints, &tpts, false, 0);
179
180        // Apply stroke adjustments to the transformed points.
181        let mut tpts = tpts;
182        for adj in &adjusts {
183            // Safety: build_adjusts validates that first_pt and last_pt are
184            // within bounds before constructing the XPathAdjust records, so
185            // this slice index cannot panic.
186            debug_assert!(
187                adj.last_pt < tpts.len(),
188                "adj.last_pt ({}) out of bounds (tpts.len() = {})",
189                adj.last_pt,
190                tpts.len()
191            );
192            for pt in &mut tpts[adj.first_pt..=adj.last_pt] {
193                let (x, y) = (&mut pt.x, &mut pt.y);
194                stroke_adjust(adj, x, y);
195            }
196        }
197
198        // Walk the path and emit segments.
199        let n = path.pts.len();
200        let mut i = 0usize;
201        while i < n {
202            if path.flags[i].contains(PathFlags::FIRST) {
203                // Start of a new subpath.
204                let sp_x = tpts[i].x;
205                let sp_y = tpts[i].y;
206                let mut cur_x = sp_x;
207                let mut cur_y = sp_y;
208                i += 1;
209                while i < n {
210                    if path.flags[i].contains(PathFlags::CURVE) {
211                        // Cubic Bezier: consume 3 points (2 control + 1 endpoint).
212                        if i + 2 >= n {
213                            break;
214                        }
215                        let p0 = PathPoint::new(cur_x, cur_y);
216                        let p1 = tpts[i];
217                        let p2 = tpts[i + 1];
218                        let p3 = tpts[i + 2];
219                        let mut flat_pts = Vec::new();
220                        flatten_curve(
221                            p0,
222                            p1,
223                            p2,
224                            p3,
225                            flatness_sq,
226                            &mut flat_pts,
227                            &mut xpath.curve_data,
228                        );
229                        for fp in &flat_pts {
230                            xpath.add_segment(cur_x, cur_y, fp.x, fp.y);
231                            cur_x = fp.x;
232                            cur_y = fp.y;
233                        }
234                        i += 3;
235                    } else {
236                        // Line segment.
237                        let nx = tpts[i].x;
238                        let ny = tpts[i].y;
239                        xpath.add_segment(cur_x, cur_y, nx, ny);
240                        cur_x = nx;
241                        cur_y = ny;
242                        let is_last = path.flags[i].contains(PathFlags::LAST);
243                        i += 1;
244                        if is_last {
245                            break;
246                        }
247                    }
248                }
249                // Closing segment if requested and the subpath is not already closed.
250                if close_subpaths && ((cur_x - sp_x).abs() > 1e-10 || (cur_y - sp_y).abs() > 1e-10)
251                {
252                    xpath.add_segment(cur_x, cur_y, sp_x, sp_y);
253                }
254            } else {
255                i += 1;
256            }
257        }
258
259        xpath
260    }
261
262    /// Scale all segment coordinates by [`AA_SIZE`] for supersampled anti-aliasing.
263    ///
264    /// `dxdy` (the slope) is invariant under uniform scaling and is **not** modified —
265    /// a uniform scale cancels out in `(x1-x0)/(y1-y0)`.
266    ///
267    /// Matches `SplashXPath::aaScale()`.
268    ///
269    /// # Ordering constraint
270    ///
271    /// This method must be called **after** [`XPath::new`] and **at most once**.
272    /// Calling it a second time multiplies coordinates by [`AA_SIZE`] again,
273    /// which will produce incorrect scan-conversion results. There is no runtime
274    /// guard against double-scaling.
275    ///
276    /// # Panics
277    ///
278    /// Does not panic in practice. However, if any coordinate is so large that
279    /// multiplying by [`AA_SIZE`] would overflow to `f64::INFINITY`, subsequent
280    /// scan-conversion arithmetic will silently produce wrong results. A
281    /// `debug_assert!` fires in debug builds if any coordinate is non-finite
282    /// before scaling.
283    pub fn aa_scale(&mut self) {
284        let s = f64::from(AA_SIZE);
285        for seg in &mut self.segs {
286            debug_assert!(
287                seg.x0.is_finite()
288                    && seg.y0.is_finite()
289                    && seg.x1.is_finite()
290                    && seg.y1.is_finite(),
291                "aa_scale: segment coordinates must be finite before scaling \
292                 (x0={}, y0={}, x1={}, y1={})",
293                seg.x0,
294                seg.y0,
295                seg.x1,
296                seg.y1,
297            );
298            seg.x0 *= s;
299            seg.y0 *= s;
300            seg.x1 *= s;
301            seg.y1 *= s;
302        }
303    }
304
305    // ── Private helpers ───────────────────────────────────────────────────────
306
307    /// Append one line segment to the edge table, enforcing `y0 ≤ y1` for
308    /// non-horizontal segments and computing `dxdy`.
309    ///
310    /// # y0 ≤ y1 invariant
311    ///
312    /// If the supplied `y0 > y1`, the endpoints are swapped and
313    /// [`XPathFlags::FLIPPED`] is set. Horizontal segments (`y0 == y1`) are
314    /// never flipped.
315    ///
316    /// # Division safety
317    ///
318    /// `dxdy = (x1-x0)/(y1-y0)` is computed only for segments that are neither
319    /// horizontal nor vertical. The HORIZ early-return guarantees `y0 ≠ y1` for
320    /// any segment that reaches this computation, so division by zero cannot
321    /// occur. A `debug_assert!` enforces this contract in debug builds.
322    ///
323    /// Matches `SplashXPath::addSegment` in `SplashXPath.cc`.
324    fn add_segment(&mut self, mut x0: f64, mut y0: f64, mut x1: f64, mut y1: f64) {
325        let mut flags = XPathFlags::empty();
326
327        // Exact bit-equality is intentional: checking for axis-aligned segments
328        // that were constructed with the same coordinate value.
329        if y0.to_bits() == y1.to_bits() {
330            // Horizontal segment: y0 == y1, dxdy is undefined; set to 0.0.
331            flags.insert(XPathFlags::HORIZ);
332            if x0.to_bits() == x1.to_bits() {
333                flags.insert(XPathFlags::VERT);
334            }
335            self.segs.push(XPathSeg {
336                x0,
337                y0,
338                x1,
339                y1,
340                dxdy: 0.0,
341                dxdy_fp: 0,
342                flags,
343            });
344            return; // Horizontal segments are NOT flipped.
345        }
346
347        // Non-horizontal: y0 ≠ y1 is guaranteed by the early return above.
348        if x0.to_bits() == x1.to_bits() {
349            flags.insert(XPathFlags::VERT);
350        }
351
352        // Compute slope before the potential swap so that the sign is
353        // consistent with the *original* orientation. After the swap below,
354        // the stored dxdy is the slope in the y0-ascending direction.
355        let dxdy = if flags.contains(XPathFlags::VERT) {
356            0.0
357        } else {
358            // Division is safe: HORIZ guard above guarantees y1 - y0 ≠ 0.0.
359            debug_assert_ne!(
360                y1.to_bits(),
361                y0.to_bits(),
362                "add_segment: y0 == y1 must be caught by the HORIZ branch"
363            );
364            (x1 - x0) / (y1 - y0)
365        };
366
367        if y0 > y1 {
368            std::mem::swap(&mut x0, &mut x1);
369            std::mem::swap(&mut y0, &mut y1);
370            flags.insert(XPathFlags::FLIPPED);
371        }
372
373        // Clamp dxdy_fp to i32 range.  The only way `dxdy * 65536` overflows i32
374        // is if the segment spans more than ~32768 pixels horizontally per pixel
375        // vertically — physically impossible for any realistic page.
376        let dxdy_fp = {
377            let fp = dxdy * 65536.0;
378            if fp >= f64::from(i32::MAX) {
379                i32::MAX
380            } else if fp <= f64::from(i32::MIN) {
381                i32::MIN
382            } else {
383                #[expect(
384                    clippy::cast_possible_truncation,
385                    reason = "fp is bounds-checked to [i32::MIN, i32::MAX] by the branches above"
386                )]
387                {
388                    fp.round() as i32
389                }
390            }
391        };
392
393        self.segs.push(XPathSeg {
394            x0,
395            y0,
396            x1,
397            y1,
398            dxdy,
399            dxdy_fp,
400            flags,
401        });
402    }
403}
404
405// ── Affine transform ──────────────────────────────────────────────────────────
406
407/// Apply a 2-D affine matrix to point `(xi, yi)`, returning the transformed
408/// [`PathPoint`] in device space.
409///
410/// Column-vector convention matching `SplashXPath::transform`:
411///
412/// ```text
413/// x_out = xi*m[0] + yi*m[2] + m[4]
414/// y_out = xi*m[1] + yi*m[3] + m[5]
415/// ```
416///
417/// Uses `f64::mul_add` for fused multiply-add, giving one rounding error per
418/// term rather than two.
419#[inline]
420#[must_use]
421pub const fn transform(m: &[f64; 6], xi: f64, yi: f64) -> PathPoint {
422    PathPoint::new(
423        xi.mul_add(m[0], yi.mul_add(m[2], m[4])),
424        xi.mul_add(m[1], yi.mul_add(m[3], m[5])),
425    )
426}
427
428// ── Stroke adjust record construction ────────────────────────────────────────
429
430/// Build [`XPathAdjust`] records from path hints and transformed points.
431///
432/// Mirrors the hint-processing loop in the `SplashXPath` constructor.
433///
434/// Only axis-aligned hint pairs (both edges strictly horizontal or both
435/// strictly vertical after transformation) are converted to adjust records;
436/// skewed pairs are silently dropped, matching the C++ behaviour.
437///
438/// Hints whose control-point indices are out of range for `tpts` are also
439/// silently dropped rather than panicking, so that malformed PDF content
440/// cannot cause crashes.
441///
442/// # Parameters `adjust_lines` and `line_pos_i`
443///
444/// Forwarded verbatim to [`XPathAdjust::new`], where they drive the thin-line
445/// snap branch (when both rounded endpoints coincide and `adjust_lines` is
446/// true, the span is expanded to `[line_pos_i, line_pos_i + 1]`). Every
447/// caller in the tree passes `(false, 0)`, which is the no-op configuration;
448/// the assert below pins that contract so an unexpected caller surfaces in
449/// debug builds rather than silently producing thin-line-snapped output.
450fn build_adjusts(
451    hints: &[StrokeAdjustHint],
452    tpts: &[PathPoint],
453    adjust_lines: bool,
454    line_pos_i: i32,
455) -> Vec<XPathAdjust> {
456    debug_assert!(
457        !adjust_lines && line_pos_i == 0,
458        "build_adjusts: only the no-op configuration (false, 0) is reachable today; \
459         got adjust_lines={adjust_lines} line_pos_i={line_pos_i}"
460    );
461    let mut adjusts = Vec::with_capacity(hints.len());
462    for h in hints {
463        // Validate indices: each hint references two consecutive point pairs.
464        // ctrl0+1 and ctrl1+1 must both be valid indices into tpts.
465        if h.ctrl0 + 1 >= tpts.len() || h.ctrl1 + 1 >= tpts.len() {
466            continue;
467        }
468        let p00 = tpts[h.ctrl0];
469        let p01 = tpts[h.ctrl0 + 1];
470        let p10 = tpts[h.ctrl1];
471        let p11 = tpts[h.ctrl1 + 1];
472        // Determine orientation using bit-exact comparison (axis-aligned check).
473        let vert = (p00.x.to_bits() == p01.x.to_bits()) && (p10.x.to_bits() == p11.x.to_bits());
474        let horiz = (p00.y.to_bits() == p01.y.to_bits()) && (p10.y.to_bits() == p11.y.to_bits());
475        if !vert && !horiz {
476            continue;
477        }
478        // The two coordinates to snap: take min/max so adj0 ≤ adj1 always.
479        let (a0, a1) = if vert {
480            (p00.x.min(p10.x), p00.x.max(p10.x))
481        } else {
482            (p00.y.min(p10.y), p00.y.max(p10.y))
483        };
484        adjusts.push(XPathAdjust::new(
485            h.first_pt,
486            h.last_pt,
487            vert,
488            a0,
489            a1,
490            adjust_lines,
491            line_pos_i,
492        ));
493    }
494    adjusts
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::path::PathBuilder;
501
502    fn identity() -> [f64; 6] {
503        [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
504    }
505
506    #[test]
507    fn horizontal_segment_not_flipped() {
508        let mut xpath = XPath {
509            segs: Vec::new(),
510            curve_data: None,
511        };
512        xpath.add_segment(0.0, 5.0, 10.0, 5.0);
513        let s = &xpath.segs[0];
514        assert!(s.flags.contains(XPathFlags::HORIZ));
515        assert!(!s.flags.contains(XPathFlags::FLIPPED));
516        assert!((s.y0 - 5.0).abs() < f64::EPSILON);
517        assert!((s.y1 - 5.0).abs() < f64::EPSILON);
518    }
519
520    #[test]
521    fn downward_segment_flipped() {
522        let mut xpath = XPath {
523            segs: Vec::new(),
524            curve_data: None,
525        };
526        xpath.add_segment(0.0, 10.0, 0.0, 0.0); // y0 > y1 → flip
527        let s = &xpath.segs[0];
528        assert!(s.flags.contains(XPathFlags::FLIPPED));
529        assert!(s.y0 <= s.y1, "y0={} y1={}", s.y0, s.y1);
530    }
531
532    #[test]
533    fn aa_scale_multiplies_coords() {
534        let mut xpath = XPath {
535            segs: Vec::new(),
536            curve_data: None,
537        };
538        xpath.add_segment(1.0, 0.0, 3.0, 2.0);
539        let orig_dxdy = xpath.segs[0].dxdy;
540        xpath.aa_scale();
541        let s = &xpath.segs[0];
542        assert!((s.x0 - 4.0).abs() < 1e-10);
543        assert!((s.y0 - 0.0).abs() < 1e-10);
544        assert!((s.x1 - 12.0).abs() < 1e-10);
545        assert!((s.y1 - 8.0).abs() < 1e-10);
546        assert!(
547            (s.dxdy - orig_dxdy).abs() < 1e-10,
548            "dxdy should be unchanged"
549        );
550    }
551
552    #[test]
553    fn vertical_segment_dxdy_zero() {
554        let mut xpath = XPath {
555            segs: Vec::new(),
556            curve_data: None,
557        };
558        xpath.add_segment(5.0, 0.0, 5.0, 10.0);
559        let s = &xpath.segs[0];
560        assert!(s.flags.contains(XPathFlags::VERT));
561        assert!(s.dxdy.abs() < f64::EPSILON);
562    }
563
564    #[test]
565    fn triangle_from_path() {
566        let mut b = PathBuilder::new();
567        b.move_to(0.0, 0.0).unwrap();
568        b.line_to(4.0, 0.0).unwrap();
569        b.line_to(2.0, 4.0).unwrap();
570        b.close(false).unwrap();
571        let path = b.build();
572        let xpath = XPath::new(&path, &identity(), 1.0, false);
573        // 3 explicit segments + 1 closing → but PathBuilder's close() already
574        // adds the closing lineTo, so 3 segments total.
575        assert_eq!(xpath.segs.len(), 3);
576    }
577
578    /// A degenerate point segment (x0==x1, y0==y1) should set both HORIZ and
579    /// VERT flags and not panic.
580    #[test]
581    fn degenerate_point_segment() {
582        let mut xpath = XPath {
583            segs: Vec::new(),
584            curve_data: None,
585        };
586        xpath.add_segment(3.0, 7.0, 3.0, 7.0);
587        let s = &xpath.segs[0];
588        assert!(s.flags.contains(XPathFlags::HORIZ));
589        assert!(s.flags.contains(XPathFlags::VERT));
590        assert!(!s.flags.contains(XPathFlags::FLIPPED));
591        assert_eq!(s.dxdy.to_bits(), 0.0_f64.to_bits());
592    }
593
594    /// `dxdy` must equal `(x1-x0)/(y1-y0)` for a sloped segment.
595    #[test]
596    fn sloped_segment_dxdy() {
597        let mut xpath = XPath {
598            segs: Vec::new(),
599            curve_data: None,
600        };
601        // y0 < y1, not vertical → dxdy = (6-2)/(5-1) = 1.0
602        xpath.add_segment(2.0, 1.0, 6.0, 5.0);
603        let s = &xpath.segs[0];
604        assert!(!s.flags.contains(XPathFlags::HORIZ));
605        assert!(!s.flags.contains(XPathFlags::VERT));
606        assert!(!s.flags.contains(XPathFlags::FLIPPED));
607        assert!((s.dxdy - 1.0).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
608    }
609
610    /// A flipped sloped segment must have the same absolute dxdy value as its
611    /// unflipped counterpart, and must satisfy y0 ≤ y1 after construction.
612    #[test]
613    fn flipped_sloped_segment_dxdy_consistent() {
614        let mut xpath = XPath {
615            segs: Vec::new(),
616            curve_data: None,
617        };
618        // Supply (x0=6, y0=5) → (x1=2, y1=1): y0 > y1, so it will be flipped.
619        xpath.add_segment(6.0, 5.0, 2.0, 1.0);
620        let s = &xpath.segs[0];
621        assert!(s.flags.contains(XPathFlags::FLIPPED));
622        assert!(
623            s.y0 <= s.y1,
624            "y0 ≤ y1 invariant violated: y0={} y1={}",
625            s.y0,
626            s.y1
627        );
628        // dxdy = (x1_orig - x0_orig)/(y1_orig - y0_orig) = (2-6)/(1-5) = 1.0
629        assert!((s.dxdy - 1.0).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
630    }
631
632    #[test]
633    fn dxdy_fp_matches_dxdy_for_slope_one() {
634        // Slope = 1.0 → dxdy_fp should be 65536.
635        let mut xpath = XPath {
636            segs: Vec::new(),
637            curve_data: None,
638        };
639        xpath.add_segment(0.0, 0.0, 4.0, 4.0);
640        let s = &xpath.segs[0];
641        assert!((s.dxdy - 1.0).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
642        assert_eq!(s.dxdy_fp, 65536, "dxdy_fp={}", s.dxdy_fp);
643    }
644
645    #[test]
646    fn dxdy_fp_matches_dxdy_for_half_slope() {
647        // Slope = 0.5 → dxdy_fp should be 32768.
648        let mut xpath = XPath {
649            segs: Vec::new(),
650            curve_data: None,
651        };
652        xpath.add_segment(0.0, 0.0, 2.0, 4.0);
653        let s = &xpath.segs[0];
654        assert!((s.dxdy - 0.5).abs() < f64::EPSILON, "dxdy={}", s.dxdy);
655        assert_eq!(s.dxdy_fp, 32768, "dxdy_fp={}", s.dxdy_fp);
656    }
657
658    #[test]
659    fn dxdy_fp_zero_for_horizontal() {
660        let mut xpath = XPath {
661            segs: Vec::new(),
662            curve_data: None,
663        };
664        xpath.add_segment(0.0, 2.0, 5.0, 2.0);
665        let s = &xpath.segs[0];
666        assert!(s.flags.contains(XPathFlags::HORIZ));
667        assert_eq!(s.dxdy_fp, 0);
668    }
669
670    #[test]
671    fn dxdy_fp_zero_for_vertical() {
672        let mut xpath = XPath {
673            segs: Vec::new(),
674            curve_data: None,
675        };
676        xpath.add_segment(3.0, 0.0, 3.0, 5.0);
677        let s = &xpath.segs[0];
678        assert!(s.flags.contains(XPathFlags::VERT));
679        assert_eq!(s.dxdy_fp, 0);
680    }
681}