Skip to main content

rlx_core/preset/
path.rs

1//! **An authored silhouette, parsed from inline SVG path data** (ADR-0107).
2//!
3//! Every other silhouette this engine draws is one of five names in the `marks`
4//! roster. This module is the escape hatch: a `[path] d = "M ... Z"` string is
5//! parsed **once at load** into a closed contour of `samples` points, normalized
6//! into the same `[-1, 1]` box a mark lives in, and handed to the scene as
7//! ordinary structural config. Nothing here runs per frame.
8//!
9//! # The subset, and why it has edges
10//!
11//! Accepted: `M m L l H h V v C c S s Q q T t Z z` — moveto, the three line
12//! forms, cubic and quadratic Beziers with their smooth-continuation forms, and
13//! closepath. Two things are **refused by name** rather than approximated:
14//!
15//! - **`A`/`a`, the elliptical arc.** Its centre parameterisation is a
16//!   different geometry from the Bezier forms above, and every design tool can
17//!   export the same curve as cubics.
18//! - **A second subpath.** A morph aligns two contours by arc length, and two
19//!   paths with different subpath counts have no natural correspondence at all
20//!   — so the pair that could not be aligned is refused at the one path rather
21//!   than guessed at the morph (ADR-0107).
22//!
23//! Both refusals name what they found, because an author meeting one is holding
24//! a file a browser renders correctly.
25//!
26//! # The y axis points down, as SVG's does
27//!
28//! A `d` string is read exactly as a browser reads it: `y` increases *downward*,
29//! so `M 0,-1` is above `M 0,1`. The contour this module hands out is in the
30//! engine's frame, where y increases upward, and [`PathShape::parse`] negates
31//! once during normalization to get there.
32//!
33//! The consequence worth stating is the one an author sees: a path pasted out of
34//! a design tool renders the way that tool drew it, with no editing. Every
35//! coordinate below that point — [`PathShape::points`], [`PathShape::signed_area`]
36//! and the morph alignment — is already y-up and needs no further flip.
37//!
38//! # A malformed path is an error with a character offset
39//!
40//! Not a fallback shape. A silently mis-parsed path renders as a *plausible
41//! wrong figure*, which reads as a design decision rather than as a mistake —
42//! so every failure carries the byte offset into `d` where it was found, and
43//! [`PathError`]'s `Display` leads with it.
44//!
45//! # The normalization is recorded, not inferred
46//!
47//! The contour's own tight bounding box is centred on the origin and its longer
48//! axis scaled to exactly `[-1, 1]`; the centre and factor applied are kept on
49//! the [`PathShape`]. Two consequences, and the second is the point: a path
50//! authored at any scale or offset lands in the same place, so **swapping one
51//! path for another does not also move the figure** — the preset's `scale` and
52//! `pan` stay the only things that do.
53
54use std::fmt;
55
56/// The fewest points a resampled contour may carry: a triangle.
57pub const MIN_SAMPLES: usize = 3;
58
59/// The most points a resampled contour may carry.
60///
61/// The field is fullscreen and evaluates a `min` over every segment at **every
62/// pixel of every frame**, so this is a per-pixel `O(N)` budget rather than a
63/// memory one — which is why exceeding it is a load error rather than a silent
64/// decimation.
65///
66/// **The number is measured, and the measurement disagreed with ADR-0107's
67/// construction by an order of magnitude.** `core/tests/path_cost.rs` prices the
68/// contour walk at ~0.105 ms per segment at 1920x1080 on the integrated adapter
69/// `docs/nfr.md` §1's floor is calibrated against; the ADR predicted ~2 % of
70/// such a GPU at 32 segments and measured 26 %. At **64** the field alone is
71/// 46 % of the floor's 16.67 ms frame budget, which is the most that can be
72/// spent while leaving the composite chain room — so this is where the ceiling
73/// sits, and it is the same value as [`DEFAULT_SAMPLES`] because that is where
74/// the two independent answers landed.
75pub const MAX_SAMPLES: usize = 64;
76
77/// The most arc pieces a fitted contour may carry before the fit is discarded
78/// and the figure stays a polyline.
79///
80/// A bound on the uniform the chain rides in, and a bound on the point of doing
81/// it at all: an arc piece costs more per pixel than a line segment, so a fit
82/// that did not collapse the count is not worth evaluating. The measured counts
83/// at the tightest budget below sit at 25 and under.
84pub const MAX_ARC_PIECES: usize = 32;
85
86/// The lateral error the arc fit is held to, in the contour's own normalized
87/// units — one pixel at 1080p for a figure drawn at `scale = 2`.
88///
89/// The fit happens at parse time, where the `scale` the preset will bind is not
90/// known and can move per frame, so the budget is fixed at the **tightest**
91/// figure size an author would reach for. A figure drawn smaller than that is
92/// fitted more finely than it needs, which costs pieces and never fidelity.
93const ARC_FIT_BUDGET: f32 = 1.0 / 1080.0;
94
95/// The arity a `[path]` resamples to when it names none.
96///
97/// The arity at which a *smooth* contour stops reading as faceted: the chord
98/// sagitta of a 64-gon inscribed in the normalized figure is under a pixel at
99/// 1080p, and at 32 it is around two and a half. A polygonal silhouette wants
100/// far fewer and should say so — `samples` is a lever downward, because
101/// [`MAX_SAMPLES`] leaves it none upward.
102pub const DEFAULT_SAMPLES: usize = 64;
103
104/// How finely a Bezier is flattened before the contour is resampled, as a
105/// divisor of the figure's own extent.
106///
107/// Flattening happens *before* normalization, so the step has to be relative to
108/// the source drawing's size, or the same shape authored in a 1000-unit viewBox
109/// and in a 1-unit one would flatten to different fidelity. A subdivision this
110/// fine has a chord error far below one resampled segment at [`MAX_SAMPLES`],
111/// which is what keeps the resample — not the flatten — the thing that sets
112/// fidelity.
113const FLATTEN_PER_EXTENT: f32 = 256.0;
114
115/// The most pieces one Bezier is flattened into, whatever its control polygon
116/// measures. A bound on load-time work for a pathological single curve.
117const MAX_FLATTEN_PER_SEGMENT: usize = 256;
118
119/// Two points closer than this fraction of the figure's extent are the same
120/// point. Consecutive duplicates are dropped before the bounding box is taken,
121/// so a `Z` landing exactly on the start point leaves no zero-length closing
122/// edge for the arc-length walk to divide by.
123const DEDUPE_FRACTION: f32 = 1e-6;
124
125/// Why a `[path] d` string could not be parsed.
126///
127/// Always carries the byte offset into `d` at which the problem was found — see
128/// this module's header for why that is not optional.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct PathError {
131    /// Byte offset into the `d` string at which the problem was found.
132    pub offset: usize,
133    /// What was wrong there.
134    pub kind: PathErrorKind,
135}
136
137/// What was wrong with a `[path] d` string.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum PathErrorKind {
140    /// The elliptical-arc command, which the subset excludes.
141    EllipticalArc(char),
142    /// A second `M`/`m` — the path has more than one subpath.
143    MultipleSubpaths,
144    /// A letter that is not a path command at all.
145    UnknownCommand(char),
146    /// The string did not begin with a moveto.
147    MissingMoveTo,
148    /// A command needed another coordinate and the string ran out, or held
149    /// something that is not a number.
150    ExpectedNumber,
151    /// Operands appeared where no command could repeat them — a number after
152    /// `Z`, or before any command.
153    UnexpectedOperand,
154    /// The path parsed but encloses no area: fewer than three distinct points,
155    /// or every point on one spot.
156    Degenerate,
157}
158
159impl fmt::Display for PathError {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(f, "at character {}: ", self.offset)?;
162        match &self.kind {
163            PathErrorKind::EllipticalArc(c) => write!(
164                f,
165                "elliptical arc '{c}' is not in the [path] subset. Its centre parameterisation is \
166                 a different geometry from the Bezier commands, and every design tool can export \
167                 the same curve as cubics — re-export with arcs converted to paths"
168            ),
169            PathErrorKind::MultipleSubpaths => write!(
170                f,
171                "a second subpath begins here, and [path] takes one closed contour. Two contours \
172                 have no natural point correspondence, which is what a morph needs — draw the \
173                 shape as a single outline, or drop the counter"
174            ),
175            PathErrorKind::UnknownCommand(c) => write!(
176                f,
177                "'{c}' is not a path command. The subset is M m L l H h V v C c S s Q q T t Z z"
178            ),
179            PathErrorKind::MissingMoveTo => {
180                write!(f, "a path must begin with a moveto ('M' or 'm')")
181            }
182            PathErrorKind::ExpectedNumber => write!(f, "expected a number"),
183            PathErrorKind::UnexpectedOperand => {
184                write!(f, "a number appears where no command can consume it")
185            }
186            PathErrorKind::Degenerate => write!(
187                f,
188                "the path encloses no area — a silhouette needs at least three distinct points"
189            ),
190        }
191    }
192}
193
194impl std::error::Error for PathError {}
195
196/// A parsed, normalized, resampled closed contour.
197///
198/// The points are the contour itself: `samples` of them, evenly spaced **by arc
199/// length** around the outline and **not** repeating the first at the end — the
200/// closing edge from the last point back to the first is implicit, and both the
201/// distance field and the arc-length walk here assume it.
202#[derive(Debug, Clone, PartialEq)]
203pub struct PathShape {
204    points: Vec<[f32; 2]>,
205    /// The same outline as a **G1-continuous chain of circular arcs**, fitted
206    /// through the line renderer's own fitter (ADR-0098) — or empty where the
207    /// fit was not worth keeping.
208    ///
209    /// Fitted from the **dense flattened** contour rather than from `points`, so
210    /// the chain is not limited by the resample's arity: `samples` governs the
211    /// polyline's fidelity and the fit's own budget governs the chain's.
212    pieces: Vec<crate::render::scenes::lines::biarc::Piece>,
213    source_center: [f32; 2],
214    source_scale: f32,
215}
216
217impl PathShape {
218    /// Parse inline SVG path data into a normalized contour of `samples` points.
219    ///
220    /// `samples` is trusted to be in [`MIN_SAMPLES`]`..=`[`MAX_SAMPLES`]; the
221    /// `[path]` table checks it at the load boundary, which is where a range
222    /// belongs.
223    pub fn parse(d: &str, samples: usize) -> Result<Self, PathError> {
224        let segments = parse_segments(d)?;
225        let dense = flatten(&segments);
226        Self::from_dense(dense, samples)
227    }
228
229    /// The contour's points, normalized into `[-1, 1]` on its longer axis.
230    pub fn points(&self) -> &[[f32; 2]] {
231        &self.points
232    }
233
234    /// The centre of the source drawing's bounding box, in the source's own
235    /// units — the translation the normalization applied, recorded.
236    pub fn source_center(&self) -> [f32; 2] {
237        self.source_center
238    }
239
240    /// The factor the source drawing was scaled by — the reciprocal of half its
241    /// longer bounding-box axis, recorded.
242    pub fn source_scale(&self) -> f32 {
243        self.source_scale
244    }
245
246    /// Twice the shoelace sum: positive when the contour winds
247    /// counter-clockwise in a y-up frame, negative when it winds clockwise.
248    ///
249    /// The stored contour *is* in that frame — `parse` negates SVG's downward y
250    /// — so this sign is the winding an author sees on screen, and a `d` string
251    /// that reads clockwise in a browser reports negative here.
252    ///
253    /// The sign is what a morph pair has to agree on (ADR-0107) — a clockwise
254    /// contour interpolating into a counter-clockwise one turns inside out
255    /// through the middle, passing through zero area on the way.
256    pub fn signed_area(&self) -> f32 {
257        signed_area(&self.points)
258    }
259
260    /// **This contour re-expressed so that interpolating toward it from `from`
261    /// is a morph rather than a scramble** (ADR-0107).
262    ///
263    /// The two alignment problems ADR-0107 says have answers, solved in the
264    /// order they have to be:
265    ///
266    /// 1. **Winding, by signed area.** A clockwise contour interpolating into a
267    ///    counter-clockwise one turns inside out through the middle — every
268    ///    intermediate frame is a valid shape and the motion is wrong — and the
269    ///    contour passes through zero enclosed area on the way. When the two
270    ///    signs disagree, the target is walked backwards.
271    /// 2. **Start point, by minimising total displacement over cyclic
272    ///    offsets.** Without it a star morphing into a star can unwind through a
273    ///    spiral: each point travels to a *correspondent* rather than to its
274    ///    neighbour, and every intermediate frame is again valid. `O(N^2)` at
275    ///    load, which at this arity is thousands of operations, so the
276    ///    brute-force search is affordable and no cleverness is owed.
277    ///
278    /// The third — two paths with different **subpath counts** — has no answer,
279    /// and is refused at the parser rather than guessed at here.
280    ///
281    /// Both contours must already carry the same number of points; `None` if
282    /// they do not, which the load boundary prevents by parsing the pair at one
283    /// arity.
284    pub fn aligned_to(&self, from: &Self) -> Option<Self> {
285        let n = self.points.len();
286        if n != from.points.len() || n < 3 {
287            return None;
288        }
289
290        // 1 — winding. `rev` walks the target backwards, which flips its signed
291        // area and leaves the same figure.
292        let flip = signed_area(&self.points) * signed_area(&from.points) < 0.0;
293        let oriented: Vec<[f32; 2]> = if flip {
294            self.points.iter().rev().copied().collect()
295        } else {
296            self.points.clone()
297        };
298
299        // 2 — start point. The cost is the sum of SQUARED displacements, which
300        // has the same minimiser as the sum of distances and no square roots in
301        // the inner loop.
302        let mut best_offset = 0usize;
303        let mut best_cost = f32::INFINITY;
304        for offset in 0..n {
305            let mut cost = 0.0f32;
306            for i in 0..n {
307                let (Some(&a), Some(&b)) = (from.points.get(i), oriented.get((i + offset) % n))
308                else {
309                    return None;
310                };
311                let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
312                cost += dx * dx + dy * dy;
313            }
314            if cost < best_cost {
315                best_cost = cost;
316                best_offset = offset;
317            }
318        }
319
320        let mut points = Vec::with_capacity(n);
321        for i in 0..n {
322            points.push(*oriented.get((i + best_offset) % n)?);
323        }
324        Some(Self {
325            points,
326            pieces: Vec::new(),
327            source_center: self.source_center,
328            source_scale: self.source_scale,
329        })
330    }
331
332    /// The fitted arc chain, or empty where the figure stays a polyline.
333    pub(crate) fn pieces(&self) -> &[crate::render::scenes::lines::biarc::Piece] {
334        &self.pieces
335    }
336
337    /// How many arc pieces the fit kept — `0` where the figure stays a polyline.
338    ///
339    /// The count rather than the chain, so a caller outside the crate can report
340    /// what a curve cost without [`biarc::Piece`](crate::render::scenes::lines::biarc)
341    /// being public API.
342    pub fn piece_count(&self) -> usize {
343        self.pieces.len()
344    }
345
346    /// Re-fit this contour's **points** to arcs at an arbitrary budget, for
347    /// measuring what a curve costs in pieces at a given fidelity.
348    ///
349    /// Not the chain the scene draws — that one is fitted from the dense
350    /// contour, at [`ARC_FIT_BUDGET`], and is [`pieces`](Self::pieces). This
351    /// exists so the relationship between fidelity and piece count can be
352    /// reported as a table rather than argued.
353    #[cfg(test)]
354    pub(crate) fn refit(&self, lateral: f32) -> Vec<crate::render::scenes::lines::biarc::Piece> {
355        let mut out = Vec::new();
356        let mut at = Vec::new();
357        crate::render::scenes::lines::biarc::fit(&self.points, true, lateral, &mut out, &mut at);
358        out
359    }
360
361    /// This contour resampled to `samples` points, evenly spaced by arc length
362    /// from its own first point. `None` when the contour or the request is
363    /// degenerate.
364    pub fn resampled(&self, samples: usize) -> Option<Self> {
365        Some(Self {
366            points: resample(&self.points, samples)?,
367            pieces: Vec::new(),
368            source_center: self.source_center,
369            source_scale: self.source_scale,
370        })
371    }
372
373    /// Build from an already-flattened dense polyline: dedupe, take the tight
374    /// bounding box, normalize, resample.
375    fn from_dense(mut dense: Vec<[f32; 2]>, samples: usize) -> Result<Self, PathError> {
376        // A dense flatten repeats the joint between pieces, and a `Z` re-states
377        // the start point. Both would be zero-length edges in the walk below.
378        let extent = rough_extent(&dense);
379        dedupe(&mut dense, extent * DEDUPE_FRACTION);
380        if dense.len() < 3 {
381            return Err(PathError {
382                offset: 0,
383                kind: PathErrorKind::Degenerate,
384            });
385        }
386
387        let (min, max) = bounds(&dense);
388        let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
389        let half = ((max[0] - min[0]) * 0.5).max((max[1] - min[1]) * 0.5);
390        if !half.is_finite() || half <= 0.0 {
391            return Err(PathError {
392                offset: 0,
393                kind: PathErrorKind::Degenerate,
394            });
395        }
396        // **The y negation is here, and here is the only place it may be.** SVG
397        // measures y downward; every consumer of `points` below — the resample,
398        // the arc fit, `signed_area`, the morph's winding and start-point
399        // alignment — measures it upward, the way clip space does. Negating in
400        // this loop puts the contour in that frame once, before any of them
401        // reads it, so none of them has to know which frame it is holding.
402        //
403        // Applying it later would not be the same edit: the winding normalization
404        // and the cyclic start-point search would then align geometry in the
405        // opposite frame from the one it renders in, and both are sign-sensitive.
406        let scale = 1.0 / half;
407        for p in &mut dense {
408            p[0] = (p[0] - center[0]) * scale;
409            p[1] = -(p[1] - center[1]) * scale;
410        }
411
412        let points = resample(&dense, samples).ok_or(PathError {
413            offset: 0,
414            kind: PathErrorKind::Degenerate,
415        })?;
416
417        // **The fit reads the dense contour, not the resample.** A chain fitted
418        // from `points` could be no more faithful than the polyline it came
419        // from; fitted from the flatten it is limited only by its own budget, so
420        // an arc figure's fidelity stops depending on `samples` at all.
421        //
422        // The chain is kept only where it is worth evaluating: it has to fit the
423        // uniform, and it has to have collapsed the count — an arc piece costs
424        // more per pixel than a line segment, so a chain the same length as the
425        // polyline is strictly worse. A figure that is all corners (a polygon)
426        // comes back from the fitter as the lines it went in as, and lands here.
427        let mut pieces = Vec::new();
428        let mut at = Vec::new();
429        crate::render::scenes::lines::biarc::fit(
430            &dense,
431            true,
432            ARC_FIT_BUDGET,
433            &mut pieces,
434            &mut at,
435        );
436        if pieces.len() > MAX_ARC_PIECES || pieces.len() * 2 > points.len() {
437            pieces.clear();
438        }
439
440        Ok(Self {
441            points,
442            pieces,
443            source_center: center,
444            source_scale: scale,
445        })
446    }
447}
448
449/// One parsed segment, in absolute source coordinates. Its start point is the
450/// previous segment's end, so it is not repeated here.
451#[derive(Debug, Clone, Copy, PartialEq)]
452enum Seg {
453    Line([f32; 2]),
454    Quad([f32; 2], [f32; 2]),
455    Cubic([f32; 2], [f32; 2], [f32; 2]),
456}
457
458/// Which kind of curve produced the last control point, for `S`/`T`'s
459/// reflection. Anything else clears it, which is what makes an `S` after an `L`
460/// reflect about the current point rather than about a stale handle.
461#[derive(Clone, Copy, PartialEq)]
462enum LastCtrl {
463    None,
464    Cubic([f32; 2]),
465    Quad([f32; 2]),
466}
467
468/// The scanner over `d`: a byte cursor plus the number and separator rules SVG
469/// path data uses — commas and whitespace are interchangeable, and both are
470/// optional wherever a sign or a `.` already separates two numbers.
471struct Scan<'a> {
472    s: &'a [u8],
473    i: usize,
474}
475
476impl<'a> Scan<'a> {
477    fn new(s: &'a str) -> Self {
478        Self {
479            s: s.as_bytes(),
480            i: 0,
481        }
482    }
483
484    fn peek(&self) -> Option<u8> {
485        self.s.get(self.i).copied()
486    }
487
488    /// Whitespace and commas separate operands and may be omitted entirely.
489    fn skip_sep(&mut self) {
490        while let Some(b) = self.peek() {
491            if b.is_ascii_whitespace() || b == b',' {
492                self.i += 1;
493            } else {
494                break;
495            }
496        }
497    }
498
499    /// Whether a number could start here — the lookahead the implicit-repeat
500    /// rule needs to tell "another operand group" from "the next command".
501    fn at_number(&self) -> bool {
502        matches!(self.peek(), Some(b) if b.is_ascii_digit() || b == b'+' || b == b'-' || b == b'.')
503    }
504
505    /// One number.
506    ///
507    /// Hand-rolled rather than delegated to `f32::from_str` over a slice found
508    /// by scanning to the next separator: SVG allows `1.5.3` to mean two
509    /// numbers, so where a number *ends* is part of the grammar, and stopping at
510    /// the second `.` is what makes that path parse the way a browser parses it.
511    fn number(&mut self) -> Result<f32, PathError> {
512        self.skip_sep();
513        let start = self.i;
514        if matches!(self.peek(), Some(b'+') | Some(b'-')) {
515            self.i += 1;
516        }
517        let mut digits = false;
518        while matches!(self.peek(), Some(b) if b.is_ascii_digit()) {
519            self.i += 1;
520            digits = true;
521        }
522        if self.peek() == Some(b'.') {
523            self.i += 1;
524            while matches!(self.peek(), Some(b) if b.is_ascii_digit()) {
525                self.i += 1;
526                digits = true;
527            }
528        }
529        if !digits {
530            return Err(PathError {
531                offset: start,
532                kind: PathErrorKind::ExpectedNumber,
533            });
534        }
535        // An exponent counts only when a digit actually follows it, so a stray
536        // `e` does not swallow the cursor.
537        if matches!(self.peek(), Some(b'e') | Some(b'E')) {
538            let save = self.i;
539            self.i += 1;
540            if matches!(self.peek(), Some(b'+') | Some(b'-')) {
541                self.i += 1;
542            }
543            let mut exp_digits = false;
544            while matches!(self.peek(), Some(b) if b.is_ascii_digit()) {
545                self.i += 1;
546                exp_digits = true;
547            }
548            if !exp_digits {
549                self.i = save;
550            }
551        }
552        let text = self
553            .s
554            .get(start..self.i)
555            .and_then(|b| std::str::from_utf8(b).ok());
556        let value = text
557            .and_then(|t| t.parse::<f32>().ok())
558            .filter(|v| v.is_finite());
559        value.ok_or(PathError {
560            offset: start,
561            kind: PathErrorKind::ExpectedNumber,
562        })
563    }
564
565    fn pair(&mut self) -> Result<[f32; 2], PathError> {
566        let x = self.number()?;
567        let y = self.number()?;
568        Ok([x, y])
569    }
570}
571
572/// Parse `d` into absolute segments, refusing what the subset excludes.
573fn parse_segments(d: &str) -> Result<Vec<Seg>, PathError> {
574    let mut scan = Scan::new(d);
575    let mut segs: Vec<Seg> = Vec::new();
576    let mut cur = [0.0f32, 0.0];
577    let mut start = [0.0f32, 0.0];
578    let mut last_ctrl = LastCtrl::None;
579    let mut started = false;
580    // The command an operand group repeats under when no letter is present. `0`
581    // means "no command yet", which is what makes a leading number an error
582    // rather than a silent lineto.
583    let mut repeat: u8 = 0;
584
585    loop {
586        scan.skip_sep();
587        let Some(b) = scan.peek() else { break };
588        let at = scan.i;
589
590        let cmd = if b.is_ascii_alphabetic() {
591            scan.i += 1;
592            b
593        } else if scan.at_number() {
594            // The implicit-repeat rule: a moveto's extra coordinate pairs are
595            // linetos (`M x y x y` draws a line), every other command repeats
596            // itself, and `Z` has no operands to repeat.
597            match repeat {
598                b'M' => b'L',
599                b'm' => b'l',
600                0 => {
601                    return Err(PathError {
602                        offset: at,
603                        kind: PathErrorKind::MissingMoveTo,
604                    });
605                }
606                b'Z' | b'z' => {
607                    return Err(PathError {
608                        offset: at,
609                        kind: PathErrorKind::UnexpectedOperand,
610                    });
611                }
612                other => other,
613            }
614        } else {
615            return Err(PathError {
616                offset: at,
617                kind: PathErrorKind::UnexpectedOperand,
618            });
619        };
620
621        if !started && !matches!(cmd, b'M' | b'm') {
622            return Err(PathError {
623                offset: at,
624                kind: PathErrorKind::MissingMoveTo,
625            });
626        }
627
628        // Relative commands are the lowercase half, and the point every one of
629        // them is relative to is the current point.
630        let rel = cmd.is_ascii_lowercase();
631        let base = if rel { cur } else { [0.0, 0.0] };
632
633        match cmd.to_ascii_uppercase() {
634            b'M' => {
635                if started {
636                    return Err(PathError {
637                        offset: at,
638                        kind: PathErrorKind::MultipleSubpaths,
639                    });
640                }
641                let p = scan.pair()?;
642                cur = [base[0] + p[0], base[1] + p[1]];
643                start = cur;
644                started = true;
645                last_ctrl = LastCtrl::None;
646            }
647            b'L' => {
648                let p = scan.pair()?;
649                cur = [base[0] + p[0], base[1] + p[1]];
650                segs.push(Seg::Line(cur));
651                last_ctrl = LastCtrl::None;
652            }
653            b'H' => {
654                let x = scan.number()?;
655                cur = [base[0] + x, cur[1]];
656                segs.push(Seg::Line(cur));
657                last_ctrl = LastCtrl::None;
658            }
659            b'V' => {
660                let y = scan.number()?;
661                cur = [cur[0], base[1] + y];
662                segs.push(Seg::Line(cur));
663                last_ctrl = LastCtrl::None;
664            }
665            b'C' => {
666                let c1 = scan.pair()?;
667                let c2 = scan.pair()?;
668                let p = scan.pair()?;
669                let c1 = [base[0] + c1[0], base[1] + c1[1]];
670                let c2 = [base[0] + c2[0], base[1] + c2[1]];
671                cur = [base[0] + p[0], base[1] + p[1]];
672                segs.push(Seg::Cubic(c1, c2, cur));
673                last_ctrl = LastCtrl::Cubic(c2);
674            }
675            b'S' => {
676                let c2 = scan.pair()?;
677                let p = scan.pair()?;
678                // The reflected handle, and the classic place a hand-written
679                // parser is wrong: it reflects the previous CUBIC's second
680                // control point about the current point, and is the current
681                // point itself when the previous command was not a cubic.
682                let c1 = match last_ctrl {
683                    LastCtrl::Cubic(prev) => [2.0 * cur[0] - prev[0], 2.0 * cur[1] - prev[1]],
684                    _ => cur,
685                };
686                let c2 = [base[0] + c2[0], base[1] + c2[1]];
687                cur = [base[0] + p[0], base[1] + p[1]];
688                segs.push(Seg::Cubic(c1, c2, cur));
689                last_ctrl = LastCtrl::Cubic(c2);
690            }
691            b'Q' => {
692                let c = scan.pair()?;
693                let p = scan.pair()?;
694                let c = [base[0] + c[0], base[1] + c[1]];
695                cur = [base[0] + p[0], base[1] + p[1]];
696                segs.push(Seg::Quad(c, cur));
697                last_ctrl = LastCtrl::Quad(c);
698            }
699            b'T' => {
700                let p = scan.pair()?;
701                // `T` reflects the previous QUADRATIC's control point — a `T`
702                // after a cubic reflects nothing and draws a straight line.
703                let c = match last_ctrl {
704                    LastCtrl::Quad(prev) => [2.0 * cur[0] - prev[0], 2.0 * cur[1] - prev[1]],
705                    _ => cur,
706                };
707                cur = [base[0] + p[0], base[1] + p[1]];
708                segs.push(Seg::Quad(c, cur));
709                last_ctrl = LastCtrl::Quad(c);
710            }
711            b'Z' => {
712                cur = start;
713                last_ctrl = LastCtrl::None;
714            }
715            b'A' => {
716                return Err(PathError {
717                    offset: at,
718                    kind: PathErrorKind::EllipticalArc(b as char),
719                });
720            }
721            _ => {
722                return Err(PathError {
723                    offset: at,
724                    kind: PathErrorKind::UnknownCommand(b as char),
725                });
726            }
727        }
728        repeat = cmd;
729    }
730
731    if !started {
732        return Err(PathError {
733            offset: 0,
734            kind: PathErrorKind::MissingMoveTo,
735        });
736    }
737    if segs.is_empty() {
738        return Err(PathError {
739            offset: 0,
740            kind: PathErrorKind::Degenerate,
741        });
742    }
743    // The contour is closed whether or not the author wrote `Z`: a filled
744    // silhouette has no open form, and an implicit close is what a browser
745    // draws for `fill`. The closing edge lives in the point list's wrap rather
746    // than in a segment, so nothing is appended — what IS prepended is the
747    // moveto's own point, which the segments above carry only as an origin.
748    segs.insert(0, Seg::Line(start));
749    Ok(segs)
750}
751
752/// Flatten absolute segments into a dense polyline, starting at the first
753/// segment's end — the moveto's point.
754fn flatten(segs: &[Seg]) -> Vec<[f32; 2]> {
755    let extent = control_extent(segs);
756    let step = (extent / FLATTEN_PER_EXTENT).max(f32::MIN_POSITIVE);
757    let mut out: Vec<[f32; 2]> = Vec::new();
758    let mut cur = [0.0f32, 0.0];
759    for seg in segs {
760        match *seg {
761            Seg::Line(p) => {
762                out.push(p);
763                cur = p;
764            }
765            Seg::Quad(c, p) => {
766                let n = pieces(chord(cur, c) + chord(c, p), step);
767                for k in 1..=n {
768                    let t = k as f32 / n as f32;
769                    out.push(quad_at(cur, c, p, t));
770                }
771                cur = p;
772            }
773            Seg::Cubic(c1, c2, p) => {
774                let n = pieces(chord(cur, c1) + chord(c1, c2) + chord(c2, p), step);
775                for k in 1..=n {
776                    let t = k as f32 / n as f32;
777                    out.push(cubic_at(cur, c1, c2, p, t));
778                }
779                cur = p;
780            }
781        }
782    }
783    out
784}
785
786/// How many pieces a curve whose control polygon measures `poly` is flattened
787/// into at `step`.
788fn pieces(poly: f32, step: f32) -> usize {
789    let n = (poly / step).ceil();
790    if !n.is_finite() || n < 1.0 {
791        return 1;
792    }
793    (n as usize).min(MAX_FLATTEN_PER_SEGMENT)
794}
795
796fn chord(a: [f32; 2], b: [f32; 2]) -> f32 {
797    ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt()
798}
799
800fn quad_at(p0: [f32; 2], c: [f32; 2], p1: [f32; 2], t: f32) -> [f32; 2] {
801    let u = 1.0 - t;
802    [
803        u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p1[0],
804        u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p1[1],
805    ]
806}
807
808fn cubic_at(p0: [f32; 2], c1: [f32; 2], c2: [f32; 2], p1: [f32; 2], t: f32) -> [f32; 2] {
809    let u = 1.0 - t;
810    let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
811    [
812        a * p0[0] + b * c1[0] + c * c2[0] + d * p1[0],
813        a * p0[1] + b * c1[1] + c * c2[1] + d * p1[1],
814    ]
815}
816
817/// The extent of every point a segment names, control points included — a
818/// superset of the drawn figure's, and the scale the flatten step is relative
819/// to. It is taken before flattening, which is the whole reason it reads control
820/// points rather than the tight bounding box the normalization uses.
821fn control_extent(segs: &[Seg]) -> f32 {
822    let mut min = [f32::INFINITY; 2];
823    let mut max = [f32::NEG_INFINITY; 2];
824    let mut see = |p: [f32; 2]| {
825        min[0] = min[0].min(p[0]);
826        min[1] = min[1].min(p[1]);
827        max[0] = max[0].max(p[0]);
828        max[1] = max[1].max(p[1]);
829    };
830    for seg in segs {
831        match *seg {
832            Seg::Line(p) => see(p),
833            Seg::Quad(c, p) => {
834                see(c);
835                see(p);
836            }
837            Seg::Cubic(c1, c2, p) => {
838                see(c1);
839                see(c2);
840                see(p);
841            }
842        }
843    }
844    let e = (max[0] - min[0]).max(max[1] - min[1]);
845    if e.is_finite() && e > 0.0 { e } else { 1.0 }
846}
847
848fn rough_extent(points: &[[f32; 2]]) -> f32 {
849    let (min, max) = bounds(points);
850    let e = (max[0] - min[0]).max(max[1] - min[1]);
851    if e.is_finite() && e > 0.0 { e } else { 1.0 }
852}
853
854fn bounds(points: &[[f32; 2]]) -> ([f32; 2], [f32; 2]) {
855    let mut min = [f32::INFINITY; 2];
856    let mut max = [f32::NEG_INFINITY; 2];
857    for p in points {
858        min[0] = min[0].min(p[0]);
859        min[1] = min[1].min(p[1]);
860        max[0] = max[0].max(p[0]);
861        max[1] = max[1].max(p[1]);
862    }
863    (min, max)
864}
865
866/// Drop consecutive points within `eps`, and the last point when it lands on
867/// the first — the closing edge is implicit, so a repeated start point would be
868/// a zero-length edge in the arc-length walk.
869fn dedupe(points: &mut Vec<[f32; 2]>, eps: f32) {
870    points.dedup_by(|a, b| chord(*a, *b) <= eps);
871    while points.len() > 1 {
872        let Some(&first) = points.first() else { break };
873        let Some(&last) = points.last() else { break };
874        if chord(first, last) <= eps {
875            points.pop();
876        } else {
877            break;
878        }
879    }
880}
881
882/// Twice the shoelace sum over a closed polygon.
883fn signed_area(points: &[[f32; 2]]) -> f32 {
884    let n = points.len();
885    let mut sum = 0.0;
886    for i in 0..n {
887        let (Some(&a), Some(&b)) = (points.get(i), points.get((i + 1) % n)) else {
888            continue;
889        };
890        sum += a[0] * b[1] - b[0] * a[1];
891    }
892    sum
893}
894
895/// Walk the closed polygon and emit `samples` points evenly spaced by arc
896/// length, starting exactly on `points[0]`.
897///
898/// Even spacing **by arc length** rather than per command: a shape whose
899/// commands are unevenly sized would otherwise bunch its points where the author
900/// happened to click, and a morph correspondence built on that bunching is wrong
901/// everywhere the two shapes were drawn differently (ADR-0107).
902fn resample(points: &[[f32; 2]], samples: usize) -> Option<Vec<[f32; 2]>> {
903    let n = points.len();
904    if n < 3 || samples < 3 {
905        return None;
906    }
907    // Cumulative length at each vertex, wrapping: `cum[i]` is the distance from
908    // `points[0]` to `points[i]` along the outline, and `cum[n]` the perimeter.
909    let mut cum = Vec::with_capacity(n + 1);
910    cum.push(0.0f32);
911    let mut total = 0.0f32;
912    for i in 0..n {
913        let (Some(&a), Some(&b)) = (points.get(i), points.get((i + 1) % n)) else {
914            return None;
915        };
916        total += chord(a, b);
917        cum.push(total);
918    }
919    if !total.is_finite() || total <= 0.0 {
920        return None;
921    }
922
923    let mut out = Vec::with_capacity(samples);
924    let mut seg = 0usize;
925    for k in 0..samples {
926        let target = total * (k as f32) / (samples as f32);
927        while seg + 1 < n && cum.get(seg + 1).is_some_and(|&c| c <= target) {
928            seg += 1;
929        }
930        let (Some(&a), Some(&b)) = (points.get(seg), points.get((seg + 1) % n)) else {
931            return None;
932        };
933        let (Some(&lo), Some(&hi)) = (cum.get(seg), cum.get(seg + 1)) else {
934            return None;
935        };
936        let run = hi - lo;
937        let t = if run > 0.0 {
938            ((target - lo) / run).clamp(0.0, 1.0)
939        } else {
940            0.0
941        };
942        out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
943    }
944    Some(out)
945}
946
947#[cfg(test)]
948mod tests;