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;