add support for bidirectional rectangle border and inset (#7188)

Extend rectangle shape to allow for both inner and outer borders, at any distance from the main shape body.

No additional instance parameters has been added. Instead, the negative values of both border and inset values are used to allow extra capabilities. Additionally, the small (<1px) gap between border and body caused by anti-aliasing has been fixed.

https://github.com/enso-org/enso/assets/919491/0ae709f2-db7b-4a45-a9d3-7fbb8802dc8c


<img width="657" alt="image" src="https://github.com/enso-org/enso/assets/919491/40e5963d-0717-4662-abf4-b9687aa921ed">
This commit is contained in:
Paweł Grabarz 2023-07-05 21:10:57 +02:00 committed by GitHub
parent 6eb46afb40
commit d11f09c192
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 235 additions and 56 deletions

View File

@ -288,7 +288,7 @@ pub(super) trait ShapeParent: display::Object {
fn new_section(&self) -> Rectangle {
let new = Rectangle::new();
new.set_corner_radius_max();
new.set_inset_border(LINE_WIDTH);
new.set_border_and_inset(LINE_WIDTH);
new.set_color(color::Rgba::transparent());
new.set_pointer_events(false);
self.display_object().add_child(&new);
@ -300,9 +300,8 @@ pub(super) trait ShapeParent: display::Object {
fn new_hover_section(&self) -> Rectangle {
let new = Rectangle::new();
new.set_corner_radius_max();
new.set_inset_border(HOVER_WIDTH);
new.set_border_and_inset(HOVER_WIDTH);
new.set_color(color::Rgba::transparent());
new.set_border_color(INVISIBLE_HOVER_COLOR);
self.display_object().add_child(&new);
new
}

View File

@ -119,7 +119,7 @@ impl Background {
let inset = selection_size + selection_offset;
let shape = Rectangle();
shape.set_corner_radius(RADIUS);
shape.set_border(selection_size);
shape.set_frame_border(selection_size);
shape.set_border_color(color::Rgba::transparent());
shape.set_inset(inset);
Self { shape, inset: Immutable(inset), selection_color: Immutable(selection_color) }

View File

@ -47,7 +47,6 @@ const SKIP_TOOLTIP_LABEL: &str = "Skip";
fn hover_area() -> Rectangle {
let area = Rectangle();
area.set_color(INVISIBLE_HOVER_COLOR);
area.set_border_color(INVISIBLE_HOVER_COLOR);
area
}

View File

@ -121,7 +121,7 @@ impl PlaceholderModel {
t.set_size(Vector2::new(0.0, 10.0))
.allow_grow_x()
.set_color(color::Rgba::new(1.0, 0.0, 0.0, 0.3))
.set_inset_border(2.0)
.set_border_and_inset(2.0)
.set_border_color(color::Rgba::new(1.0, 0.0, 0.0, 1.0));
});
root.add_child(&viz);

View File

@ -20,6 +20,16 @@ pub use shape::Shape;
// =================
// === Constants ===
// =================
/// The threshold of minimum border width, below which the border is considered to be not visible.
/// This is necessary to be able to avoid rendering zero-width border completely, removing any
/// artifacts caused by anti-aliasing near the zero value.
const MINIMUM_BORDER_WIDTH: f32 = 0.1;
// =============
// === Shape ===
// =============
@ -32,11 +42,11 @@ pub mod shape {
(
style: Style,
color: Vector4,
border_color: Vector4,
clip: Vector2,
corner_radius: f32,
inset: f32,
border: f32,
border_color: Vector4,
clip: Vector2,
rotate: f32,
) {
// === Canvas ===
@ -52,21 +62,34 @@ pub mod shape {
let canvas_width = canvas_width + &canvas_clip_width_diff.abs();
// === Body ===
let inset2 = (&inset * 2.0).px();
let inset2 = (Max::max(inset.clone(), Var::from(0.0)) * 2.0).px();
let width = &canvas_width - &inset2;
let height = &canvas_height - &inset2;
let color = Var::<color::Rgba>::from(color);
let body = Rect((&width, &height)).corners_radius(corner_radius.px());
let body = body.fill(color);
// === Border ===
let padded_body = body.grow((inset - &border).px());
let border = padded_body.grow(border.px()) - padded_body;
let border_color = Var::<color::Rgba>::from(border_color);
let border = border.fill(border_color);
let border_center = &inset * border.negative() + &border * 0.5;
let abs_border = border.abs();
// when border width is close enough to zero, offset it thickness into far negatives to
// avoid rendering it completely. Necessary due to anti-aliasing.
let border_below_threshold = (&abs_border - Var::from(MINIMUM_BORDER_WIDTH)).negative();
let border_thickness = abs_border - border_below_threshold * 1000.0;
let border_body = body.grow(border_center.px()).stroke(border_thickness.px());
// When the border is touching the edge of the body, extend the body by up to a pixel.
// That way there is no visual gap between the shapes caused by anti-aliasing. In those
// scenarios, the extended body will be occluded by the border, therefore it will not
// have any visible effect, other than removing the unwanted artifact.
let fwidth = Var::<f32>::from("fwidth(position.x)");
let touch_offset = border.clamp(0.0.into(), fwidth);
let body = body.grow(touch_offset);
// === Shape ===
let shape = border.union_exclusive(&body);
let color = Var::<color::Rgba>::from(color);
let border_color = Var::<color::Rgba>::from(border_color);
let colored_body = body.fill(color);
let colored_border = border_body.fill(border_color);
let shape = colored_body.union_exclusive(&colored_border);
// === Rotation ===
// Rotate about one corner.
@ -101,12 +124,18 @@ pub mod shape {
/// such as circles, rings, or ring segments. The advantage of having a singular shape for these
/// cases is that a single draw call can be used to render multiple GUI elements, which ultimately
/// enhances performance.
#[derive(Clone, CloneRef, Deref, Default)]
#[derive(Clone, CloneRef, Deref)]
#[allow(missing_docs)]
pub struct Rectangle {
pub view: shape::View,
}
impl Default for Rectangle {
fn default() -> Self {
Self::new()
}
}
impl Debug for Rectangle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Rectangle").finish()
@ -121,7 +150,7 @@ impl Rectangle {
/// Constructor.
pub fn new() -> Self {
Self::default().build(|r| {
Self { view: default() }.build(|r| {
r.set_border_color(display::shape::INVISIBLE_HOVER_COLOR);
})
}
@ -138,8 +167,18 @@ impl Rectangle {
self.modify_view(|view| view.color.set(color.into()))
}
/// Set the corner radius. If the corner radius will be larger than possible (e.g. larger than
/// the shape dimension), it will be clamped to the highest possible value.
/// Set the corner radius of the body.
///
/// Note that the corner radius of the border will be different, depending on the chosen border
/// settings. The corner radius of border shape will always naturally flow around the rectangle
/// body, following its curvature and keeping corners concentric.
///
/// For [outer border](Self::set_border), the outer border's radius will be equal to the body's
/// corner radius plus the border width. For [inner border](Self::set_border_inner), the inner
/// border's radius will be smaller, depending on used distance.
///
/// If the corner radius will be larger than the body size, it will be clamped to at most half
/// of the smaller body dimension.
pub fn set_corner_radius(&self, radius: f32) -> &Self {
self.modify_view(|view| view.corner_radius.set(radius))
}
@ -156,26 +195,64 @@ impl Rectangle {
/// Set the padding between edge of the frame and main shape.
///
/// This value should not be less than the width of the border. To set it to the same width as
/// the border, you can use [`Self::set_inset_border`].
/// the border, you can use [`Self::set_border_and_inset`].
///
/// If this value is greater than the border width, the extra padding will be between the body
/// and the border.
/// If the inset value is greater than the absolute value of border width, the shape will have
/// additional transparent padding. If the inset value is positive, the extra padding will
/// be between the body and the outside frame (size) of the rectangle. When it is negative,
/// it can be used to inset the border into the body. To avoid confusion, it is recommended
/// to use [`Self::set_inner_border`] instead.
pub fn set_inset(&self, inset: f32) -> &Self {
self.modify_view(|view| view.inset.set(inset))
}
/// Set the border size of the shape. If you want to use border, you should always set the inset
/// at least of the size of the border. If you do not want the border to be animated, you can
/// use [`Self::set_inset_border`] instead. To make the border visible, you also need to set the
/// border color using [`Self::set_border_color`].
pub fn set_border(&self, border: f32) -> &Self {
self.modify_view(|view| view.border.set(border))
/// Set the outer border size of the shape. The border will grow outwards from the rectangle
/// body, towards the frame. In order to accommodate its width, use [`Self::set_inset`] to set
/// the inset value to be greater or equal to the maximum planned border's width.
///
/// If you don't plan to animate the border width, you can use [`Self::set_border_and_inset`] to
/// set both settings to the same value.
pub fn set_border(&self, border_width: f32) -> &Self {
self.modify_view(|view| view.border.set(border_width.max(0.0)))
}
/// Set both the inset and border at once. See documentation of [`Self::set_border`] and
/// [`Self::set_inset`] to learn more. To make the border visible, you also need to set the
/// border color using [`Self::set_border_color`].
pub fn set_inset_border(&self, border: f32) -> &Self {
/// Set the inner border size of the shape, positioned within the rectangle body. The inner
/// border can be offset away from the body's edge by using a non-zero `distance` value.
///
/// When the `distance` is set to 0, the effect is similar to an outer border set using
/// [`Self::set_border_and_inset`], but the interpretation of corner radius is different. In
/// this case, the effective corner radius of the border's outside edge will be equal to the
/// body's, corner radius, while the inside edge corners will be appropriately sharper.
///
/// NOTE: This setting will override the inset value set with [`Self::set_inset`], setting it to
/// a negative value. Therefore mixing it with other border settings is not recommended.
pub fn set_inner_border(&self, border_width: f32, distance: f32) -> &Self {
self.modify_view(|view| {
let clamped_width = border_width.max(0.0);
view.border.set(-clamped_width);
view.inset.set(-distance.max(0.0));
})
}
/// Set the frame border size of the shape. The border will grow inwards from the frame's
/// outside edge, towards the rectangle body. In order to create space between the body and
/// frame's border, use [`Self::set_inset`] to set the inset value to be greater or equal to the
/// maximum planned border's width.
///
/// If you don't plan to animate the border width, you can use [`Self::set_border_and_inset`] to
/// set both settings to the same value.
pub fn set_frame_border(&self, border_width: f32) -> &Self {
self.modify_view(|view| view.border.set(-border_width.max(0.0)))
}
/// Set both the inset and border at once. The effect is visually similar to an inner border set
/// using [`Self::set_inner_border`] with distance set to 0, but the interpretation of corner
/// radius is different. In this case, the effective corner radius of the border's outside edge
/// will be equal to the body's corner radius plus the border width.
///
/// See documentation of [`Self::set_border`] and [`Self::set_inset`] to learn more. To make the
/// border visible, you also need to set the border color using [`Self::set_border_color`].
pub fn set_border_and_inset(&self, border: f32) -> &Self {
self.set_inset(border).set_border(border)
}

View File

@ -128,6 +128,10 @@ where for<'t> &'t Self: IntoOwned<Owned = Self> {
}
/// Unify the shape with another one.
///
/// Warning: This operator is an approximation - given correct exact input signed distance, it
/// will return exact distance outside the shape, but underestimate the distance inside the
/// shape. For more details, see https://iquilezles.org/articles/interiordistance/
fn union<S: IntoOwned>(&self, that: S) -> Union<Self, Owned<S>> {
Union(self, that)
}
@ -135,16 +139,28 @@ where for<'t> &'t Self: IntoOwned<Owned = Self> {
/// Unify two shapes, blending their colors based on the foreground shape's SDF value. This
/// means that even if these shapes overlap and the foreground is semi-transparent, it will
/// blend with the background only in the anti-aliased areas.
///
/// Warning: This operator is an approximation - given correct exact input signed distance, it
/// will return exact distance outside the shape, but underestimate the distance inside the
/// shape. For more details, see https://iquilezles.org/articles/interiordistance/
fn union_exclusive<S: IntoOwned>(&self, that: S) -> UnionExclusive<Self, Owned<S>> {
UnionExclusive(self, that)
}
/// Subtracts the argument from this shape.
///
/// Warning: This operator is an approximation - given correct exact input signed distance, it
/// will overestimate the distance to the resulting shape. For more details, see
/// https://iquilezles.org/articles/interiordistance/
fn difference<S: IntoOwned>(&self, that: S) -> Difference<Self, Owned<S>> {
Difference(self, that)
}
/// Computes the intersection of the shapes.
///
/// Warning: This operator is an approximation - given correct exact input signed distance, it
/// will underestimate the distance to the resulting shape. For more details, see
/// https://iquilezles.org/articles/interiordistance/
fn intersection<S: IntoOwned>(&self, that: S) -> Intersection<Self, Owned<S>> {
Intersection(self, that)
}
@ -190,7 +206,7 @@ where for<'t> &'t Self: IntoOwned<Owned = Self> {
Recolorize(self, r, g, b)
}
/// Makes the borders of the shape crisp. Please note that it removes any form of antialiasing
/// Makes the borders of the shape crisp. Please note that it removes any form of anti-aliasing
/// and can cause distortions especially with round surfaces.
fn pixel_snap(&self) -> PixelSnap<Self> {
PixelSnap(self)
@ -206,6 +222,23 @@ where for<'t> &'t Self: IntoOwned<Owned = Self> {
Shrink(self, value.into())
}
/// Create a stroke of given thickness around shape's boundary. The stroke is centered around
/// the shape's 0 distance isoline. If you want to offset it, use `grow` or `shrink` operators
/// before applying `stroke`.
///
/// Also known as "annulus" or "onion" operator. See "Making shapes annular" section in
/// https://iquilezles.org/articles/distfunctions2d/ for more details.
///
/// Note: This operator is exact - given correct exact input signed distance, it will produce an
/// exact signed distance field for the resulting shape. But it is particularly sensitive to
/// non-exactness of the input signed distance field, both outside and inside the shape. Be
/// careful when using it after applying `union`, `difference` or `intersection` operators. See
/// following shadertoy for demonstration of potential artifacts:
/// https://www.shadertoy.com/view/dslfzH
fn stroke<T: Into<Var<Pixels>>>(&self, thickness: T) -> Stroke<Self> {
Stroke(self, thickness.into())
}
/// Repeats the shape with the given tile size.
fn repeat<T: Into<Var<Vector2<Pixels>>>>(&self, value: T) -> Repeat<Self> {
Repeat(self, value)

View File

@ -129,4 +129,5 @@ define_modifiers! {
Grow grow (child) (value:f32)
Shrink shrink (child) (value:f32)
Repeat repeat (child) (tile_size:Vector2<Pixels>)
Stroke stroke (child) (thickness:f32)
}

View File

@ -12,6 +12,7 @@ use crate::display::shape::primitive::def::class::AnyShape;
use crate::display::shape::primitive::def::class::ShapeRef;
use crate::display::shape::primitive::shader::canvas;
use crate::display::shape::primitive::shader::canvas::Canvas;
use crate::display::shape::Grow;
use crate::display::shape::Var;
use crate::system::gpu::shader::glsl::Glsl;
@ -449,14 +450,13 @@ impl Plane {
impl Rect {
/// Sets the radius of all the corners.
pub fn corners_radius<T>(&self, radius: T) -> RoundedRectByCorner
pub fn corners_radius<T>(&self, radius: T) -> Grow<Rect>
where T: Into<Var<Pixels>> {
let radius = radius.into();
let top_left = radius.clone();
let top_right = radius.clone();
let bottom_left = radius.clone();
let bottom_right = radius;
RoundedRectByCorner(self.size(), top_left, top_right, bottom_left, bottom_right)
let size = self.size();
let min_size = Min::min(size.x(), size.y());
let radius = Min::min(min_size * 0.5, radius.into());
let offset = Var::<Vector2<Pixels>>::from(format!("vec2({} * 2.0)", radius.glsl()));
Grow(Rect(size - offset), radius)
}
/// Sets the radiuses of each of the corners.

View File

@ -510,9 +510,7 @@ impl Var<f32> {
Var::Dynamic(t) => Var::Dynamic(format!("smoothstep({e1},{e2},{t})").into()),
}
}
}
impl Var<f32> {
/// Linearly interpolate between two values.
pub fn mix(&self, e1: impl RefInto<Glsl>, e2: impl RefInto<Glsl>) -> Self {
let e1 = e1.glsl();
@ -522,6 +520,22 @@ impl Var<f32> {
Var::Dynamic(t) => Var::Dynamic(format!("mix({e1},{e2},{t})").into()),
}
}
/// Returns 1.0 if value is strictly greater than 0.0. Returns 0.0 otherwise.
pub fn positive(&self) -> Self {
match self {
Var::Static(t) => Var::Static((*t > 0.0) as u32 as f32),
Var::Dynamic(t) => Var::Dynamic(format!("float({t} > 0.0)").into()),
}
}
/// Returns 1.0 if value is strictly less than 0.0. Returns 0.0 otherwise.
pub fn negative(&self) -> Self {
match self {
Var::Static(t) => Var::Static((*t < 0.0) as u32 as f32),
Var::Dynamic(t) => Var::Dynamic(format!("float({t} < 0.0)").into()),
}
}
}
@ -629,14 +643,14 @@ where T: Clamp<Output = T> + Into<Glsl>
// === Signum ===
// ==============
impl<T> Signum for Var<T>
where T: Signum<Output = T>
impl<T> Signum for &Var<T>
where T: Copy + Signum<Output = T>
{
type Output = Var<T>;
fn signum(self) -> Self {
fn signum(self) -> Var<T> {
match self {
Self::Static(t) => Var::Static(t.signum()),
Self::Dynamic(t) => Var::Dynamic(format!("sign({t})").into()),
Var::Static(t) => Var::Static(t.signum()),
Var::Dynamic(t) => Var::Dynamic(format!("sign({t})").into()),
}
}
}

View File

@ -11,7 +11,7 @@
// pixels when the sprite coordinates are not integers or if the scene is zoomed. See the docs
// attached to the shape system's geometry material to learn more.
vec2 position = input_local.xy ;
vec2 position = input_local.xy;
Shape view_box = debug_shape(rect(position, input_size));
Shape shape = run(position);
shape = intersection_no_blend(shape, view_box);

View File

@ -180,6 +180,9 @@ Sdf grow (Sdf a, float size) {
return Sdf(a.distance - size);
}
Sdf stroke (Sdf a, float thickness2) {
return Sdf(abs(a.distance) - thickness2);
}
// ================
@ -240,6 +243,12 @@ BoundSdf grow (BoundSdf a, float size) {
return a;
}
BoundSdf stroke (BoundSdf a, float thickness2) {
a.distance = abs(a.distance) - thickness2;
a.bounds = grow(a.bounds, thickness2);
return a;
}
BoundSdf inverse (BoundSdf a) {
return bound_sdf(inverse(sdf(a)), inverse(a.bounds));
}
@ -370,6 +379,10 @@ Shape grow (Shape s, float value) {
return shape(id, sdf, s.color);
}
Shape stroke (Shape s1, float thickness2) {
return shape(s1.id, stroke(s1.sdf, thickness2), s1.color);
}
Shape inverse (Shape s1) {
return shape(s1.id, inverse(s1.sdf), s1.color);
}

View File

@ -333,6 +333,15 @@ impl Canvas {
this.new_shape_from_expr(&expr)
})
}
/// Create a stroke of given thickness around shape's boundary.
pub fn stroke<T: Into<Var<f32>>>(&mut self, num: usize, s: Shape, thickness: T) -> Shape {
self.if_not_defined(num, |this| {
let thickness: Glsl = (thickness.into() * 0.5).glsl();
let expr = format!("return stroke({},({thickness}));", s.getter());
this.new_shape_from_expr(&expr)
})
}
}

View File

@ -10,6 +10,7 @@ use ensogl_core::display::shape::compound::rectangle::*;
use ensogl_core::display::world::*;
use ensogl_core::prelude::*;
use ensogl_core::animation::TimeInfo;
use ensogl_core::data::color;
use ensogl_core::display;
use ensogl_core::display::navigation::navigator::Navigator;
@ -30,51 +31,71 @@ pub fn main() {
let camera = scene.camera().clone_ref();
let navigator = Navigator::new(scene, &camera);
let border_demo_1 = RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(100.0, 100.0))
.set_color(color::Rgba::new(0.8, 0.2, 0.2, 0.5))
.set_border_color(color::Rgba::new(0.0, 0.5, 0.5, 1.0));
});
let border_demo_2 = RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(100.0, 100.0))
.set_color(color::Rgba::new(0.2, 0.8, 0.2, 0.5))
.set_border_color(color::Rgba::new(0.2, 0.2, 0.5, 1.0));
});
let border_demo_3 = RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(100.0, 100.0))
.set_color(color::Rgba::new(0.2, 0.2, 0.8, 0.5))
.set_border_color(color::Rgba::new(0.2, 0.5, 0.2, 1.0));
});
let shapes = [
Circle().build(|t| {
t.set_size(Vector2::new(100.0, 100.0))
.set_color(color::Rgba::new(0.5, 0.0, 0.0, 0.3))
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(color::Rgba::new(0.0, 0.0, 1.0, 1.0))
.keep_bottom_left_quarter();
}),
RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(100.0, 100.0))
.set_color(color::Rgba::new(0.5, 0.0, 0.0, 0.3))
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(color::Rgba::new(0.0, 0.0, 1.0, 1.0));
}),
RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(100.0, 50.0))
.set_color(color::Rgba::new(0.5, 0.0, 0.0, 0.3))
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(color::Rgba::new(0.0, 0.0, 1.0, 1.0))
.keep_top_half();
}),
RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(100.0, 50.0))
.set_color(color::Rgba::new(0.5, 0.0, 0.0, 0.3))
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(color::Rgba::new(0.0, 0.0, 1.0, 1.0))
.keep_bottom_half();
}),
RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(50.0, 100.0))
.set_color(color::Rgba::new(0.5, 0.0, 0.0, 0.3))
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(color::Rgba::new(0.0, 0.0, 1.0, 1.0))
.keep_right_half();
}),
RoundedRectangle(10.0).build(|t| {
t.set_size(Vector2::new(50.0, 100.0))
.set_color(color::Rgba::new(0.5, 0.0, 0.0, 0.3))
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(color::Rgba::new(0.0, 0.0, 1.0, 1.0))
.keep_left_half();
}),
SimpleTriangle::from_base_and_altitude(100.0, 25.0).into(),
SimpleTriangle::from_base_and_altitude(100.0, 50.0).into(),
SimpleTriangle::from_base_and_altitude(100.0, 100.0).into(),
border_demo_1.clone(),
border_demo_2.clone(),
border_demo_3.clone(),
];
let root = display::object::Instance::new();
@ -85,6 +106,19 @@ pub fn main() {
}
world.add_child(&root);
world
.on
.before_frame
.add(move |time: TimeInfo| {
let t = time.frame_start().as_s();
let inset = 10.0 + (t * 1.3).sin() * 10.0;
let width = 10.0 + (t * 2.77).cos() * 10.0;
border_demo_1.set_inset(inset).set_border(width);
border_demo_2.set_inset(inset).set_frame_border(width);
border_demo_3.set_inner_border(width, inset);
})
.forget();
world.keep_alive_forever();
mem::forget(navigator);
mem::forget(root);

View File

@ -53,7 +53,7 @@ pub fn main() {
let new = Circle().build(|t| {
t.set_size(Vector2::new(64.0, 64.0))
.set_color(*color)
.set_inset_border(5.0)
.set_border_and_inset(5.0)
.set_border_color(*border);
});
let x = rng.gen_range(-512.0..512.0);