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}