cranpose_ui_graphics/stroke.rs
1//! Stroke styling and analytic arc geometry.
2//!
3//! # Angle convention
4//!
5//! Every angle in this module is expressed in **radians**, with `0` pointing
6//! along the **+X axis** and increasing angles sweeping **clockwise on
7//! screen**. Cranpose uses y-down device coordinates, so a point on the arc of
8//! radius `r` at angle `θ` is
9//!
10//! ```text
11//! (center.x + r * cos(θ), center.y + r * sin(θ))
12//! ```
13//!
14//! which — because `y` grows downwards — visually rotates clockwise as `θ`
15//! grows. This is exactly the convention already baked into the sweep-gradient
16//! branch of `shape.wgsl`, which derives its parameter from `atan2(dy, dx)`.
17
18use crate::{
19 Point, Rect,
20 float::{all_finite, at_least, within},
21};
22
23/// Shape of the two ends of an open stroked path (an arc, today).
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum StrokeCap {
26 /// Flat end exactly at the geometric end of the path.
27 #[default]
28 Butt,
29 /// Semicircular end bulging half the stroke width past the path end.
30 Round,
31 /// Flat end projected half the stroke width past the path end.
32 Square,
33}
34
35/// Shape produced where two stroked segments meet at a corner.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
37pub enum StrokeJoin {
38 /// Extend the outer edges until they meet in a sharp point.
39 #[default]
40 Miter,
41 /// Fill the corner with a circular arc of half the stroke width.
42 Round,
43 /// Cut the corner off with a straight chamfer.
44 Bevel,
45}
46
47/// Describes how an outline is stroked.
48///
49/// The stroke is *centered* on the geometry: it extends `width / 2` to either
50/// side of the path, matching Skia / Jetpack Compose semantics.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct Stroke {
53 /// Total stroke width in the caller's coordinate space (dp for
54 /// [`crate::DrawScope`] callers).
55 pub width: f32,
56 /// How the ends of an open stroked path are shaped.
57 pub cap: StrokeCap,
58 /// How corners between stroked segments are shaped.
59 pub join: StrokeJoin,
60}
61
62impl Stroke {
63 /// A `width`-wide stroke with butt caps and miter joins.
64 pub const fn new(width: f32) -> Self {
65 Self {
66 width,
67 cap: StrokeCap::Butt,
68 join: StrokeJoin::Miter,
69 }
70 }
71
72 /// Returns the stroke with its width replaced by `width`.
73 pub const fn with_width(mut self, width: f32) -> Self {
74 self.width = width;
75 self
76 }
77
78 /// Returns the stroke with its cap replaced by `cap`.
79 pub const fn with_cap(mut self, cap: StrokeCap) -> Self {
80 self.cap = cap;
81 self
82 }
83
84 /// Returns the stroke with its join replaced by `join`.
85 pub const fn with_join(mut self, join: StrokeJoin) -> Self {
86 self.join = join;
87 self
88 }
89
90 /// Half the stroke width, clamped to a finite non-negative value.
91 ///
92 /// This is the amount the stroke bleeds outside (and inside) the geometry.
93 pub fn half_width(&self) -> f32 {
94 if self.width.is_finite() {
95 at_least(self.width * 0.5, 0.0)
96 } else {
97 0.0
98 }
99 }
100
101 /// A stroke is renderable only when it has a strictly positive, finite width.
102 pub fn is_visible(&self) -> bool {
103 // The positive finite floats are the bit patterns 1 through the
104 // largest finite one; zero, the negatives, infinity and NaN fall
105 // outside. One integer compare instead of two float compares, each
106 // an FPSCR transfer on armv7, for every stroked primitive recorded.
107 self.width.to_bits().wrapping_sub(1) < f32::MAX.to_bits()
108 }
109
110 /// Scales the stroke width (used when a layer transform scales the shape).
111 pub fn scaled(&self, scale: f32) -> Self {
112 Self {
113 width: self.width * scale,
114 ..*self
115 }
116 }
117}
118
119impl Default for Stroke {
120 fn default() -> Self {
121 Self::new(1.0)
122 }
123}
124
125/// Full turn in radians.
126pub const TAU: f32 = std::f32::consts::PI * 2.0;
127
128/// A resolved circular *band* between two radii, limited to an angular sweep.
129///
130/// Both a stroked arc and a filled annular sector lower to this single form:
131///
132/// * stroked arc — `inner = radius - width/2`, `outer = radius + width/2`,
133/// ends shaped by the stroke's [`StrokeCap`];
134/// * filled annular sector — `inner`/`outer` as given, always butt (flat
135/// radial) ends.
136///
137/// Values are normalized on construction: `sweep_angle` is non-negative and at
138/// most [`TAU`], `outer_radius >= inner_radius >= 0`, and non-finite inputs
139/// collapse to a degenerate geometry (see [`ArcGeometry::is_degenerate`]).
140#[derive(Clone, Copy, Debug, PartialEq)]
141pub struct ArcGeometry {
142 /// Center of the circle the band lies on.
143 pub center: Point,
144 /// Inner radius of the band. It is 0 for a pie wedge.
145 pub inner_radius: f32,
146 /// Outer radius of the band, not less than `inner_radius`.
147 pub outer_radius: f32,
148 /// Normalized to `[0, TAU)`.
149 pub start_angle: f32,
150 /// Normalized to `[0, TAU]`.
151 pub sweep_angle: f32,
152 /// How the two ends of the band are shaped.
153 pub cap: StrokeCap,
154}
155
156/// Exact `x.floor()` without the libm call `f32::floor` lowers to on armv7
157/// (no `vrintm` there): truncate via int cast, fix up negatives. Bit-equal
158/// to `floorf` for every input — casts only run below 2^23, where i32 cannot
159/// saturate, and at 2^23 and above every finite f32 is already an integer.
160/// NaN fails the range test and passes through unchanged, like `floorf`.
161#[inline]
162fn exact_floor(x: f32) -> f32 {
163 if x == 0.0 {
164 return x;
165 }
166 if x.abs() < 8_388_608.0 {
167 let truncated = x as i32 as f32;
168 truncated - ((x < truncated) as i32 as f32)
169 } else {
170 x
171 }
172}
173
174/// `x mod TAU` into `[0, TAU)` without `rem_euclid`, whose `fmodf` lowers to
175/// the software routine in compiler_builtins on aarch64 Android and shows up
176/// in profiles at two calls per arc per frame. Multiply-floor keeps it to a
177/// couple of instructions; the fixup folds the one-ulp overshoot cases back
178/// into range.
179#[inline]
180fn wrap_angle_tau(x: f32) -> f32 {
181 if (0.0..TAU).contains(&x) {
182 return x;
183 }
184 let wrapped = x - exact_floor(x * (1.0 / TAU)) * TAU;
185 if wrapped >= TAU {
186 wrapped - TAU
187 } else if wrapped < 0.0 {
188 0.0
189 } else {
190 wrapped
191 }
192}
193
194/// `(sin, cos)` by refined parabola, absolute error under [`FAST_TRIG_ERR`].
195/// Bounding boxes only need trig that is close — the box gets padded by the
196/// worst-case position error afterwards — and libm's `sincosf`, called twice
197/// per partial arc, was one of the larger single costs of recording a
198/// shape-heavy frame on a watch-class core.
199#[inline]
200fn fast_sin_cos(angle: f32) -> (f32, f32) {
201 use std::f32::consts::{FRAC_PI_2, PI};
202 #[inline]
203 fn fold_sin(x: f32) -> f32 {
204 const B: f32 = 4.0 / PI;
205 const C: f32 = -4.0 / (PI * PI);
206 let y = B * x + C * x * x.abs();
207 0.225 * (y * y.abs() - y) + y
208 }
209 let x = wrap_angle_tau(angle);
210 let x = if x > PI { x - TAU } else { x };
211 let mut c = x + FRAC_PI_2;
212 if c > PI {
213 c -= TAU;
214 }
215 (fold_sin(x), fold_sin(c))
216}
217
218/// Worst-case absolute error of [`fast_sin_cos`]; bounds derived from it are
219/// padded by radius x this so the approximate box always contains the exact
220/// shape.
221const FAST_TRIG_ERR: f32 = 1.3e-3;
222
223impl ArcGeometry {
224 /// Normalizing constructor. Never panics and never stores a NaN.
225 #[inline]
226 pub fn new(
227 center: Point,
228 inner_radius: f32,
229 outer_radius: f32,
230 start_angle: f32,
231 sweep_angle: f32,
232 cap: StrokeCap,
233 ) -> Self {
234 if !all_finite([
235 center.x,
236 center.y,
237 inner_radius,
238 outer_radius,
239 start_angle,
240 sweep_angle,
241 ]) {
242 return Self::DEGENERATE;
243 }
244 let outer = at_least(outer_radius, 0.0);
245 let inner = within(inner_radius, 0.0, outer);
246 Self::with_angles(center, inner, outer, start_angle, sweep_angle, cap)
247 }
248
249 /// [`Self::new`] for the radii [`arc_band`] returns, which already hold
250 /// `0 <= inner <= outer`: the same geometry without clamping them again.
251 #[inline]
252 pub(crate) fn of_band(
253 center: Point,
254 (inner, outer, cap): (f32, f32, StrokeCap),
255 start_angle: f32,
256 sweep_angle: f32,
257 ) -> Self {
258 if !all_finite([center.x, center.y, inner, outer, start_angle, sweep_angle]) {
259 return Self::DEGENERATE;
260 }
261 Self::with_angles(center, inner, outer, start_angle, sweep_angle, cap)
262 }
263
264 /// The geometry of normalized radii with its sweep made positive and at
265 /// most a full turn, and its start wrapped into `[0, TAU)`.
266 #[inline(always)]
267 fn with_angles(
268 center: Point,
269 inner: f32,
270 outer: f32,
271 start_angle: f32,
272 sweep_angle: f32,
273 cap: StrokeCap,
274 ) -> Self {
275 // A sweep in (0, TAU) from a start in [0, TAU) is already normal:
276 // told apart by the bits alone, with no float compare, each an
277 // FPSCR transfer on armv7 (a positive finite float's bits order as
278 // its value; zero, negatives, infinity and NaN fall outside).
279 if sweep_angle.to_bits().wrapping_sub(1) < TAU.to_bits() - 1
280 && start_angle.to_bits() < TAU.to_bits()
281 {
282 return Self {
283 center,
284 inner_radius: inner,
285 outer_radius: outer,
286 start_angle,
287 sweep_angle,
288 cap,
289 };
290 }
291 Self::normalizing_angles(center, inner, outer, start_angle, sweep_angle, cap)
292 }
293
294 /// [`Self::with_angles`] by comparing the angles as floats.
295 #[inline]
296 fn normalizing_angles(
297 center: Point,
298 inner: f32,
299 outer: f32,
300 start_angle: f32,
301 sweep_angle: f32,
302 cap: StrokeCap,
303 ) -> Self {
304 let (mut start, mut sweep) = if sweep_angle < 0.0 {
305 (start_angle + sweep_angle, -sweep_angle)
306 } else {
307 (start_angle, sweep_angle)
308 };
309 if sweep >= TAU {
310 sweep = TAU;
311 start = 0.0;
312 }
313 start = wrap_angle_tau(start);
314 if !start.is_finite() {
315 start = 0.0;
316 }
317 let cap = if sweep >= TAU { StrokeCap::Round } else { cap };
318
319 Self {
320 center,
321 inner_radius: inner,
322 outer_radius: outer,
323 start_angle: start,
324 sweep_angle: sweep,
325 cap,
326 }
327 }
328
329 const DEGENERATE: Self = Self {
330 center: Point::ZERO,
331 inner_radius: 0.0,
332 outer_radius: 0.0,
333 start_angle: 0.0,
334 sweep_angle: 0.0,
335 cap: StrokeCap::Butt,
336 };
337
338 /// Radius of the band's centerline (`ra` in the analytic arc SDF).
339 pub fn mid_radius(&self) -> f32 {
340 (self.inner_radius + self.outer_radius) * 0.5
341 }
342
343 /// Half the band thickness (`rb` in the analytic arc SDF). Also the radius
344 /// of a round cap and the projection distance of a square cap.
345 pub fn half_thickness(&self) -> f32 {
346 (self.outer_radius - self.inner_radius) * 0.5
347 }
348
349 /// True when the band encloses no area and therefore must not be emitted.
350 pub fn is_degenerate(&self) -> bool {
351 !(self.outer_radius > 0.0
352 && self.outer_radius > self.inner_radius
353 && self.sweep_angle > 0.0)
354 }
355
356 /// True when `angle` lies inside `[start, start + sweep]` (mod `TAU`).
357 pub fn contains_angle(&self, angle: f32) -> bool {
358 if self.sweep_angle >= TAU {
359 return true;
360 }
361 let delta = wrap_angle_tau(angle - self.start_angle);
362 delta <= self.sweep_angle + 1e-6
363 }
364
365 /// Scales radii and translates the center. Angles are unchanged, so this is
366 /// only valid for a uniform (non-mirroring) scale.
367 pub fn scaled_about(&self, center: Point, scale: f32) -> Self {
368 Self {
369 center,
370 inner_radius: self.inner_radius * scale,
371 outer_radius: self.outer_radius * scale,
372 ..*self
373 }
374 }
375
376 /// Tight axis-aligned bounding box of the rendered band, caps included.
377 ///
378 /// The box is the union of
379 /// * the two radial ends (inner and outer radius, extended for
380 /// round/square caps), and
381 /// * the outer-radius point at every axis direction (0, 90, 180, 270
382 /// degrees) that the sweep actually crosses.
383 ///
384 /// Sampling only the endpoints would be wrong for any sweep that crosses an
385 /// axis: a 0..270 degree sweep reaches `center.x + outer` *and*
386 /// `center.x - outer` even though neither endpoint does.
387 pub fn bounds(&self) -> Rect {
388 if self.is_degenerate() {
389 return Rect {
390 x: self.center.x,
391 y: self.center.y,
392 width: 0.0,
393 height: 0.0,
394 };
395 }
396
397 if self.sweep_angle >= TAU && self.cap != StrokeCap::Square {
398 let r = self.outer_radius;
399 return Rect {
400 x: self.center.x - r,
401 y: self.center.y - r,
402 width: r + r,
403 height: r + r,
404 };
405 }
406
407 let mut min_x = f32::INFINITY;
408 let mut min_y = f32::INFINITY;
409 let mut max_x = f32::NEG_INFINITY;
410 let mut max_y = f32::NEG_INFINITY;
411 let mut include = |x: f32, y: f32| {
412 min_x = min_x.min(x);
413 min_y = min_y.min(y);
414 max_x = max_x.max(x);
415 max_y = max_y.max(y);
416 };
417
418 let rb = self.half_thickness();
419 let ra = self.mid_radius();
420 let end_angle = self.start_angle + self.sweep_angle;
421
422 for (angle, outward) in [(self.start_angle, -1.0f32), (end_angle, 1.0f32)] {
423 let (sin, cos) = fast_sin_cos(angle);
424 match self.cap {
425 StrokeCap::Butt => {
426 include(
427 self.center.x + cos * self.inner_radius,
428 self.center.y + sin * self.inner_radius,
429 );
430 include(
431 self.center.x + cos * self.outer_radius,
432 self.center.y + sin * self.outer_radius,
433 );
434 }
435 StrokeCap::Square => {
436 let tx = -sin * rb * outward;
437 let ty = cos * rb * outward;
438 include(
439 self.center.x + cos * self.inner_radius + tx,
440 self.center.y + sin * self.inner_radius + ty,
441 );
442 include(
443 self.center.x + cos * self.outer_radius + tx,
444 self.center.y + sin * self.outer_radius + ty,
445 );
446 }
447 StrokeCap::Round => {
448 let cx = self.center.x + cos * ra;
449 let cy = self.center.y + sin * ra;
450 include(cx - rb, cy - rb);
451 include(cx + rb, cy + rb);
452 }
453 }
454 }
455
456 const AXIS_DIRECTIONS: [(f32, f32); 4] = [(0.0, 1.0), (1.0, 0.0), (0.0, -1.0), (-1.0, 0.0)];
457 for (quadrant, (sin, cos)) in AXIS_DIRECTIONS.into_iter().enumerate() {
458 let angle = quadrant as f32 * std::f32::consts::FRAC_PI_2;
459 if self.contains_angle(angle) {
460 include(
461 self.center.x + cos * self.outer_radius,
462 self.center.y + sin * self.outer_radius,
463 );
464 }
465 }
466
467 let pad = (self.outer_radius + rb) * FAST_TRIG_ERR + 0.02;
468 Rect {
469 x: min_x - pad,
470 y: min_y - pad,
471 width: (max_x - min_x + pad + pad).max(0.0),
472 height: (max_y - min_y + pad + pad).max(0.0),
473 }
474 }
475}
476
477/// Resolves the `(inner, outer, cap)` band described by a
478/// [`crate::DrawPrimitive::Arc`].
479///
480/// * `stroke = Some(_)` — a stroked arc centered on `radius`.
481/// * `stroke = None` — a filled annular sector from `inner_radius` to `radius`
482/// with flat (butt) radial ends. `inner_radius <= 0` yields a filled pie
483/// wedge.
484///
485/// Non-finite input collapses to an empty band so the caller drops the draw
486/// instead of pushing NaN down the pipeline.
487#[inline]
488pub fn arc_band(radius: f32, inner_radius: f32, stroke: Option<Stroke>) -> (f32, f32, StrokeCap) {
489 match stroke {
490 Some(stroke) => {
491 if !radius.is_finite() || !stroke.is_visible() {
492 return (0.0, 0.0, stroke.cap);
493 }
494 let half = stroke.half_width();
495 let radius = at_least(radius, 0.0);
496 (at_least(radius - half, 0.0), radius + half, stroke.cap)
497 }
498 None => {
499 if !radius.is_finite() || !inner_radius.is_finite() {
500 return (0.0, 0.0, StrokeCap::Butt);
501 }
502 let outer = at_least(radius, 0.0);
503 let inner = within(inner_radius, 0.0, outer);
504 (inner, outer, StrokeCap::Butt)
505 }
506 }
507}
508
509/// Grows `rect` by `amount` on every side, clamping to a non-negative size.
510pub fn inflate_rect(rect: Rect, amount: f32) -> Rect {
511 if !amount.is_finite() || amount <= 0.0 {
512 return rect;
513 }
514 Rect {
515 x: rect.x - amount,
516 y: rect.y - amount,
517 width: (rect.width + amount * 2.0).max(0.0),
518 height: (rect.height + amount * 2.0).max(0.0),
519 }
520}
521
522/// A stroked straight segment: its two ends, half its width and its cap,
523/// in the units it is drawn in. [`crate::DrawPrimitive::Line`] lowers to it,
524/// and the GPU and CPU renderers take their coverage from the same frame.
525#[derive(Clone, Copy, Debug, PartialEq)]
526pub struct LineGeometry {
527 /// Start of the segment.
528 pub start: Point,
529 /// End of the segment.
530 pub end: Point,
531 /// Half the stroke width, in the units the line is drawn in.
532 pub half_width: f32,
533 /// How the two ends of the segment are shaped.
534 pub cap: StrokeCap,
535}
536
537/// Where a pixel sits relative to a [`LineGeometry`]: its midpoint, the unit
538/// direction from start to end, and half its length.
539#[derive(Clone, Copy, Debug, PartialEq)]
540pub struct LineFrame {
541 /// Midpoint of the segment.
542 pub center: Point,
543 /// Unit vector pointing from the start to the end.
544 pub direction: Point,
545 /// Half the segment's length, in the units it is drawn in.
546 pub half_length: f32,
547}
548
549impl LineGeometry {
550 /// The segment from `start` to `end` stroked with `stroke`.
551 pub fn new(start: Point, end: Point, stroke: Stroke) -> Self {
552 Self {
553 start,
554 end,
555 half_width: stroke.half_width(),
556 cap: stroke.cap,
557 }
558 }
559
560 /// True when the segment covers nothing: a non-finite input, no width,
561 /// or no length between butt caps, as Compose's `drawLine` draws nothing
562 /// there.
563 pub fn is_degenerate(&self) -> bool {
564 !all_finite([self.start.x, self.start.y, self.end.x, self.end.y])
565 || !(self.half_width > 0.0 && self.half_width.is_finite())
566 || (self.cap == StrokeCap::Butt && self.start == self.end)
567 }
568
569 /// The midpoint, unit direction and half length coverage is measured
570 /// in. A segment of no length points along +X, so its round or square
571 /// cap still draws a dot.
572 pub fn frame(&self) -> LineFrame {
573 let (dx, dy) = (self.end.x - self.start.x, self.end.y - self.start.y);
574 let length = (dx * dx + dy * dy).sqrt();
575 let direction = if length > 0.0 {
576 Point::new(dx / length, dy / length)
577 } else {
578 Point::new(1.0, 0.0)
579 };
580 LineFrame {
581 center: Point::new(
582 (self.start.x + self.end.x) * 0.5,
583 (self.start.y + self.end.y) * 0.5,
584 ),
585 direction,
586 half_length: length * 0.5,
587 }
588 }
589
590 /// How far past each end the stroke reaches: half the width for round
591 /// and square caps, nothing for butt caps.
592 pub fn cap_reach(&self) -> f32 {
593 if self.cap == StrokeCap::Butt {
594 0.0
595 } else {
596 self.half_width
597 }
598 }
599
600 /// The axis-aligned box of the ends, the rect the primitive carries.
601 pub fn end_bounds(&self) -> Rect {
602 let min_x = self.start.x.min(self.end.x);
603 let min_y = self.start.y.min(self.end.y);
604 Rect {
605 x: min_x,
606 y: min_y,
607 width: self.start.x.max(self.end.x) - min_x,
608 height: self.start.y.max(self.end.y) - min_y,
609 }
610 }
611
612 /// How far past the ends' box the stroke reaches on each axis: a round
613 /// end's disc bounds it by half the width every way; a butt or square
614 /// end's corners reach along the segment by the cap and across it by
615 /// half the width, which on a slant is more than half the width.
616 pub fn reach(&self) -> Point {
617 if self.cap == StrokeCap::Round {
618 return Point::new(self.half_width, self.half_width);
619 }
620 let frame = self.frame();
621 let (along_x, along_y) = (frame.direction.x.abs(), frame.direction.y.abs());
622 let cap = self.cap_reach();
623 Point::new(
624 along_x * cap + along_y * self.half_width,
625 along_y * cap + along_x * self.half_width,
626 )
627 }
628
629 /// The pixels the stroke can reach: the ends' box grown by
630 /// [`Self::reach`].
631 pub fn bounds(&self) -> Rect {
632 let ends = self.end_bounds();
633 let reach = self.reach();
634 Rect {
635 x: ends.x - reach.x,
636 y: ends.y - reach.y,
637 width: ends.width + reach.x * 2.0,
638 height: ends.height + reach.y * 2.0,
639 }
640 }
641
642 /// The same segment with its ends at `start` and `end` and its width
643 /// scaled by `scale`, as a layer's transform places it.
644 pub fn placed(&self, start: Point, end: Point, scale: f32) -> Self {
645 Self {
646 start,
647 end,
648 half_width: self.half_width * scale,
649 ..*self
650 }
651 }
652
653 /// The share of the pixel centred at `point` the stroke covers: exact
654 /// box coverage across the segment and along it, as a rect's edges are
655 /// covered, so a thin line keeps its weight at any sub-pixel offset;
656 /// past the ends of a round cap, the distance to the cap's disc.
657 /// `shape.wgsl` mirrors it.
658 pub fn coverage(&self, point: Point) -> f32 {
659 let frame = self.frame();
660 let (dx, dy) = (point.x - frame.center.x, point.y - frame.center.y);
661 let along = (dx * frame.direction.x + dy * frame.direction.y).abs();
662 let across = (dx * frame.direction.y - dy * frame.direction.x).abs();
663 let across_coverage = (self.half_width + 0.5 - across).clamp(0.0, 1.0);
664 if self.cap == StrokeCap::Round {
665 if along <= frame.half_length {
666 return across_coverage;
667 }
668 let (past, side) = (along - frame.half_length, across);
669 let distance = (past * past + side * side).sqrt() - self.half_width;
670 return (0.5 - distance).clamp(0.0, 1.0);
671 }
672 let reach = frame.half_length + self.cap_reach();
673 across_coverage * (reach + 0.5 - along).clamp(0.0, 1.0)
674 }
675}
676
677#[cfg(test)]
678#[path = "tests/stroke_tests.rs"]
679mod tests;