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;