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