use crate::layout::Axis;
use crate::renderer::CornerRadii;
use crate::transform::Transform;
use crate::widgets::Rect;
use smallvec::SmallVec;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlacedShape {
pub rect: Rect,
pub radii: CornerRadii,
pub curvature: f32,
pub placement: Transform,
}
impl PlacedShape {
pub(crate) fn from_clip(
clip: &crate::renderer::tree::ClipRegion,
placement: Transform,
) -> Self {
Self::placed(clip.rect, clip.corner_radius, clip.curvature, placement)
}
pub fn placed(rect: Rect, radii: CornerRadii, curvature: f32, placement: Transform) -> Self {
Self {
rect,
radii,
curvature,
placement,
}
}
#[inline]
pub(crate) fn translated(&self, dx: f32, dy: f32) -> Self {
Self {
placement: self.placement.translated(dx, dy),
..*self
}
}
#[inline]
pub fn world_aabb(&self) -> Rect {
self.placement.map_rect(self.rect)
}
#[inline]
pub fn to_local(&self, scale: f32) -> Option<Transform> {
self.placement.physical_inverse(scale)
}
pub(crate) fn clamped_radii(&self) -> CornerRadii {
clamp_radii(self.radii, self.rect.width, self.rect.height)
}
pub(crate) fn distance(&self, x: f32, y: f32) -> f32 {
self.rect.shape_distance(
x,
y,
crate::widgets::Corners {
radii: self.radii,
curvature: self.curvature,
},
)
}
#[inline]
pub(crate) fn y_bounds(&self) -> (f32, f32) {
self.world_aabb().span(Axis::Vertical)
}
pub(crate) fn is_a_box(&self) -> bool {
self.placement.keeps_axes() && is_square(self.largest_corner_on_surface())
}
pub(crate) fn box_cuts_like_the_shape(&self, ink: Rect) -> bool {
if !self.placement.keeps_axes() {
return false;
}
let (sx, sy) = self.placement.extract_scale_components();
let r = self.clamped_radii();
let b = self.world_aabb();
let (right, bottom) = (b.x + b.width, b.y + b.height);
[
(r.top_left, b.x, b.y, 1.0, 1.0),
(r.top_right, right, b.y, -1.0, 1.0),
(r.bottom_right, right, bottom, -1.0, -1.0),
(r.bottom_left, b.x, bottom, 1.0, -1.0),
]
.into_iter()
.all(|(radius, cx, cy, dx, dy)| {
if is_square(radius * sx.max(sy)) {
return true;
}
let (w, h) = (radius * sx * dx, radius * sy * dy);
let wedge = Rect::new(cx.min(cx + w), cy.min(cy + h), w.abs(), h.abs());
!overlaps(wedge, ink)
})
}
fn largest_corner_on_surface(&self) -> f32 {
let (sx, sy) = self.placement.extract_scale_components();
self.clamped_radii().max() * sx.max(sy)
}
fn samples_per_corner(&self) -> usize {
let radius = self.largest_corner_on_surface();
((radius * 0.62).sqrt().ceil() as usize).clamp(2, 12)
}
pub(crate) fn outline(&self) -> Outline {
let declared = self.clamped_radii();
let r = if self.curvature < 0.0 {
CornerRadii::uniform(0.0)
} else {
declared
};
let (x0, y0) = (self.rect.x, self.rect.y);
let (x1, y1) = (x0 + self.rect.width, y0 + self.rect.height);
let corners = [
Corner {
centre: (x0 + r.top_left, y0 + r.top_left),
radius: r.top_left,
quadrant: (-1.0, -1.0),
},
Corner {
centre: (x1 - r.top_right, y0 + r.top_right),
radius: r.top_right,
quadrant: (1.0, -1.0),
},
Corner {
centre: (x1 - r.bottom_right, y1 - r.bottom_right),
radius: r.bottom_right,
quadrant: (1.0, 1.0),
},
Corner {
centre: (x0 + r.bottom_left, y1 - r.bottom_left),
radius: r.bottom_left,
quadrant: (-1.0, 1.0),
},
];
let mut edges: SmallVec<[Edge; 8]> = SmallVec::new();
for (from, to) in [
((x0 + r.top_left, y0), (x1 - r.top_right, y0)),
((x1, y0 + r.top_right), (x1, y1 - r.bottom_right)),
((x1 - r.bottom_right, y1), (x0 + r.bottom_left, y1)),
((x0, y1 - r.bottom_left), (x0, y0 + r.top_left)),
] {
edges.push(Edge::placed(from, to, &self.placement));
}
let mut arcs: SmallVec<[Arc; 4]> = SmallVec::new();
for corner in corners.iter().filter(|c| c.radius > 0.0) {
let k = self.curvature;
if k == 1.0 {
arcs.push(Arc::placed(corner, &self.placement));
} else if k == 0.0 {
edges.push(Edge::placed(
corner.on_x_side(),
corner.on_y_side(),
&self.placement,
));
} else {
let mut previous = corner.on_x_side();
for point in corner.superellipse(k, self.samples_per_corner()) {
edges.push(Edge::placed(previous, point, &self.placement));
previous = point;
}
edges.push(Edge::placed(previous, corner.on_y_side(), &self.placement));
}
}
let bites = if self.curvature < 0.0 {
corners
.iter()
.zip(declared.to_array())
.filter(|(_, radius)| *radius > 0.0)
.map(|(corner, radius)| Arc::placed(&Corner { radius, ..*corner }, &self.placement))
.collect()
} else {
SmallVec::new()
};
Outline { edges, arcs, bites }
}
#[cfg(test)]
pub(crate) fn span_at(&self, y: f32) -> Option<(f32, f32)> {
let spans = self.outline().span_at(y);
assert!(
spans.len() <= 1,
"scanline {y} meets this shape in {} intervals, not one",
spans.len()
);
spans.first().copied()
}
}
pub(crate) struct Outline {
edges: SmallVec<[Edge; 8]>,
arcs: SmallVec<[Arc; 4]>,
bites: SmallVec<[Arc; 4]>,
}
pub(crate) type Spans = SmallVec<[(f32, f32); 2]>;
pub(crate) fn subtract(spans: &Spans, (lo, hi): (f32, f32)) -> Spans {
let mut out = Spans::new();
for &(a, b) in spans {
if hi <= a || lo >= b {
out.push((a, b));
continue;
}
if a < lo {
out.push((a, lo));
}
if hi < b {
out.push((hi, b));
}
}
out
}
impl Outline {
pub(crate) fn bounds_at(&self, y: f32) -> Option<(f32, f32)> {
let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
let mut hit = |x: f32| {
lo = lo.min(x);
hi = hi.max(x);
};
for edge in &self.edges {
let ((ax, ay), (bx, by)) = (edge.from, edge.to);
if by == ay {
if (ay - y).abs() < 1e-4 {
hit(ax);
hit(bx);
}
continue;
}
let t = (y - ay) / (by - ay);
if (0.0..=1.0).contains(&t) {
hit(ax + t * (bx - ax));
}
}
for arc in &self.arcs {
arc.hits(y, &mut hit);
}
(lo <= hi).then_some((lo, hi))
}
#[cfg(test)]
pub(crate) fn span_at(&self, y: f32) -> Spans {
let mut spans = Spans::new();
let Some(bounds) = self.bounds_at(y) else {
return spans;
};
spans.push(bounds);
self.take_bites_from(&mut spans, y, y);
spans
}
pub(crate) fn is_bitten(&self) -> bool {
!self.bites.is_empty()
}
pub(crate) fn take_bites_from(&self, spans: &mut Spans, y0: f32, y1: f32) {
for bite in &self.bites {
if let Some(cut) = bite.removed_between(y0, y1) {
*spans = subtract(spans, cut);
}
}
}
}
#[derive(Debug, Clone, Copy)]
struct Edge {
from: (f32, f32),
to: (f32, f32),
}
impl Edge {
fn placed(from: (f32, f32), to: (f32, f32), placement: &Transform) -> Self {
Self {
from: placement.transform_point(from.0, from.1),
to: placement.transform_point(to.0, to.1),
}
}
}
#[derive(Debug, Clone, Copy)]
struct Corner {
centre: (f32, f32),
radius: f32,
quadrant: (f32, f32),
}
impl Corner {
fn superellipse(&self, curvature: f32, samples: usize) -> impl Iterator<Item = (f32, f32)> {
let n = 2f32.powf(curvature);
let (cx, cy) = self.centre;
let (qx, qy) = self.quadrant;
let r = self.radius;
(1..=samples).map(move |i| {
let t = std::f32::consts::FRAC_PI_2 * i as f32 / (samples + 1) as f32;
(
cx + qx * r * t.cos().powf(2.0 / n),
cy + qy * r * t.sin().powf(2.0 / n),
)
})
}
fn on_x_side(&self) -> (f32, f32) {
(self.centre.0 + self.quadrant.0 * self.radius, self.centre.1)
}
fn on_y_side(&self) -> (f32, f32) {
(self.centre.0, self.centre.1 + self.quadrant.1 * self.radius)
}
}
#[derive(Debug, Clone, Copy)]
struct Arc {
corner: Corner,
centre_y: f32,
solve: Solve,
placement: Transform,
widest_x: (f32, f32),
}
#[derive(Debug, Clone, Copy)]
enum Solve {
Upright { half_height: f32 },
Turned { amplitude: f32, phase: f32 },
}
impl Arc {
fn placed(corner: &Corner, placement: &Transform) -> Self {
let (_, centre_y) = placement.transform_point(corner.centre.0, corner.centre.1);
let t = placement.b().atan2(placement.a());
let widest_x = (t.cos(), t.sin());
if placement.keeps_axes() {
return Self {
corner: *corner,
centre_y,
solve: Solve::Upright {
half_height: placement.d() * corner.radius,
},
placement: *placement,
widest_x,
};
}
let (p, q) = (placement.c() * corner.radius, placement.d() * corner.radius);
Self {
corner: *corner,
centre_y,
solve: Solve::Turned {
amplitude: (p * p + q * q).sqrt(),
phase: q.atan2(p),
},
placement: *placement,
widest_x,
}
}
fn at_height(&self, target: f32) -> Option<[(f32, f32); 2]> {
match self.solve {
Solve::Upright { half_height } => {
if half_height.abs() < f32::EPSILON || target.abs() > half_height.abs() {
return None;
}
let st = target / half_height;
let ct = (1.0 - st * st).max(0.0).sqrt();
Some([(ct, st), (-ct, st)])
}
Solve::Turned { amplitude, phase } => {
if amplitude < f32::EPSILON || target.abs() > amplitude {
return None;
}
let base = (target / amplitude).clamp(-1.0, 1.0).acos();
let (a, b) = (phase + base, phase - base);
Some([(a.cos(), a.sin()), (b.cos(), b.sin())])
}
}
}
fn point_of(&self, ct: f32, st: f32) -> (f32, f32) {
let (cx, cy) = self.corner.centre;
let r = self.corner.radius;
self.placement.transform_point(cx + r * ct, cy + r * st)
}
fn removed_between(&self, y0: f32, y1: f32) -> Option<(f32, f32)> {
let (lo, hi) = if y0 <= y1 { (y0, y1) } else { (y1, y0) };
let (mut left, mut right) = (f32::INFINITY, f32::NEG_INFINITY);
let mut take = |x: f32| {
left = left.min(x);
right = right.max(x);
};
for edge in [lo, hi] {
if let Some(pair) = self.at_height(edge - self.centre_y) {
for (ct, st) in pair {
take(self.point_of(ct, st).0);
}
}
}
let (ct, st) = self.widest_x;
for (ct, st) in [(ct, st), (-ct, -st)] {
let (x, y) = self.point_of(ct, st);
if (lo..=hi).contains(&y) {
take(x);
}
}
(left <= right).then_some((left, right))
}
fn hits(&self, y: f32, hit: &mut impl FnMut(f32)) {
let target = y - self.centre_y;
let Some(pair) = self.at_height(target) else {
return;
};
for (ct, st) in pair {
if ct * self.corner.quadrant.0 < -1e-6 || st * self.corner.quadrant.1 < -1e-6 {
continue;
}
hit(self.point_of(ct, st).0);
}
}
}
fn is_square(radius_on_surface: f32) -> bool {
radius_on_surface <= 0.5
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
}
fn clamp_radii(radii: CornerRadii, w: f32, h: f32) -> CornerRadii {
let half = (w * 0.5).min(h * 0.5);
let clamp = |r: f32| r.max(0.0).min(half);
CornerRadii {
top_left: clamp(radii.top_left),
top_right: clamp(radii.top_right),
bottom_right: clamp(radii.bottom_right),
bottom_left: clamp(radii.bottom_left),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::renderer::CornerRadii;
use crate::widgets::Rect;
fn scooped(placement: Transform) -> PlacedShape {
PlacedShape::placed(
Rect::new(0.0, 0.0, 120.0, 120.0),
CornerRadii::uniform(40.0),
-1.0,
placement,
)
}
fn sheared() -> Transform {
Transform::rotate_degrees(25.0).then(&Transform::scale_xy(3.0, 1.0))
}
#[test]
fn a_circle_answers_only_the_scanlines_it_crosses() {
let corner = Corner {
centre: (40.0, 40.0),
radius: 40.0,
quadrant: (-1.0, -1.0),
};
for flat in [
Arc::placed(
&Corner {
radius: 0.0,
..corner
},
&Transform::IDENTITY,
),
Arc::placed(&corner, &Transform::scale_xy(1.0, 0.0)),
] {
for target in [0.0_f32, 1.0, -1.0] {
assert!(
flat.at_height(target).is_none(),
"a circle with no height crosses no scanline, and answering \
one means answering with NaN"
);
}
}
for placement in [
Transform::IDENTITY,
Transform::rotate_degrees(30.0),
sheared(),
Transform::scale_xy(2.0, 3.0),
] {
let arc = Arc::placed(&corner, &placement);
let reach = match arc.solve {
Solve::Upright { half_height } => half_height.abs(),
Solve::Turned { amplitude, .. } => amplitude,
};
assert!(reach > 1.0, "the placed circle has to have some height");
for (target, crosses) in [
(0.0, true),
(reach * 0.5, true),
(-reach * 0.5, true),
(reach, true),
(-reach, true),
(reach * 1.01, false),
(-reach * 1.01, false),
(reach * 4.0, false),
] {
let answer = arc.at_height(target);
assert_eq!(
answer.is_some(),
crosses,
"a height of {target} against a reach of {reach}"
);
for (ct, st) in answer.into_iter().flatten() {
assert!(
(ct * ct + st * st - 1.0).abs() < 1e-3,
"({ct}, {st}) is not on the unit circle"
);
let (_, y) = arc.point_of(ct, st);
assert!(
(y - arc.centre_y - target).abs() < 1e-2,
"asked for height {target} and got a point at {}",
y - arc.centre_y
);
}
}
}
}
#[test]
fn subtract_removes_exactly_the_cut_and_leaves_real_intervals() {
let spans: Spans = [(0.0_f32, 10.0), (20.0, 30.0)].into_iter().collect();
for cut in [
(-5.0_f32, -1.0), (40.0, 50.0), (-5.0, 0.0), (10.0, 15.0), (-5.0, 4.0), (6.0, 15.0), (3.0, 7.0), (-5.0, 35.0), (0.0, 10.0), (5.0, 5.0), ] {
let out = subtract(&spans, cut);
for &(left, right) in &out {
assert!(
left < right,
"cut {cut:?} left a span {left}..{right} that is not an interval"
);
}
for pair in out.windows(2) {
assert!(
pair[0].1 <= pair[1].0,
"cut {cut:?} returned {out:?}, which is not ascending and disjoint"
);
}
let mut x = -10.0_f32;
while x < 40.0 {
let was_in = spans.iter().any(|&(a, b)| x >= a && x < b);
let cut_out = x >= cut.0 && x < cut.1;
let is_in = out.iter().any(|&(a, b)| x >= a && x < b);
assert_eq!(
is_in,
was_in && !cut_out,
"cut {cut:?} on {spans:?} gave {out:?}, which disagrees at x={x}"
);
x += 0.25;
}
}
}
#[test]
fn where_the_box_is_called_exact_no_pixel_disagrees() {
use crate::widgets::Corners;
let radii = [
CornerRadii {
top_left: 30.0,
top_right: 12.0,
bottom_right: 40.0,
bottom_left: 22.0,
},
CornerRadii {
top_left: 26.0,
top_right: 0.0,
bottom_right: 0.0,
bottom_left: 18.0,
},
];
let placements = [
Transform::IDENTITY,
Transform::scale_xy(2.0, 3.0),
Transform::scale_xy(0.5, 1.5),
Transform::scale_xy(-1.0, 1.0),
Transform::rotate_degrees(20.0),
];
let mut exact = 0;
for (w, h) in [(200.0_f32, 100.0_f32), (90.0, 240.0)] {
for radii in radii {
for placement in placements {
let shape =
PlacedShape::placed(Rect::new(0.0, 0.0, w, h), radii, 1.0, placement);
let Some(to_local) = placement.inverse() else {
continue;
};
let corners = Corners {
radii: shape.clamped_radii(),
curvature: 1.0,
};
let local = Rect::new(0.0, 0.0, w, h);
let b = shape.world_aabb();
let step = 24.0;
let mut y = b.y - step;
while y < b.y + b.height + step {
let mut x = b.x - step;
while x < b.x + b.width + step {
let ink = Rect::new(x, y, step, step);
if shape.box_cuts_like_the_shape(ink) {
exact += 1;
let mut py = ink.y;
while py < ink.y + ink.height {
let mut px = ink.x;
while px < ink.x + ink.width {
let in_box = px >= b.x
&& px < b.x + b.width
&& py >= b.y
&& py < b.y + b.height;
if in_box {
let (lx, ly) = to_local.transform_point(px, py);
assert!(
local.contains_shape(lx, ly, corners),
"{w}x{h} under {placement:?}: the box is called \
exact for {ink:?}, and ({px}, {py}) is inside the \
box and outside the shape"
);
}
px += 1.0;
}
py += 1.0;
}
}
x += step;
}
y += step;
}
}
}
}
assert!(
exact > 100,
"the predicate said the box was exact {exact} times, which is too \
few for this to have checked anything"
);
}
#[test]
fn a_box_cuts_like_its_shape_only_where_the_corners_are_not() {
let card = |radius: f32, placement: Transform| {
PlacedShape::placed(
Rect::new(0.0, 0.0, 200.0, 100.0),
CornerRadii::uniform(radius),
1.0,
placement,
)
};
let square = card(0.0, Transform::IDENTITY);
assert!(square.box_cuts_like_the_shape(Rect::new(0.0, 0.0, 200.0, 100.0)));
assert!(
square.box_cuts_like_the_shape(Rect::new(-10.0, -10.0, 220.0, 120.0)),
"ink running past every corner point is still cut by the box, which \
is the shape — a square corner has no wedge to reach into"
);
let hairline = card(0.3, Transform::IDENTITY);
assert!(hairline.is_a_box());
assert!(hairline.box_cuts_like_the_shape(Rect::new(-10.0, -10.0, 220.0, 120.0)));
assert!(
!card(0.3, Transform::scale(4.0))
.box_cuts_like_the_shape(Rect::new(-10.0, -10.0, 900.0, 500.0))
);
let rounded = card(30.0, Transform::IDENTITY);
assert!(
rounded.box_cuts_like_the_shape(Rect::new(40.0, 35.0, 120.0, 30.0)),
"a label in the middle of the card is cut the same by either"
);
assert!(
!rounded.box_cuts_like_the_shape(Rect::new(10.0, 10.0, 40.0, 20.0)),
"ink inside a corner box is where the two answers differ"
);
assert!(
rounded.box_cuts_like_the_shape(Rect::new(0.0, 35.0, 200.0, 20.0)),
"the corner box is as tall as the radius and no taller"
);
let turned = card(0.0, Transform::rotate_degrees(15.0));
assert!(!turned.box_cuts_like_the_shape(Rect::new(80.0, 40.0, 20.0, 10.0)));
let scaled = card(30.0, Transform::scale_xy(3.0, 3.0));
assert!(rounded.box_cuts_like_the_shape(Rect::new(0.0, 35.0, 200.0, 20.0)));
assert!(
!scaled.box_cuts_like_the_shape(Rect::new(0.0, 35.0, 600.0, 20.0)),
"a 30 radius under a 3x scale is 90 pixels of corner"
);
}
#[test]
fn a_band_claims_nothing_the_shape_misses_anywhere_in_it() {
for placement in [
Transform::IDENTITY,
Transform::rotate_degrees(30.0),
sheared(),
] {
let shape = scooped(placement);
let outline = shape.outline();
let (top, bottom) = shape.y_bounds();
let bands = 24;
let step = (bottom - top) / bands as f32;
let mut checked = 0;
for i in 0..bands {
let (y0, y1) = (top + i as f32 * step, top + (i + 1) as f32 * step);
let (Some(a), Some(b)) = (outline.bounds_at(y0), outline.bounds_at(y1)) else {
continue;
};
let band = (a.0.max(b.0), a.1.min(b.1));
let mut claimed = Spans::new();
if band.0 < band.1 {
claimed.push(band);
}
outline.take_bites_from(&mut claimed, y0, y1);
for &(lo, hi) in &claimed {
for t in 0..=8 {
let x = lo + (hi - lo) * t as f32 / 8.0;
for s in 0..=20 {
let y = y0 + (y1 - y0) * s as f32 / 20.0;
let there = outline.span_at(y);
let covered = there
.iter()
.any(|&(left, right)| x >= left - 1e-3 && x <= right + 1e-3);
assert!(
covered,
"the band {y0}..{y1} claims x={x}, which the shape does not \
cover at y={y}: it is {there:?} there"
);
checked += 1;
}
}
}
}
assert!(
checked > 2000,
"the bands have to actually claim something to be worth checking: {checked}"
);
}
}
#[test]
fn a_corner_with_no_radius_is_not_a_bite() {
let shape = PlacedShape::placed(
Rect::new(0.0, 0.0, 120.0, 60.0),
CornerRadii {
top_left: 50.0,
top_right: 50.0,
bottom_right: 0.0,
bottom_left: 0.0,
},
-1.0,
Transform::IDENTITY,
);
assert_eq!(
shape.outline().bites.len(),
2,
"two corners are scooped and two have no radius at all"
);
let round = PlacedShape::placed(
Rect::new(0.0, 0.0, 120.0, 60.0),
CornerRadii::uniform(20.0),
1.0,
Transform::IDENTITY,
);
assert!(round.outline().bites.is_empty(), "only a scoop bites");
}
#[test]
fn a_bite_does_not_care_which_end_of_the_band_it_is_given() {
let outline = scooped(sheared()).outline();
let (top, bottom) = scooped(sheared()).y_bounds();
let mut agreed = 0;
for i in 0..24 {
let (y0, y1) = (
top + (bottom - top) * i as f32 / 24.0,
top + (bottom - top) * (i + 1) as f32 / 24.0,
);
let mut forward = Spans::new();
forward.push((-1e4, 1e4));
outline.take_bites_from(&mut forward, y0, y1);
let mut backward = Spans::new();
backward.push((-1e4, 1e4));
outline.take_bites_from(&mut backward, y1, y0);
assert_eq!(
forward, backward,
"the band {y0}..{y1} lost a different bite when named backwards"
);
if forward.len() > 1 {
agreed += 1;
}
}
assert!(
agreed > 0,
"no band here actually had a bite taken out of it, so the two \
orders agreed on nothing"
);
}
}