rlx_core/render/scenes/lines/biarc.rs
1//! Biarc fitting: a sampled outline in, a **G1-continuous chain of circular
2//! arcs** out (ADR-0098).
3//!
4//! This is the half of ADR-0098 that makes the cheap primitive enough. A
5//! sampled polyline shows its joints because it is only **C0** — the tangent
6//! jumps at every vertex, and the eye reads a tangent discontinuity as a corner
7//! however fine the sampling. A chain built here is **G1**: consecutive pieces
8//! share both an endpoint and a tangent direction there, by construction, so
9//! the same handful of pieces that read as a faceted polygon read as a drawn
10//! curve. The approximation error shows up as a curve slightly in the wrong
11//! *place* rather than as a visible vertex.
12//!
13//! **A corner in the source outline stays a corner.** The fit breaks its chain
14//! wherever consecutive chords turn by more than `CORNER_TURN`, because a
15//! trefoil's three cusps and a diamond's four vertices are the figure, not
16//! sampling artefacts, and a run that is all corners comes back as the polyline
17//! it was given.
18//!
19//! **That is not enough to leave a Maurer chord web alone, and the measurement
20//! says so.** A `d = 29` walk is about 90 % corners — but the other 10 % are
21//! runs of two and three chords that the fit happily replaces with arcs, which
22//! would redraw a figure whose chords *are* the figure. So the decision of
23//! whether a walk is a curve at all is the **caller's**, taken from
24//! `corner_fraction` before the fit is ever called; see
25//! `curves::maurer_rose_pieces`.
26//!
27//! Pure: no clock, no randomness, no global state, so the same outline always
28//! yields the same chain (the determinism rule). Allocation-free into a
29//! caller-preallocated `out`, because `parametric_curve` resamples every frame.
30
31// Hot-path panic-denial pragma. The fit is build-time for the motif roster and
32// **per frame** for `parametric_curve`, whose build model is a resample every
33// frame (ADR-0007) and which therefore has no load moment to run it at.
34#![deny(
35 clippy::unwrap_used,
36 clippy::expect_used,
37 clippy::indexing_slicing,
38 clippy::panic,
39 clippy::unreachable
40)]
41
42use std::f32::consts::{PI, TAU};
43
44/// The largest angle, in radians, by which a fitted piece's tangent may differ
45/// from the outline's at any sample the piece spans.
46///
47/// **The G1 property costs nothing and is not what this bounds.** Consecutive
48/// pieces share a tangent by construction whatever this number is; what a
49/// tangent error does instead is let a piece lean off the curve *between* the
50/// samples it interpolates.
51///
52/// **Derived to land on the same order as the caller's lateral budget, so
53/// neither criterion is silently dominated.** A tangent error `e` held across a
54/// piece of length `L` displaces the drawn curve from the authored one by about
55/// `e * L / 4`. The fit's longest piece on the roster is about a fifth of a
56/// motif's own unit span, so `0.05` rad — 2.9 degrees — works out at `2.5e-3`
57/// units against the `4.0e-3` `star.rs` passes. Both bite: `petal`'s measured
58/// tangent error sits on this budget, `teardrop`'s and `trefoil`'s sit on the
59/// lateral one.
60///
61/// **A tangent budget alone would not bound the piece count, and this is the
62/// one place that has to be said out loud.** Two of the three fitted motifs
63/// carry a point of *unbounded curvature* — `petal`'s and `teardrop`'s tips,
64/// where the outline's `1.6` exponent makes `y ~ |x|^0.8` and the tangent turns
65/// arbitrarily fast through vertical. No circular arc tracks that, so a
66/// tangent-only criterion subdivides without limit toward the tip and buys
67/// nothing: the whole region where it is failing is `7.6e-6` units wide, five
68/// hundred times narrower than a pixel. The lateral budget is what stops it.
69pub(crate) const TANGENT_BUDGET: f32 = 0.05;
70
71/// One pixel at 1080p, in the renderer's world-y units — the unit every
72/// caller's lateral budget is quoted in.
73///
74/// The renderer maps world y `[-1, 1]` onto the target's height, so 1080 rows
75/// make one unit 540 px. A caller that fits in the frame it draws in passes a
76/// multiple of this directly ([`curves`](super::curves)); a caller that fits in
77/// a **local** frame later scaled down — a motif outline, authored spanning one
78/// unit and placed at a ring `scale` — divides by the largest scale it will be
79/// drawn at, because that is where its error is largest
80/// ([`star`](super::star)).
81pub(crate) const PIXEL_1080P: f32 = 1.0 / 540.0;
82
83/// The chord-to-chord turn above which a vertex is a **corner of the figure**
84/// rather than a sample of a curve, and the chain breaks there.
85///
86/// Sixty degrees, and the gap it sits in is wide at both ends. Below it: a
87/// smooth outline resampled at [`FIT_SAMPLES`] turns about 1.4 degrees per
88/// chord, and even `petal`'s tip — the sharpest feature in the roster that is
89/// not a corner — turns 27 degrees across the two chords that straddle it.
90/// Above it: `trefoil`'s three cusps run into the origin and come back out
91/// along the same ray, a 180-degree turn, and a Maurer chord web at `d = 29` or
92/// more turns past 60 degrees at every single vertex.
93pub(crate) const CORNER_TURN: f32 = PI / 3.0;
94
95/// How far one piece may turn, in radians.
96///
97/// The biarc construction reads the turn between its two end tangents through
98/// an angle wrapped into `(-PI, PI]`, so a span that genuinely turns further is
99/// ambiguous — it would be fitted as the short way round. Nine tenths of half a
100/// turn keeps the construction clear of that wrap with room to spare, and costs
101/// nothing real: a closed outline needs at least three pieces to come back to
102/// itself either way.
103const MAX_PIECE_TURN: f32 = 0.9 * PI;
104
105/// How near a single arc's far tangent must come to the span's before the pair
106/// collapses to that one arc.
107///
108/// **This is an equality test, not a budget, and the difference is the whole
109/// G1 property.** Collapsing at [`TANGENT_BUDGET`] would leave the next piece
110/// starting up to 2.9 degrees off where this one ended — a tangent
111/// discontinuity at every joint, which is precisely the defect ADR-0098 exists
112/// to remove, arrived at by way of an optimization. At `1e-4` rad the collapse
113/// only happens where the biarc it replaces would have been that same arc
114/// twice, so it costs an instance and changes no geometry.
115const G1_TOLERANCE: f32 = 1e-4;
116
117/// The largest radius a fitted arc may carry before the piece is emitted as a
118/// straight line instead.
119///
120/// The fragment shades an arc by `abs(length(p - c) - r)`, and in `f32` that
121/// difference of two large nearly-equal numbers loses exactly the precision the
122/// stroke needs: one ulp at magnitude `R` is `R * 2^-23`, so at `R = 64` the
123/// distance resolves to `7.6e-6` — a two-hundredth of a pixel at 1080p — and at
124/// `R = 1e6` it resolves to `0.06`, twenty stroke widths. A piece flat enough
125/// to want a radius past this deviates from its own chord by less than `L^2 /
126/// (8R)`, which for the longest piece the fit emits is under half a pixel — so
127/// the straight line it becomes is not an approximation anyone can see, and a
128/// straight line is the right primitive for a straight run.
129const MAX_RADIUS: f32 = 64.0;
130
131/// Samples the motif roster's outlines are re-drawn at for fitting.
132///
133/// The fit sees only samples, so the sampling has to be finer than the features
134/// it is meant to resolve — `Motif::outline`'s 24 is the *reference polyline*'s
135/// resolution and would cap a piece boundary to a 15-degree grid. 256 is dense
136/// enough that the chord-to-chord turn on a smooth motif is about 1.4 degrees,
137/// well under [`CORNER_TURN`], and it costs one build-time pass over an array.
138pub(crate) const FIT_SAMPLES: usize = 256;
139
140/// One piece of a fitted chain, in the fit's own frame.
141///
142/// Two variants rather than one because a straight run is not a curve: a
143/// distance field is strictly more expensive for a line than a quad is, and an
144/// arc's radius for a flat piece is exactly the regime [`MAX_RADIUS`] rules
145/// out. The caller turns these into the two instance kinds `LineRenderer`
146/// already draws.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub(crate) enum Piece {
149 /// A circular arc: centre of curvature, radius, start angle and **signed**
150 /// sweep, exactly the quantities `ArcInstance` carries.
151 Arc {
152 centre: [f32; 2],
153 radius: f32,
154 start: f32,
155 sweep: f32,
156 },
157 /// A straight run from `a` to `b`.
158 Line { a: [f32; 2], b: [f32; 2] },
159}
160
161impl Piece {
162 /// Where the piece begins.
163 pub(crate) fn start_point(self) -> [f32; 2] {
164 match self {
165 Piece::Arc {
166 centre,
167 radius,
168 start,
169 ..
170 } => on_circle(centre, radius, start),
171 Piece::Line { a, .. } => a,
172 }
173 }
174
175 /// Where the piece ends.
176 pub(crate) fn end_point(self) -> [f32; 2] {
177 match self {
178 Piece::Arc {
179 centre,
180 radius,
181 start,
182 sweep,
183 } => on_circle(centre, radius, start + sweep),
184 Piece::Line { b, .. } => b,
185 }
186 }
187
188 /// The unit direction of travel where the piece begins — the incoming half
189 /// of the G1 property.
190 pub(crate) fn start_tangent(self) -> [f32; 2] {
191 match self {
192 Piece::Arc { start, sweep, .. } => arc_tangent(start, sweep),
193 Piece::Line { a, b } => normalize([b[0] - a[0], b[1] - a[1]]),
194 }
195 }
196
197 /// The unit direction of travel where the piece ends — the outgoing half.
198 pub(crate) fn end_tangent(self) -> [f32; 2] {
199 match self {
200 Piece::Arc { start, sweep, .. } => arc_tangent(start + sweep, sweep),
201 Piece::Line { a, b } => normalize([b[0] - a[0], b[1] - a[1]]),
202 }
203 }
204
205 /// The miter extensions the piece at `k` carries at its two ends, in
206 /// `width` units (ADR-0158) — the one rule both fitted-chain producers
207 /// stroke by.
208 ///
209 /// A neighbour's direction is its own **tangent**, because a neighbour may
210 /// be an arc and an arc has no third point to take a direction from. Where
211 /// the fit kept the chain G1 the two tangents are equal and the miter is
212 /// exactly the flat half-width, so only the breaks the fit made at real
213 /// corners reach past it.
214 ///
215 /// `closed` wraps the chain's two ends onto each other. An open chain's
216 /// outer ends are genuinely free and read `0.0`, which is what keeps a
217 /// stroke from running past the figure's own endpoints.
218 ///
219 /// Angles are taken on the chain **as the fit produced it**. A producer that
220 /// places its copies through a rotation, a uniform scale or a reflection may
221 /// use these lengths unchanged: a similarity preserves angles.
222 pub(crate) fn chain_extensions(
223 chain: &[Piece],
224 k: usize,
225 width: f32,
226 closed: bool,
227 ) -> (f32, f32) {
228 use super::renderer::miter_extension_between;
229
230 let last = chain.len().saturating_sub(1);
231 let Some(here) = chain.get(k) else {
232 return (0.0, 0.0);
233 };
234 let before = if k > 0 {
235 chain.get(k - 1)
236 } else if closed {
237 chain.get(last)
238 } else {
239 None
240 };
241 let after = if k < last {
242 chain.get(k + 1)
243 } else if closed {
244 chain.first()
245 } else {
246 None
247 };
248 (
249 before.map_or(0.0, |p| {
250 miter_extension_between(width, p.end_tangent(), here.start_tangent())
251 }),
252 after.map_or(0.0, |p| {
253 miter_extension_between(width, here.end_tangent(), p.start_tangent())
254 }),
255 )
256 }
257
258 /// How far `p` is from this piece, and the piece's unit tangent at the
259 /// nearest point on it — the two quantities the two budgets are read
260 /// against.
261 ///
262 /// Outside an arc's angular span the nearer endpoint stands in, which is
263 /// the same convention the arc fragment shades by, so the fit is judging
264 /// the shape the GPU will actually draw.
265 fn measure(self, p: [f32; 2]) -> (f32, [f32; 2]) {
266 match self {
267 Piece::Arc {
268 centre,
269 radius,
270 start,
271 sweep,
272 } => {
273 let v = [p[0] - centre[0], p[1] - centre[1]];
274 let len = norm(v);
275 let angle = v[1].atan2(v[0]);
276 // How far into the sweep `p` sits, measured the way the sweep
277 // runs. Inside the span the arc itself is nearest; outside it,
278 // one of the two ends is.
279 let along = (sweep.signum() * (angle - start)).rem_euclid(TAU);
280 if along <= sweep.abs() {
281 let here = start + sweep.signum() * along;
282 ((len - radius).abs(), arc_tangent(here, sweep))
283 } else {
284 let (a, b) = (self.start_point(), self.end_point());
285 let (da, db) = (dist(p, a), dist(p, b));
286 if da <= db {
287 (da, self.start_tangent())
288 } else {
289 (db, self.end_tangent())
290 }
291 }
292 }
293 Piece::Line { a, b } => {
294 let d = [b[0] - a[0], b[1] - a[1]];
295 let len2 = dot(d, d);
296 let t = if len2 > f32::EPSILON {
297 (dot([p[0] - a[0], p[1] - a[1]], d) / len2).clamp(0.0, 1.0)
298 } else {
299 0.0
300 };
301 let foot = [a[0] + t * d[0], a[1] + t * d[1]];
302 (dist(p, foot), self.start_tangent())
303 }
304 }
305 }
306}
307
308/// What one [`fit`] cost and how well it did — the numbers reported
309/// against the segment counts they replace, and the ones a test reads
310/// instead of re-deriving the fit's internals.
311#[derive(Clone, Copy, Debug, Default, PartialEq)]
312pub(crate) struct FitStats {
313 /// [`Piece::Arc`]s emitted.
314 pub arcs: usize,
315 /// [`Piece::Line`]s emitted.
316 pub lines: usize,
317 /// The largest tangent error, in radians, at any spanned sample.
318 pub max_tangent_err: f32,
319 /// The largest distance, in the fit's frame, from any spanned sample to the
320 /// piece that spans it.
321 pub max_deviation: f32,
322 /// Chain breaks the fit made because the outline turned past
323 /// [`CORNER_TURN`] — corners of the figure, where G1 does not hold and is
324 /// not wanted.
325 pub corners: usize,
326}
327
328/// Fit a G1 chain of circular arcs to `points`, into `out` (cleared first), and
329/// each piece's position along the walk into `at` (likewise) as a **fractional
330/// sample index**.
331///
332/// `closed` says whether the last point joins back to the first; a closed
333/// outline with no corner anywhere comes back to its start tangentially, so the
334/// closing joint is G1 like every other one.
335///
336/// The chain **interpolates**: every piece boundary sits exactly on one of the
337/// input samples, with the outline's own tangent there. So the fit can only be
338/// wrong *between* samples, which is what the two budgets bound, and it can
339/// never drift away from the figure.
340///
341/// **`at` is not bookkeeping.** A piece spans as many samples as the budgets
342/// allow, so the `k`th piece is not the `k`th chord and anything that colours a
343/// figure along its own path — `parametric_curve`'s ramp, which runs
344/// `0..1` across the walk — reads a fitted chain by this and not by index. A
345/// caller that has no such axis passes a scratch buffer and ignores it.
346pub(crate) fn fit(
347 points: &[[f32; 2]],
348 closed: bool,
349 lateral: f32,
350 out: &mut Vec<Piece>,
351 at: &mut Vec<f32>,
352) -> FitStats {
353 out.clear();
354 at.clear();
355 let mut stats = FitStats::default();
356 let n = points.len();
357 if n < 2 {
358 return stats;
359 }
360 let f = Fitter {
361 points,
362 closed,
363 lateral,
364 };
365 // Vertices run `0..=chords`, and for a closed outline the last one is the
366 // first one again — which is what lets the closing joint be an ordinary
367 // interior joint rather than a special case.
368 let chords = if closed { n } else { n - 1 };
369
370 // Corner **vertices**, counted directly rather than tallied as the run loop
371 // breaks: a closed outline whose vertex 0 is a corner has a corner at the
372 // joint where the chain wraps, and the loop starting there never breaks on
373 // it. That is a real tangent discontinuity in the drawn figure — a square
374 // has four, not three.
375 stats.corners = (0..chords).filter(|&k| f.is_corner(k)).count();
376
377 let mut run_start = 0usize;
378 while run_start < chords {
379 let run_end = f.next_break(run_start, chords);
380 let mut i = run_start;
381 while i < run_end {
382 let j = f.longest_span(i, run_end);
383 let (first, second) = f.piece_pair(i, j);
384 // The joint of a biarc sits somewhere inside the span; half way
385 // along it is close enough for a colour axis and needs no arc-length
386 // integral to say so.
387 let midway = 0.5 * (i + j) as f32;
388 for (piece, walk) in [(Some(first), i as f32), (second, midway)]
389 .into_iter()
390 .filter_map(|(piece, walk)| piece.map(|piece| (piece, walk)))
391 {
392 match piece {
393 Piece::Arc { .. } => stats.arcs += 1,
394 Piece::Line { .. } => stats.lines += 1,
395 }
396 out.push(piece);
397 at.push(walk);
398 }
399 let (tangent_err, deviation) = f.worst(i, j, first, second);
400 stats.max_tangent_err = stats.max_tangent_err.max(tangent_err);
401 stats.max_deviation = stats.max_deviation.max(deviation);
402 i = j;
403 }
404 run_start = run_end;
405 }
406 stats
407}
408
409/// The share of `points`' vertices at which the walk turns past
410/// [`CORNER_TURN`] — **is this a curve at all?**, as one number in `0..=1`.
411///
412/// A caller reads it before fitting, because the fit itself answers the
413/// question the expensive way: a walk that is all corners breaks into
414/// one-chord runs and comes back as the polyline it was given, having done
415/// `O(n)` work to change nothing. `parametric_curve` samples a **Maurer walk**,
416/// which is a chord web at a large angular step and a smooth rose at a small
417/// one, and the two are the same code with one parameter between them — so the
418/// decision cannot be made at load, only from the geometry in hand.
419pub(crate) fn corner_fraction(points: &[[f32; 2]], closed: bool) -> f32 {
420 let f = Fitter {
421 points,
422 closed,
423 // Unused: nothing here fits anything, it only counts turns.
424 lateral: 0.0,
425 };
426 let chords = f.chords();
427 if chords < 2 {
428 return 0.0;
429 }
430 let corners = (0..chords).filter(|&k| f.is_corner(k)).count();
431 corners as f32 / chords as f32
432}
433
434/// The fit's working state: the samples and whether they close. Every method is
435/// a pure function of those two, which is what makes the whole fit one.
436struct Fitter<'a> {
437 points: &'a [[f32; 2]],
438 closed: bool,
439 /// The lateral budget, in the input's own units — see [`fit`].
440 lateral: f32,
441}
442
443impl Fitter<'_> {
444 /// How many chords the outline has — one per sample when it closes, one
445 /// fewer when it does not.
446 fn chords(&self) -> usize {
447 if self.closed {
448 self.points.len()
449 } else {
450 self.points.len().saturating_sub(1)
451 }
452 }
453
454 /// Vertex `k`, wrapping for a closed outline so vertex `chords` is vertex
455 /// `0` again.
456 fn at(&self, k: usize) -> [f32; 2] {
457 let n = self.points.len().max(1);
458 self.points.get(k % n).copied().unwrap_or([0.0, 0.0])
459 }
460
461 /// The chord leaving vertex `k`, as a unit direction.
462 fn chord(&self, k: usize) -> [f32; 2] {
463 let (a, b) = (self.at(k), self.at(k + 1));
464 normalize([b[0] - a[0], b[1] - a[1]])
465 }
466
467 /// Whether vertex `k` is a corner of the figure — the chords either side of
468 /// it turn past [`CORNER_TURN`]. Only an interior vertex can be one; an
469 /// open outline's two ends have nothing to turn against.
470 fn is_corner(&self, k: usize) -> bool {
471 let chords = self.chords();
472 if chords == 0 || (!self.closed && (k == 0 || k >= chords)) {
473 return false;
474 }
475 let prev = if k == 0 { chords - 1 } else { k - 1 };
476 turn(self.chord(prev), self.chord(k)).abs() > CORNER_TURN
477 }
478
479 /// The first vertex after `from` at which the chain must break: a corner,
480 /// or the end of the outline.
481 fn next_break(&self, from: usize, chords: usize) -> usize {
482 ((from + 1)..chords)
483 .find(|&k| self.is_corner(k))
484 .unwrap_or(chords)
485 }
486
487 /// The unit tangent a piece **leaves** vertex `k` along.
488 ///
489 /// A corner and an open outline's first vertex have no incoming chord to
490 /// average with, so the outgoing chord is the tangent there; everywhere
491 /// else it is the central difference, which is the same value
492 /// [`tangent_in`](Self::tangent_in) returns — and that equality is the G1
493 /// property at every interior joint.
494 fn tangent_out(&self, k: usize) -> [f32; 2] {
495 if self.is_corner(k) || (!self.closed && k == 0) {
496 self.chord(k)
497 } else {
498 self.central(k)
499 }
500 }
501
502 /// The unit tangent a piece **arrives** at vertex `k` along.
503 fn tangent_in(&self, k: usize) -> [f32; 2] {
504 if self.is_corner(k) || (!self.closed && k >= self.chords()) {
505 self.chord(k.saturating_sub(1))
506 } else {
507 self.central(k)
508 }
509 }
510
511 /// The central-difference tangent at vertex `k`: the direction from its
512 /// predecessor to its successor, which is second-order accurate on a
513 /// uniformly sampled curve and needs no derivative from the caller.
514 fn central(&self, k: usize) -> [f32; 2] {
515 let chords = self.chords().max(1);
516 let prev = if k == 0 { chords - 1 } else { k - 1 };
517 let (a, b) = (self.at(prev), self.at(k + 1));
518 normalize([b[0] - a[0], b[1] - a[1]])
519 }
520
521 /// The largest `j` such that one piece pair spans `i..j` inside the run
522 /// ending at `run_end` and stays inside both budgets.
523 ///
524 /// Doubling, then a bisection — `O(span log span)` work per piece rather
525 /// than the `O(span^2)` a linear walk with a full recheck would cost, which
526 /// is what makes the fit affordable on `parametric_curve`'s per-frame path.
527 /// A one-chord span is accepted unconditionally: it has no interior sample
528 /// to be wrong about, and there is nothing shorter to fall back to.
529 fn longest_span(&self, i: usize, run_end: usize) -> usize {
530 let max = run_end - i;
531 if max <= 1 {
532 return i + 1;
533 }
534 let mut lo = 1usize;
535 let mut hi = 2usize;
536 loop {
537 if hi >= max {
538 if self.spans(i, i + max) {
539 return i + max;
540 }
541 hi = max;
542 break;
543 }
544 if self.spans(i, i + hi) {
545 lo = hi;
546 hi = hi.saturating_mul(2);
547 } else {
548 break;
549 }
550 }
551 while hi > lo + 1 {
552 let mid = lo + (hi - lo) / 2;
553 if self.spans(i, i + mid) {
554 lo = mid;
555 } else {
556 hi = mid;
557 }
558 }
559 i + lo
560 }
561
562 /// Whether one piece pair may span `i..j`: the outline must not turn
563 /// further than [`MAX_PIECE_TURN`] over it, and every sample strictly
564 /// inside must sit within both budgets of the fit.
565 fn spans(&self, i: usize, j: usize) -> bool {
566 let mut turned = 0.0f32;
567 for k in i..j.saturating_sub(1) {
568 turned += turn(self.chord(k), self.chord(k + 1)).abs();
569 if turned > MAX_PIECE_TURN {
570 return false;
571 }
572 }
573 let (first, second) = self.piece_pair(i, j);
574 let (tangent_err, deviation) = self.worst(i, j, first, second);
575 tangent_err <= TANGENT_BUDGET && deviation <= self.lateral
576 }
577
578 /// The worst tangent error and lateral deviation the fit of `i..j` shows at
579 /// the samples strictly inside it. `(0, 0)` for a one-chord span, which has
580 /// none.
581 fn worst(&self, i: usize, j: usize, first: Piece, second: Option<Piece>) -> (f32, f32) {
582 let mut worst_tangent = 0.0f32;
583 let mut worst_deviation = 0.0f32;
584 for k in (i + 1)..j {
585 let p = self.at(k);
586 // The nearer of the pair stands for the fit at this sample: a biarc
587 // is two arcs meeting at one point, so whichever is closer is the
588 // one the sample is being drawn by.
589 let (mut deviation, mut tangent) = first.measure(p);
590 if let Some(second) = second {
591 let (d2, t2) = second.measure(p);
592 if d2 < deviation {
593 deviation = d2;
594 tangent = t2;
595 }
596 }
597 worst_deviation = worst_deviation.max(deviation);
598 worst_tangent = worst_tangent.max(turn(self.central(k), tangent).abs());
599 }
600 (worst_tangent, worst_deviation)
601 }
602
603 /// The one or two pieces that carry the span `i..j`, interpolating both
604 /// endpoints and both tangents exactly.
605 ///
606 /// **A single arc first**, and that is not an optimization detail: a span
607 /// that one arc already fits tangentially is one instance rather than two,
608 /// and the whole circular family reaches the GPU that way. Only when one
609 /// arc arrives pointing the wrong way does the span cost a biarc.
610 fn piece_pair(&self, i: usize, j: usize) -> (Piece, Option<Piece>) {
611 let (p0, p1) = (self.at(i), self.at(j));
612 let t0 = self.tangent_out(i);
613 let t1 = self.tangent_in(j);
614
615 let single = arc_or_line(p0, t0, p1);
616 if turn(single.end_tangent(), t1).abs() <= G1_TOLERANCE {
617 return (single, None);
618 }
619 biarc(p0, t0, p1, t1)
620 }
621}
622
623/// The two arcs of a biarc through `(p0, t0)` and `(p1, t1)`.
624///
625/// **The construction, in one line of geometry.** An arc from `P` to `Q` leaves
626/// `P` and arrives at `Q` along directions that are mirror images in the chord
627/// `PQ`, so writing the chord angles of the two halves as `alpha` and `beta`,
628/// tangent continuity at the joint is exactly `alpha - beta = (theta0 -
629/// theta1) / 2`. Every joint satisfying that lies on one circle through `P0`
630/// and `P1` (the inscribed-angle theorem); this picks the member equidistant
631/// from both, which is the symmetric choice and the one that degenerates
632/// gracefully — equal end tangents put the joint at the midpoint.
633///
634/// The turn is read through [`turn`], which wraps into `(-PI, PI]`, so the
635/// offset angle is at most a quarter turn and the `tan` below is bounded by 1.
636/// That is why the caller caps a span's turn at [`MAX_PIECE_TURN`] rather than
637/// trusting the formula past half a turn.
638fn biarc(p0: [f32; 2], t0: [f32; 2], p1: [f32; 2], t1: [f32; 2]) -> (Piece, Option<Piece>) {
639 let v = [p1[0] - p0[0], p1[1] - p0[1]];
640 let len = norm(v);
641 if len <= f32::EPSILON {
642 return (Piece::Line { a: p0, b: p1 }, None);
643 }
644 let vhat = [v[0] / len, v[1] / len];
645 let nhat = [-vhat[1], vhat[0]];
646 let offset = 0.5 * len * (0.25 * turn(t1, t0)).tan();
647 let joint = [
648 0.5 * (p0[0] + p1[0]) + offset * nhat[0],
649 0.5 * (p0[1] + p1[1]) + offset * nhat[1],
650 ];
651 let first = arc_or_line(p0, t0, joint);
652 // The second half is built **backwards from `p1`** so its far tangent is
653 // `t1` exactly rather than approximately, then flipped. Building it forward
654 // from the joint would need the joint tangent, which is the one quantity
655 // the construction derives rather than knows.
656 let second = reverse(arc_or_line(p1, [-t1[0], -t1[1]], joint));
657 (first, Some(second))
658}
659
660/// The arc leaving `p` along the unit direction `t` and ending at `q` — or the
661/// straight line, when the circle through them would be flatter than
662/// [`MAX_RADIUS`] can be shaded at.
663fn arc_or_line(p: [f32; 2], t: [f32; 2], q: [f32; 2]) -> Piece {
664 let line = Piece::Line { a: p, b: q };
665 // The left normal of the direction of travel: the centre lies along it, and
666 // its sign is what says which way the arc bends.
667 let nhat = [-t[1], t[0]];
668 let c = [q[0] - p[0], q[1] - p[1]];
669 let chord2 = dot(c, c);
670 if chord2 <= f32::EPSILON {
671 return line;
672 }
673 let denom = 2.0 * dot(nhat, c);
674 // `|chord2 / denom| > MAX_RADIUS`, without the division — a flat span makes
675 // `denom` vanish and the quotient is exactly the radius.
676 if denom.abs() * MAX_RADIUS <= chord2 {
677 return line;
678 }
679 let signed = chord2 / denom;
680 let centre = [p[0] + signed * nhat[0], p[1] + signed * nhat[1]];
681 let radius = signed.abs();
682 if !centre[0].is_finite() || !centre[1].is_finite() || !radius.is_finite() {
683 return line;
684 }
685 let start = (p[1] - centre[1]).atan2(p[0] - centre[0]);
686 let end = (q[1] - centre[1]).atan2(q[0] - centre[0]);
687 // A centre to the **left** of the direction of travel is a counter-clockwise
688 // arc, which is the whole of the sweep's sign.
689 let sweep = if signed > 0.0 {
690 (end - start).rem_euclid(TAU)
691 } else {
692 -((start - end).rem_euclid(TAU))
693 };
694 Piece::Arc {
695 centre,
696 radius,
697 start,
698 sweep,
699 }
700}
701
702/// The same piece traversed the other way — same points, same picture, opposite
703/// direction of travel.
704fn reverse(piece: Piece) -> Piece {
705 match piece {
706 Piece::Arc {
707 centre,
708 radius,
709 start,
710 sweep,
711 } => Piece::Arc {
712 centre,
713 radius,
714 start: start + sweep,
715 sweep: -sweep,
716 },
717 Piece::Line { a, b } => Piece::Line { a: b, b: a },
718 }
719}
720
721/// A point on a circle at `angle`.
722fn on_circle(centre: [f32; 2], radius: f32, angle: f32) -> [f32; 2] {
723 let (sin, cos) = angle.sin_cos();
724 [centre[0] + radius * cos, centre[1] + radius * sin]
725}
726
727/// The unit direction of travel at `angle` on an arc sweeping `sweep` — the
728/// radius turned a quarter turn, the way the sweep runs.
729fn arc_tangent(angle: f32, sweep: f32) -> [f32; 2] {
730 let (sin, cos) = angle.sin_cos();
731 let s = if sweep < 0.0 { -1.0 } else { 1.0 };
732 [-s * sin, s * cos]
733}
734
735/// The signed angle from unit direction `a` to unit direction `b`, wrapped into
736/// `(-PI, PI]`.
737fn turn(a: [f32; 2], b: [f32; 2]) -> f32 {
738 let cross = a[0] * b[1] - a[1] * b[0];
739 let along = dot(a, b);
740 cross.atan2(along)
741}
742
743fn dot(a: [f32; 2], b: [f32; 2]) -> f32 {
744 a[0] * b[0] + a[1] * b[1]
745}
746
747fn norm(v: [f32; 2]) -> f32 {
748 dot(v, v).sqrt()
749}
750
751fn dist(a: [f32; 2], b: [f32; 2]) -> f32 {
752 norm([b[0] - a[0], b[1] - a[1]])
753}
754
755/// `v` scaled to unit length; `+x` for a degenerate input, so a repeated sample
756/// yields a direction rather than a NaN that would poison the whole chain.
757fn normalize(v: [f32; 2]) -> [f32; 2] {
758 let len = norm(v);
759 if len > f32::EPSILON {
760 [v[0] / len, v[1] / len]
761 } else {
762 [1.0, 0.0]
763 }
764}
765
766#[cfg(test)]
767mod tests;