Skip to main content

rasterrocket_render/path/
mod.rs

1//! PDF path geometry: points, flags, subpath state machine, and builder API.
2//!
3//! The `pts` and `flags` arrays are parallel `SoA` (one entry per index).  This
4//! layout originates in `SplashPath` from `splash/SplashPath.{h,cc}`, but is
5//! preserved deliberately rather than ported: `xpath::XPath::new` transforms
6//! the entire `pts` array under a CTM in one pass before anything reads
7//! `flags`, and the contiguous f64-pair layout keeps that pre-pass tight.
8//! Walkers that read both arrays together (`xpath.rs`, `stroke/path.rs`)
9//! pay one extra L1-resident byte per index, which is negligible.
10//!
11//! ## Subpath state machine
12//!
13//! A [`Path`] is always in one of three states (matching the C++ comments in
14//! `SplashPath.cc`):
15//!
16//! | State            | Condition                        | Meaning                          |
17//! |------------------|----------------------------------|----------------------------------|
18//! | No current point | `cur_subpath == pts.len()`       | Fresh path or just after close   |
19//! | One-point subpath| `cur_subpath == pts.len() - 1`   | After moveTo, before lineTo      |
20//! | Open subpath     | `cur_subpath < pts.len() - 1`    | Active path with ≥ 2 points      |
21//!
22//! The `one_point_subpath` and `open_subpath` predicates both guard against
23//! `pts.is_empty()` before comparing with `pts.len() - 1`, so neither can
24//! underflow on an empty vector.
25
26pub mod adjust;
27pub mod flatten;
28
29use bitflags::bitflags;
30
31// ── Types ─────────────────────────────────────────────────────────────────────
32
33/// A 2-D point in path space (f64 coordinates, matching `SplashPathPoint`).
34#[derive(Copy, Clone, Debug, PartialEq)]
35pub struct PathPoint {
36    /// Horizontal coordinate.
37    pub x: f64,
38    /// Vertical coordinate.
39    pub y: f64,
40}
41
42impl PathPoint {
43    /// Construct a new point.
44    #[must_use]
45    pub const fn new(x: f64, y: f64) -> Self {
46        Self { x, y }
47    }
48}
49
50impl From<(f64, f64)> for PathPoint {
51    /// Convert a `(x, y)` tuple into a [`PathPoint`].
52    fn from((x, y): (f64, f64)) -> Self {
53        Self { x, y }
54    }
55}
56
57impl From<PathPoint> for (f64, f64) {
58    /// Destructure a [`PathPoint`] into a `(x, y)` tuple.
59    fn from(p: PathPoint) -> Self {
60        (p.x, p.y)
61    }
62}
63
64bitflags! {
65    /// Per-point flags stored in the parallel `flags` array of a [`Path`].
66    ///
67    /// Matches the `splashPathFirst/Last/Closed/Curve` constants in
68    /// `SplashPath.h`.
69    #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
70    pub struct PathFlags: u8 {
71        /// First point of a subpath (set on the `moveTo` point).
72        const FIRST  = 0x01;
73        /// Last point of a subpath (set on every newly appended endpoint).
74        const LAST   = 0x02;
75        /// Subpath is closed (set on both the first **and** last point).
76        const CLOSED = 0x04;
77        /// This point is a cubic Bezier control point, not an on-curve endpoint.
78        const CURVE  = 0x08;
79    }
80}
81
82impl PathFlags {
83    /// Returns `true` if this point is the first of its subpath.
84    #[must_use]
85    pub const fn is_first(self) -> bool {
86        self.contains(Self::FIRST)
87    }
88
89    /// Returns `true` if this point is the last of its subpath.
90    #[must_use]
91    pub const fn is_last(self) -> bool {
92        self.contains(Self::LAST)
93    }
94
95    /// Returns `true` if the subpath containing this point is closed.
96    #[must_use]
97    pub const fn is_closed(self) -> bool {
98        self.contains(Self::CLOSED)
99    }
100
101    /// Returns `true` if this point is a Bezier control point (off-curve).
102    #[must_use]
103    pub const fn is_curve(self) -> bool {
104        self.contains(Self::CURVE)
105    }
106}
107
108/// Stroke-adjust hint: a pair of path segments that should be snapped to
109/// integer pixel boundaries to avoid seams between adjacent filled rectangles.
110///
111/// Matches `SplashPathHint` in `SplashPath.h`.
112#[derive(Copy, Clone, Debug)]
113pub struct StrokeAdjustHint {
114    /// Index (into [`Path::pts`]) of the first control segment.
115    pub ctrl0: usize,
116    /// Index (into [`Path::pts`]) of the second control segment.
117    pub ctrl1: usize,
118    /// First point of the range to adjust (inclusive, index into [`Path::pts`]).
119    pub first_pt: usize,
120    /// Last point of the range to adjust (inclusive, index into [`Path::pts`]).
121    pub last_pt: usize,
122}
123
124/// Errors returned by [`PathBuilder`] construction methods.
125#[derive(Copy, Clone, Debug, PartialEq, Eq)]
126pub enum PathError {
127    /// `lineTo`, `curveTo`, or `close` was called when there is no current
128    /// point (the path is fresh or was just closed).
129    ///
130    /// Callers should ensure a preceding `move_to` succeeded before calling
131    /// drawing operators.
132    NoCurPt,
133    /// `moveTo` was called while a one-point subpath is active (a `moveTo`
134    /// was immediately followed by another `moveTo` with no drawing operator
135    /// in between).
136    ///
137    /// Callers should either draw at least one segment or close the current
138    /// subpath before beginning a new one.
139    BogusPath,
140}
141
142impl std::fmt::Display for PathError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            Self::NoCurPt => f.write_str(
146                "path error: no current point \
147                 (call move_to before line_to, curve_to, or close)",
148            ),
149            Self::BogusPath => f.write_str(
150                "path error: consecutive moveTo without a drawing operator \
151                 (a one-point subpath is already active)",
152            ),
153        }
154    }
155}
156
157impl std::error::Error for PathError {}
158
159// ── Path ──────────────────────────────────────────────────────────────────────
160
161/// A PDF graphics path: an ordered sequence of subpaths built from lines and
162/// cubic Bezier curves.
163///
164/// # Invariants
165///
166/// These match `SplashPath.cc`:
167///
168/// - `pts.len() == flags.len()` at all times.
169/// - `cur_subpath` is the index of the first point of the currently open
170///   subpath, or equals `pts.len()` when there is no current point.
171/// - Control points (flag [`PathFlags::CURVE`]) always appear in groups of
172///   three between two on-curve points.
173#[derive(Clone, Debug, Default)]
174pub struct Path {
175    /// Ordered sequence of path points.
176    pub pts: Vec<PathPoint>,
177    /// Per-point flags, parallel to [`Self::pts`].
178    pub flags: Vec<PathFlags>,
179    /// Optional stroke-adjust hints.
180    pub hints: Vec<StrokeAdjustHint>,
181    /// Index of the first point of the currently open subpath.
182    ///
183    /// Equals `pts.len()` when there is no current point (fresh path or
184    /// immediately after a `close`).
185    pub cur_subpath: usize,
186}
187
188impl Path {
189    /// Create an empty path with no current point.
190    #[must_use]
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    // ── State predicates ──────────────────────────────────────────────────────
196
197    /// Returns `true` when there is no current point.
198    ///
199    /// This is the case for a freshly created path and immediately after
200    /// [`PathBuilder::close`] completes.
201    #[inline]
202    #[must_use]
203    pub const fn no_current_point(&self) -> bool {
204        self.cur_subpath == self.pts.len()
205    }
206
207    /// Returns `true` after exactly one [`PathBuilder::move_to`] with no
208    /// subsequent [`PathBuilder::line_to`] or [`PathBuilder::curve_to`].
209    ///
210    /// # Underflow safety
211    ///
212    /// The `!self.pts.is_empty()` guard ensures `pts.len() - 1` is evaluated
213    /// only when `pts` has at least one element, so no wrapping subtraction
214    /// can occur.
215    #[inline]
216    #[must_use]
217    pub const fn one_point_subpath(&self) -> bool {
218        !self.pts.is_empty() && self.cur_subpath == self.pts.len() - 1
219    }
220
221    /// Returns `true` when the current subpath has at least two points.
222    #[inline]
223    #[must_use]
224    pub const fn open_subpath(&self) -> bool {
225        !self.pts.is_empty() && self.cur_subpath < self.pts.len() - 1
226    }
227
228    /// Returns the current point (last appended endpoint), if any.
229    ///
230    /// Returns `None` when [`Self::no_current_point`] is true — i.e. on a
231    /// fresh path **and** immediately after a `close` (because `close` sets
232    /// `cur_subpath` to `pts.len()`).
233    #[must_use]
234    pub fn current_point(&self) -> Option<PathPoint> {
235        if self.no_current_point() {
236            None
237        } else {
238            self.pts.last().copied()
239        }
240    }
241
242    // ── Geometry ──────────────────────────────────────────────────────────────
243
244    /// Translate every point in the path by `(dx, dy)`.
245    pub fn offset(&mut self, dx: f64, dy: f64) {
246        for p in &mut self.pts {
247            p.x += dx;
248            p.y += dy;
249        }
250    }
251
252    /// Append all points and hints from `other` into `self`.
253    ///
254    /// `cur_subpath` is set to `self.pts.len() + other.cur_subpath` **before**
255    /// appending, matching `SplashPath::append`.
256    ///
257    /// # Edge cases
258    ///
259    /// If `other` is empty (`other.pts.is_empty()`), then
260    /// `other.cur_subpath == 0` (the default) and
261    /// `self.cur_subpath` is set to `self.pts.len()` — the "no current point"
262    /// sentinel — which is correct: appending an empty path does not create a
263    /// current point.
264    pub fn append(&mut self, other: &Self) {
265        debug_assert!(
266            other.cur_subpath <= other.pts.len(),
267            "append: other.cur_subpath ({}) exceeds other.pts.len() ({}); invariant broken",
268            other.cur_subpath,
269            other.pts.len()
270        );
271        let base = self.pts.len();
272        self.cur_subpath = base + other.cur_subpath;
273        self.pts.extend_from_slice(&other.pts);
274        self.flags.extend_from_slice(&other.flags);
275        for h in &other.hints {
276            self.hints.push(StrokeAdjustHint {
277                ctrl0: h.ctrl0 + base,
278                ctrl1: h.ctrl1 + base,
279                first_pt: h.first_pt + base,
280                last_pt: h.last_pt + base,
281            });
282        }
283    }
284}
285
286// ── PathBuilder ───────────────────────────────────────────────────────────────
287
288/// Ergonomic builder for [`Path`] implementing the PDF path construction
289/// operators (`m`, `l`, `c`, `h`) with the same state-machine semantics as
290/// `SplashPath::moveTo` / `lineTo` / `curveTo` / `close`.
291pub struct PathBuilder {
292    path: Path,
293}
294
295impl PathBuilder {
296    /// Create a new, empty builder with no current point.
297    #[must_use]
298    pub fn new() -> Self {
299        Self { path: Path::new() }
300    }
301
302    /// Begin a new subpath at `(x, y)`. Equivalent to the PDF `m` operator.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`PathError::BogusPath`] if a one-point subpath is already
307    /// active (i.e. the previous operation was also a `move_to` with no
308    /// drawing operator in between). Callers must not silently ignore this
309    /// error: it indicates a malformed path construction sequence.
310    pub fn move_to(&mut self, x: f64, y: f64) -> Result<(), PathError> {
311        if self.path.one_point_subpath() {
312            return Err(PathError::BogusPath);
313        }
314        let len = self.path.pts.len();
315        self.path.pts.push(PathPoint::new(x, y));
316        self.path.flags.push(PathFlags::FIRST | PathFlags::LAST);
317        self.path.cur_subpath = len;
318        Ok(())
319    }
320
321    /// Add a line segment from the current point to `(x, y)`.
322    ///
323    /// Equivalent to the PDF `l` operator.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`PathError::NoCurPt`] if there is no current point. Callers
328    /// must ensure a successful [`Self::move_to`] precedes this call.
329    ///
330    /// # Panics
331    ///
332    /// Panics if `flags` is empty despite a current point existing, which
333    /// would indicate a broken `Path` invariant (`pts.len() == flags.len()`).
334    pub fn line_to(&mut self, x: f64, y: f64) -> Result<(), PathError> {
335        if self.path.no_current_point() {
336            return Err(PathError::NoCurPt);
337        }
338        // Clear LAST on the previous endpoint before appending the new one.
339        let last = self.path.flags.last_mut().unwrap();
340        last.remove(PathFlags::LAST);
341        self.path.pts.push(PathPoint::new(x, y));
342        self.path.flags.push(PathFlags::LAST);
343        Ok(())
344    }
345
346    /// Add a cubic Bezier curve. Equivalent to the PDF `c` operator.
347    ///
348    /// `(x1, y1)` and `(x2, y2)` are the two off-curve control points;
349    /// `(x3, y3)` is the on-curve endpoint. Three points are always appended:
350    /// the control points receive [`PathFlags::CURVE`] and the endpoint
351    /// receives [`PathFlags::LAST`].
352    ///
353    /// # Errors
354    ///
355    /// Returns [`PathError::NoCurPt`] if there is no current point. Callers
356    /// must ensure a successful [`Self::move_to`] precedes this call.
357    ///
358    /// # Panics
359    ///
360    /// Panics if `flags` is empty despite a current point existing, which
361    /// would indicate a broken `Path` invariant (`pts.len() == flags.len()`).
362    pub fn curve_to(
363        &mut self,
364        x1: f64,
365        y1: f64,
366        x2: f64,
367        y2: f64,
368        x3: f64,
369        y3: f64,
370    ) -> Result<(), PathError> {
371        if self.path.no_current_point() {
372            return Err(PathError::NoCurPt);
373        }
374        // Clear LAST on the previous endpoint.
375        let last = self.path.flags.last_mut().unwrap();
376        last.remove(PathFlags::LAST);
377        // Two off-curve control points tagged CURVE, then the on-curve endpoint.
378        self.path.pts.push(PathPoint::new(x1, y1));
379        self.path.flags.push(PathFlags::CURVE);
380        self.path.pts.push(PathPoint::new(x2, y2));
381        self.path.flags.push(PathFlags::CURVE);
382        self.path.pts.push(PathPoint::new(x3, y3));
383        self.path.flags.push(PathFlags::LAST);
384        Ok(())
385    }
386
387    /// Close the current subpath. Equivalent to the PDF `h` operator.
388    ///
389    /// Behaviour:
390    ///
391    /// - If `force` is `true`, a closing `lineTo(first)` is **always**
392    ///   appended.
393    /// - If `sp == last_idx` the subpath consists of exactly one point (the
394    ///   `moveTo` with no drawing operators).  In this degenerate case the
395    ///   subpath is trivially "closed" (first == last by identity), so no
396    ///   extra `lineTo` is needed — the single point has [`PathFlags::CLOSED`]
397    ///   set on itself.  This matches the C++ `SplashPath::close` behaviour.
398    /// - Otherwise, a closing `lineTo(first)` is appended only when
399    ///   `first != last` (the path is not already closed geometrically).
400    ///
401    /// After closing, `cur_subpath` is advanced to `pts.len()` (the
402    /// "no current point" sentinel), so [`Path::current_point`] returns `None`
403    /// until the next `move_to`.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`PathError::NoCurPt`] if there is no current point. The `?`
408    /// inside this method propagates any error from the internal `line_to`
409    /// call; since `line_to` only errors on `NoCurPt` and we have already
410    /// verified a current point exists at entry, that propagation path is only
411    /// reachable if an invariant is broken.
412    pub fn close(&mut self, force: bool) -> Result<(), PathError> {
413        if self.path.no_current_point() {
414            return Err(PathError::NoCurPt);
415        }
416        let sp = self.path.cur_subpath;
417        let last_idx = self.path.pts.len() - 1;
418        let first = self.path.pts[sp];
419        let last = self.path.pts[last_idx];
420
421        // Add a closing lineTo(first) when:
422        //   • `force` is set, OR
423        //   • the subpath has exactly one point (sp == last_idx) — no lineTo
424        //     is needed but we still fall through to stamp CLOSED, OR
425        //   • first != last (not yet geometrically closed).
426        //
427        // The `sp == last_idx` branch skips the `line_to` call because the
428        // condition is placed *before* the `first != last` check.  The `?`
429        // propagation is correct: we hold a current point so `line_to` can
430        // only fail if the invariant `pts.len() == flags.len()` is broken.
431        if force || (sp != last_idx && first != last) {
432            self.line_to(first.x, first.y)?;
433        }
434        debug_assert_eq!(
435            self.path.pts.len(),
436            self.path.flags.len(),
437            "close: pts/flags length invariant violated"
438        );
439
440        // Stamp CLOSED on the first and last point of the subpath.
441        let new_last = self.path.pts.len() - 1;
442        self.path.flags[sp].insert(PathFlags::CLOSED);
443        self.path.flags[new_last].insert(PathFlags::CLOSED);
444
445        // Advance past this subpath → "no current point" state.
446        self.path.cur_subpath = self.path.pts.len();
447        Ok(())
448    }
449
450    /// Add a stroke-adjust hint referencing existing point indices.
451    ///
452    /// Indices refer to positions in [`Path::pts`] at build time.
453    pub fn add_stroke_adjust_hint(
454        &mut self,
455        ctrl0: usize,
456        ctrl1: usize,
457        first_pt: usize,
458        last_pt: usize,
459    ) {
460        self.path.hints.push(StrokeAdjustHint {
461            ctrl0,
462            ctrl1,
463            first_pt,
464            last_pt,
465        });
466    }
467
468    /// Returns the current point (last appended endpoint), if any.
469    ///
470    /// Returns `None` when there is no current point — i.e. on a freshly
471    /// created builder or immediately after a successful [`Self::close`].
472    /// Delegates to [`Path::current_point`].
473    #[must_use]
474    pub fn cur_pt(&self) -> Option<PathPoint> {
475        self.path.current_point()
476    }
477
478    /// Returns the number of points accumulated in the builder so far.
479    ///
480    /// This is a read-only view used by callers that need to record point
481    /// indices for stroke-adjustment hints (e.g. `raster::stroke::make_stroke_path`).
482    #[must_use]
483    pub const fn pts_len(&self) -> usize {
484        self.path.pts.len()
485    }
486
487    /// Translate every point accumulated so far by `(dx, dy)`.
488    pub fn offset(&mut self, dx: f64, dy: f64) {
489        self.path.offset(dx, dy);
490    }
491
492    /// Consume the builder and return the completed [`Path`].
493    #[must_use]
494    pub fn build(self) -> Path {
495        self.path
496    }
497}
498
499impl Default for PathBuilder {
500    fn default() -> Self {
501        Self::new()
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    // ── State-machine basics ───────────────────────────────────────────────────
510
511    #[test]
512    fn initial_state() {
513        let p = Path::new();
514        assert!(p.no_current_point());
515        assert!(!p.one_point_subpath());
516        assert!(!p.open_subpath());
517    }
518
519    #[test]
520    fn move_to_gives_one_point() {
521        let mut b = PathBuilder::new();
522        b.move_to(1.0, 2.0).unwrap();
523        assert!(b.path.one_point_subpath());
524        assert!(!b.path.open_subpath());
525    }
526
527    #[test]
528    fn line_to_opens_subpath() {
529        let mut b = PathBuilder::new();
530        b.move_to(0.0, 0.0).unwrap();
531        b.line_to(10.0, 0.0).unwrap();
532        assert!(b.path.open_subpath());
533        assert_eq!(b.path.pts.len(), 2);
534    }
535
536    // ── curve_to ──────────────────────────────────────────────────────────────
537
538    #[test]
539    fn curve_to_adds_three_points() {
540        let mut b = PathBuilder::new();
541        b.move_to(0.0, 0.0).unwrap();
542        b.curve_to(1.0, 2.0, 3.0, 4.0, 5.0, 0.0).unwrap();
543        // moveTo + 2 control points + 1 endpoint = 4
544        assert_eq!(b.path.pts.len(), 4);
545        assert!(b.path.flags[1].is_curve());
546        assert!(b.path.flags[2].is_curve());
547        assert!(b.path.flags[3].is_last());
548        assert!(!b.path.flags[3].is_curve());
549    }
550
551    // ── close ─────────────────────────────────────────────────────────────────
552
553    #[test]
554    fn close_sets_closed_flag() {
555        let mut b = PathBuilder::new();
556        b.move_to(0.0, 0.0).unwrap();
557        b.line_to(10.0, 0.0).unwrap();
558        b.line_to(5.0, 5.0).unwrap();
559        b.close(false).unwrap();
560        assert!(b.path.flags[0].is_closed());
561        assert!(b.path.flags.last().unwrap().is_closed());
562        assert!(b.path.no_current_point());
563    }
564
565    /// After a close, `current_point()` must return `None` because `close`
566    /// advances `cur_subpath` to `pts.len()`.
567    #[test]
568    fn after_close_current_point_is_none() {
569        let mut b = PathBuilder::new();
570        b.move_to(0.0, 0.0).unwrap();
571        b.line_to(1.0, 0.0).unwrap();
572        b.close(false).unwrap();
573        assert_eq!(b.path.current_point(), None);
574    }
575
576    /// A one-point subpath (`moveTo` with no drawing operators) should have
577    /// [`PathFlags::CLOSED`] set on that single point when `close` is called.
578    /// The single point acts as both first and last, so both writes target
579    /// `pts[sp]` — which is correct per `SplashPath::close`.
580    #[test]
581    fn close_one_point_subpath_sets_closed_flag() {
582        let mut b = PathBuilder::new();
583        b.move_to(3.0, 4.0).unwrap();
584        assert!(b.path.one_point_subpath());
585        b.close(false).unwrap();
586        // The single point must carry CLOSED.
587        assert!(b.path.flags[0].is_closed());
588        // After close there is no current point.
589        assert!(b.path.no_current_point());
590        // No extra point should have been appended.
591        assert_eq!(b.path.pts.len(), 1);
592    }
593
594    // ── Error paths ───────────────────────────────────────────────────────────
595
596    #[test]
597    fn no_cur_pt_errors() {
598        let mut b = PathBuilder::new();
599        assert_eq!(b.line_to(1.0, 1.0), Err(PathError::NoCurPt));
600        assert_eq!(
601            b.curve_to(1.0, 1.0, 2.0, 2.0, 3.0, 3.0),
602            Err(PathError::NoCurPt)
603        );
604        assert_eq!(b.close(false), Err(PathError::NoCurPt));
605    }
606
607    #[test]
608    fn bogus_path_on_double_moveto() {
609        let mut b = PathBuilder::new();
610        b.move_to(0.0, 0.0).unwrap();
611        assert_eq!(b.move_to(1.0, 1.0), Err(PathError::BogusPath));
612    }
613
614    // ── PathError::Display ────────────────────────────────────────────────────
615
616    #[test]
617    fn path_error_display() {
618        let no_pt = PathError::NoCurPt.to_string();
619        assert!(
620            no_pt.contains("no current point"),
621            "NoCurPt display should mention 'no current point', got: {no_pt}"
622        );
623
624        let bogus = PathError::BogusPath.to_string();
625        assert!(
626            bogus.contains("consecutive moveTo"),
627            "BogusPath display should mention 'consecutive moveTo', got: {bogus}"
628        );
629    }
630
631    // ── From<(f64, f64)> / From<PathPoint> ───────────────────────────────────
632
633    #[test]
634    #[expect(
635        clippy::float_cmp,
636        reason = "testing exact round-trip identity through From impls, not approximate equality"
637    )]
638    fn from_tuple_pathpoint() {
639        let p: PathPoint = (1.5_f64, 2.5_f64).into();
640        assert_eq!(p.x, 1.5);
641        assert_eq!(p.y, 2.5);
642
643        let t: (f64, f64) = p.into();
644        assert_eq!(t, (1.5, 2.5));
645    }
646
647    // ── PathFlags helpers ─────────────────────────────────────────────────────
648
649    #[test]
650    fn path_flags_helpers() {
651        let f = PathFlags::FIRST | PathFlags::LAST | PathFlags::CLOSED | PathFlags::CURVE;
652        assert!(f.is_first());
653        assert!(f.is_last());
654        assert!(f.is_closed());
655        assert!(f.is_curve());
656
657        let empty = PathFlags::empty();
658        assert!(!empty.is_first());
659        assert!(!empty.is_last());
660        assert!(!empty.is_closed());
661        assert!(!empty.is_curve());
662    }
663
664    // ── Path::append ──────────────────────────────────────────────────────────
665
666    #[test]
667    fn append_adjusts_hints() {
668        let mut a = PathBuilder::new();
669        a.move_to(0.0, 0.0).unwrap();
670        a.line_to(1.0, 0.0).unwrap();
671        let pa = a.build();
672
673        let mut b_path = pa.clone();
674        let mut other = pa;
675        other.hints.push(StrokeAdjustHint {
676            ctrl0: 0,
677            ctrl1: 1,
678            first_pt: 0,
679            last_pt: 1,
680        });
681        b_path.append(&other);
682        // Hint indices should be offset by original pa.pts.len() = 2.
683        assert_eq!(b_path.hints[0].ctrl0, 2);
684        assert_eq!(b_path.hints[0].first_pt, 2);
685    }
686
687    /// Appending an empty path must leave `self` in the no-current-point state
688    /// and must not panic.
689    #[test]
690    fn append_empty_other_is_safe() {
691        let mut base = PathBuilder::new();
692        base.move_to(0.0, 0.0).unwrap();
693        base.line_to(1.0, 0.0).unwrap();
694        let mut p = base.build();
695        let original_len = p.pts.len();
696
697        p.append(&Path::new());
698
699        assert_eq!(p.pts.len(), original_len);
700        // cur_subpath == pts.len() → no current point.
701        assert!(p.no_current_point());
702    }
703}