diff --git a/crates/grida-canvas/benches/bench_rectangles.rs b/crates/grida-canvas/benches/bench_rectangles.rs index 9979e58d07..95ab3a645d 100644 --- a/crates/grida-canvas/benches/bench_rectangles.rs +++ b/crates/grida-canvas/benches/bench_rectangles.rs @@ -23,6 +23,7 @@ fn create_rectangles(count: usize, with_effects: bool) -> Scene { height: 100.0, }, corner_radius: RectangularCornerRadius::zero(), + corner_smoothing: CornerSmoothing::default(), fills: Paints::new([Paint::from(CGColor(255, 0, 0, 255))]), strokes: Paints::default(), stroke_width: 1.0, diff --git a/crates/grida-canvas/examples/golden_corner_smoothing.rs b/crates/grida-canvas/examples/golden_corner_smoothing.rs new file mode 100644 index 0000000000..08e599e1bd --- /dev/null +++ b/crates/grida-canvas/examples/golden_corner_smoothing.rs @@ -0,0 +1,110 @@ +/*! Corner Smoothing Visual Comparison + * + * Simple overlay test: circular corners (red) vs smoothed corners (blue) + */ + +use cg::cg::types::*; +use cg::node::factory::NodeFactory; +use cg::node::scene_graph::{Parent, SceneGraph}; +use cg::node::schema::*; +use cg::runtime::camera::Camera2D; +use cg::runtime::scene::{Backend, Renderer}; +use math2::{rect::Rectangle, transform::AffineTransform}; + +async fn create_scene() -> Scene { + let nf = NodeFactory::new(); + let mut graph = SceneGraph::new(); + + let box_size = 600.0; + let corner_radius = 150.0; + let x = 100.0; + let y = 100.0; + + println!("Creating overlay comparison:"); + println!(" Box size: {}×{}", box_size, box_size); + println!(" Corner radius: {}", corner_radius); + println!(" Smoothing: 1.0 (maximum)"); + println!(); + + // Background: Circular corners (s=0.0) - RED stroke + let mut rect_circular = nf.create_rectangle_node(); + rect_circular.transform = AffineTransform::new(x, y, 0.0); + rect_circular.size = Size { + width: box_size, + height: box_size, + }; + rect_circular.corner_radius = RectangularCornerRadius::circular(corner_radius); + rect_circular.corner_smoothing = CornerSmoothing::new(0.0); // Circular + rect_circular.fills = Paints::default(); // No fill + rect_circular.strokes = Paints::new([Paint::from(CGColor::from_rgb(255, 50, 50))]); + rect_circular.stroke_width = 3.0; + rect_circular.stroke_align = StrokeAlign::Center; + + graph.append_child(Node::Rectangle(rect_circular), Parent::Root); + + // Foreground: Maximum smoothing (s=1.0) - BLUE stroke + let mut rect_smoothed = nf.create_rectangle_node(); + rect_smoothed.transform = AffineTransform::new(x, y, 0.0); + rect_smoothed.size = Size { + width: box_size, + height: box_size, + }; + rect_smoothed.corner_radius = RectangularCornerRadius::circular(corner_radius); + rect_smoothed.corner_smoothing = CornerSmoothing::new(1.0); // Maximum smoothing + rect_smoothed.fills = Paints::default(); // No fill + rect_smoothed.strokes = Paints::new([Paint::from(CGColor::from_rgb(50, 150, 255))]); + rect_smoothed.stroke_width = 3.0; + rect_smoothed.stroke_align = StrokeAlign::Center; + + graph.append_child(Node::Rectangle(rect_smoothed), Parent::Root); + + Scene { + name: "corner smoothing comparison".into(), + graph, + background_color: Some(CGColor::BLACK), + } +} + +#[tokio::main] +async fn main() { + println!("=== Corner Smoothing Visual Test ===\n"); + + // Render scene + let scene = create_scene().await; + + let width = 800.0; + let height = 800.0; + + let mut renderer = Renderer::new( + Backend::new_from_raster(width as i32, height as i32), + None, + Camera2D::new_from_bounds(Rectangle::from_xywh(0.0, 0.0, width, height)), + ); + renderer.load_scene(scene); + + let surface = unsafe { &mut *renderer.backend.get_surface() }; + let canvas = surface.canvas(); + renderer.render_to_canvas(canvas, width, height); + + let image = surface.image_snapshot(); + let data = image + .encode(None, skia_safe::EncodedImageFormat::PNG, None) + .unwrap(); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/goldens/corner_smoothing.png"), + data.as_bytes(), + ) + .unwrap(); + + renderer.free(); + + println!("✅ Test completed"); + println!(" Output: goldens/corner_smoothing.png"); + println!("\n📖 Visual guide:"); + println!(" RED = Circular corners (s=0.0, n=2.0, standard)"); + println!(" BLUE = Smoothed corners (s=1.0, n=10.0, superellipse)"); + println!("\n If working correctly:"); + println!(" - Blue curve should be 'tighter' at corners"); + println!(" - Red curve should extend further out before turning"); + println!(" - Difference should be clearly visible"); +} diff --git a/crates/grida-canvas/examples/golden_layout_flex.rs b/crates/grida-canvas/examples/golden_layout_flex.rs index 71c3cd0cf7..9e80b987f7 100644 --- a/crates/grida-canvas/examples/golden_layout_flex.rs +++ b/crates/grida-canvas/examples/golden_layout_flex.rs @@ -28,6 +28,7 @@ fn create_container_with_gap(id: &str, width: f32, height: f32, gap: f32) -> Con rotation: 0.0, position: Default::default(), corner_radius: Default::default(), + corner_smoothing: Default::default(), fills: Default::default(), strokes: Default::default(), stroke_width: 0.0, diff --git a/crates/grida-canvas/examples/golden_layout_flex_alignment.rs b/crates/grida-canvas/examples/golden_layout_flex_alignment.rs index 282b259067..4d71866f67 100644 --- a/crates/grida-canvas/examples/golden_layout_flex_alignment.rs +++ b/crates/grida-canvas/examples/golden_layout_flex_alignment.rs @@ -24,6 +24,7 @@ fn create_child_container(id: &str, width: f32, height: f32) -> ContainerNodeRec rotation: 0.0, position: Default::default(), corner_radius: Default::default(), + corner_smoothing: Default::default(), fills: Default::default(), strokes: Default::default(), stroke_width: 0.0, diff --git a/crates/grida-canvas/examples/golden_layout_flex_padding.rs b/crates/grida-canvas/examples/golden_layout_flex_padding.rs index 95f0b045fb..ea3989f228 100644 --- a/crates/grida-canvas/examples/golden_layout_flex_padding.rs +++ b/crates/grida-canvas/examples/golden_layout_flex_padding.rs @@ -123,6 +123,7 @@ fn create_child_container(id: &str, width: f32, height: f32) -> ContainerNodeRec rotation: 0.0, position: Default::default(), corner_radius: Default::default(), + corner_smoothing: Default::default(), fills: Default::default(), strokes: Default::default(), stroke_width: 0.0, diff --git a/crates/grida-canvas/examples/golden_layout_padding.rs b/crates/grida-canvas/examples/golden_layout_padding.rs index 481276ee83..1c41e39d79 100644 --- a/crates/grida-canvas/examples/golden_layout_padding.rs +++ b/crates/grida-canvas/examples/golden_layout_padding.rs @@ -30,6 +30,7 @@ fn create_container_with_padding( rotation: 0.0, position: Default::default(), corner_radius: Default::default(), + corner_smoothing: Default::default(), fills: Default::default(), strokes: Default::default(), stroke_width: 0.0, diff --git a/crates/grida-canvas/goldens/corner_smoothing.png b/crates/grida-canvas/goldens/corner_smoothing.png new file mode 100644 index 0000000000..bdce220f56 Binary files /dev/null and b/crates/grida-canvas/goldens/corner_smoothing.png differ diff --git a/crates/grida-canvas/src/cg/types.rs b/crates/grida-canvas/src/cg/types.rs index b3805bab9f..548a25f4e4 100644 --- a/crates/grida-canvas/src/cg/types.rs +++ b/crates/grida-canvas/src/cg/types.rs @@ -960,6 +960,51 @@ impl Default for RectangularCornerRadius { } } +/// A normalized curvature-continuous (G²) corner smoothing factor. +/// +/// `CornerSmoothing` controls how sharply or smoothly corners are blended +/// when joining edges, transitioning from circular fillets (G¹) to +/// curvature-continuous blends (G²). +/// +/// # Range +/// - `0.0` — standard rounded corners (circular arcs) +/// - `1.0` — fully smoothed, continuous-curvature corners (Apple-/Figma-style) +/// +/// The mathematical foundation is described in +/// https://grida.co/docs/math/g2-curve-blending +/// +/// # Examples +/// ```rust +/// use cg::cg::types::CornerSmoothing; +/// let smooth = CornerSmoothing::new(0.6); +/// assert!(smooth.value() > 0.0 && smooth.value() <= 1.0); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct CornerSmoothing(pub f32); + +impl CornerSmoothing { + /// Creates a new `CornerSmoothing` value, clamped to `[0.0, 1.0]`. + pub fn new(value: f32) -> Self { + Self(value.clamp(0.0, 1.0)) + } + + /// Returns the raw normalized value. + #[inline] + pub fn value(self) -> f32 { + self.0 + } + + #[inline] + pub fn is_zero(&self) -> bool { + self.0 == 0.0 + } +} + +impl Default for CornerSmoothing { + fn default() -> Self { + Self(0.0) + } +} // #region text /// Text Transform (Text Case) diff --git a/crates/grida-canvas/src/io/io_figma.rs b/crates/grida-canvas/src/io/io_figma.rs index df46f84cbb..42e134123a 100644 --- a/crates/grida-canvas/src/io/io_figma.rs +++ b/crates/grida-canvas/src/io/io_figma.rs @@ -763,6 +763,7 @@ impl FigmaConverter { component.corner_radius, component.rectangle_corner_radii.as_ref(), ), + corner_smoothing: CornerSmoothing::new(component.corner_smoothing.unwrap_or(0.0) as f32), fills: self.convert_fills(Some(&component.fills.as_ref())), strokes: self.convert_strokes(Some(&component.strokes)), stroke_width: component.stroke_weight.unwrap_or(0.0) as f32, @@ -880,6 +881,7 @@ impl FigmaConverter { instance.corner_radius, instance.rectangle_corner_radii.as_ref(), ), + corner_smoothing: CornerSmoothing::new(instance.corner_smoothing.unwrap_or(0.0) as f32), fills: self.convert_fills(Some(&instance.fills.as_ref())), strokes: self.convert_strokes(Some(&instance.strokes)), stroke_width: instance.stroke_weight.unwrap_or(0.0) as f32, @@ -951,6 +953,7 @@ impl FigmaConverter { mask: None, rotation: transform.rotation(), corner_radius: RectangularCornerRadius::zero(), + corner_smoothing: Default::default(), fills: self.convert_fills(Some(§ion.fills.as_ref())), strokes: Paints::default(), stroke_width: 0.0, @@ -1098,6 +1101,7 @@ impl FigmaConverter { origin.corner_radius, origin.rectangle_corner_radii.as_ref(), ), + corner_smoothing: CornerSmoothing::new(origin.corner_smoothing.unwrap_or(0.0) as f32), fills: self.convert_fills(Some(&origin.fills.as_ref())), strokes: self.convert_strokes(Some(&origin.strokes)), stroke_width: origin.stroke_weight.unwrap_or(0.0) as f32, @@ -1338,6 +1342,7 @@ impl FigmaConverter { mask: None, rotation: transform.rotation(), corner_radius: RectangularCornerRadius::zero(), + corner_smoothing: Default::default(), fills: Paints::new([TRANSPARENT]), strokes: Paints::default(), stroke_width: 0.0, @@ -1625,6 +1630,7 @@ impl FigmaConverter { origin.corner_radius, origin.rectangle_corner_radii.as_ref(), ), + corner_smoothing: CornerSmoothing::new(origin.corner_smoothing.unwrap_or(0.0) as f32), fills: self.convert_fills(Some(&origin.fills)), strokes: self.convert_strokes(Some(&origin.strokes)), stroke_width: origin.stroke_weight.unwrap_or(1.0) as f32, diff --git a/crates/grida-canvas/src/io/io_grida.rs b/crates/grida-canvas/src/io/io_grida.rs index 231013e861..b7f372704b 100644 --- a/crates/grida-canvas/src/io/io_grida.rs +++ b/crates/grida-canvas/src/io/io_grida.rs @@ -760,6 +760,8 @@ pub struct JSONUnknownNodeProperties { deserialize_with = "de_radius_option" )] pub corner_radius_bottom_left: Option, + #[serde(rename = "cornerSmoothing", default)] + pub corner_smoothing: Option, // fill #[serde(rename = "fill")] @@ -1233,6 +1235,7 @@ impl From for ContainerNodeRec { node.base.corner_radius_bottom_right, node.base.corner_radius_bottom_left, ), + corner_smoothing: CornerSmoothing::new(node.base.corner_smoothing.unwrap_or(0.0)), fills: merge_paints(node.base.fill, node.base.fills), strokes: merge_paints(node.base.stroke, node.base.strokes), stroke_width: node.base.stroke_width, @@ -1456,6 +1459,7 @@ impl From for Node { node.base.corner_radius_bottom_right, node.base.corner_radius_bottom_left, ), + corner_smoothing: CornerSmoothing::new(node.base.corner_smoothing.unwrap_or(0.0)), fills: merge_paints(node.base.fill, node.base.fills), strokes: merge_paints(node.base.stroke, node.base.strokes), stroke_width: node.base.stroke_width, @@ -1553,6 +1557,7 @@ impl From for Node { node.base.corner_radius_bottom_right, node.base.corner_radius_bottom_left, ), + corner_smoothing: CornerSmoothing::new(node.base.corner_smoothing.unwrap_or(0.0)), fill: fill.clone(), strokes: merge_paints(node.base.stroke, node.base.strokes), stroke_width: node.base.stroke_width, @@ -3924,6 +3929,99 @@ mod tests { } } + #[test] + fn deserialize_rectangle_with_corner_smoothing() { + let json = r#"{ + "id": "rect-smooth", + "name": "Smooth Rectangle", + "type": "rectangle", + "left": 100.0, + "top": 100.0, + "width": 200.0, + "height": 200.0, + "cornerRadius": 50.0, + "cornerSmoothing": 0.6 + }"#; + + let node: JSONNode = serde_json::from_str(json) + .expect("failed to deserialize rectangle with corner smoothing"); + + match node { + JSONNode::Rectangle(rect) => { + assert_eq!(rect.base.corner_smoothing, Some(0.6)); + + let converted: Node = rect.into(); + if let Node::Rectangle(rect_rec) = converted { + assert_eq!(rect_rec.corner_smoothing.value(), 0.6); + } else { + panic!("Expected Rectangle node"); + } + } + _ => panic!("Expected Rectangle node"), + } + } + + #[test] + fn deserialize_container_with_corner_smoothing() { + let json = r#"{ + "id": "container-smooth", + "name": "Smooth Container", + "type": "container", + "left": 0.0, + "top": 0.0, + "width": 300.0, + "height": 300.0, + "cornerRadius": 40.0, + "cornerSmoothing": 1.0 + }"#; + + let node: JSONNode = serde_json::from_str(json) + .expect("failed to deserialize container with corner smoothing"); + + match node { + JSONNode::Container(container) => { + assert_eq!(container.base.corner_smoothing, Some(1.0)); + + let converted: ContainerNodeRec = container.into(); + assert_eq!(converted.corner_smoothing.value(), 1.0); + } + _ => panic!("Expected Container node"), + } + } + + #[test] + fn deserialize_image_with_corner_smoothing() { + let json = r#"{ + "id": "image-smooth", + "name": "Smooth Image", + "type": "image", + "src": "test.png", + "left": 0.0, + "top": 0.0, + "width": 250.0, + "height": 250.0, + "cornerRadius": 30.0, + "cornerSmoothing": 0.8 + }"#; + + let node: JSONNode = + serde_json::from_str(json).expect("failed to deserialize image with corner smoothing"); + + match node { + JSONNode::Image(image) => { + assert_eq!(image.base.corner_smoothing, Some(0.8)); + + let converted: Node = image.into(); + if let Node::Image(image_rec) = converted { + assert_eq!(image_rec.corner_smoothing.value(), 0.8); + } else { + panic!("Expected Image node"); + } + } + _ => panic!("Expected Image node"), + } + } + #[test] fn deserialize_container_with_all_layout_properties() { // Test a container with all layout properties including gap and wrap diff --git a/crates/grida-canvas/src/node/factory.rs b/crates/grida-canvas/src/node/factory.rs index 5a30e0c5f9..e2df8b293a 100644 --- a/crates/grida-canvas/src/node/factory.rs +++ b/crates/grida-canvas/src/node/factory.rs @@ -59,6 +59,7 @@ impl NodeFactory { transform: AffineTransform::identity(), size: Self::DEFAULT_SIZE, corner_radius: RectangularCornerRadius::zero(), + corner_smoothing: Default::default(), fills: Paints::new([Self::default_solid_paint(Self::DEFAULT_COLOR)]), strokes: Paints::default(), stroke_width: Self::DEFAULT_STROKE_WIDTH, @@ -159,6 +160,7 @@ impl NodeFactory { rotation: 0.0, position: Default::default(), corner_radius: Default::default(), + corner_smoothing: Default::default(), fills: Paints::new([Self::default_solid_paint(Self::DEFAULT_COLOR)]), strokes: Default::default(), stroke_width: Self::DEFAULT_STROKE_WIDTH, @@ -296,6 +298,7 @@ impl NodeFactory { transform: AffineTransform::identity(), size: Self::DEFAULT_SIZE, corner_radius: RectangularCornerRadius::zero(), + corner_smoothing: Default::default(), fill: Self::default_image_paint(), strokes: Paints::new([Self::default_solid_paint(Self::DEFAULT_STROKE_COLOR)]), stroke_width: Self::DEFAULT_STROKE_WIDTH, diff --git a/crates/grida-canvas/src/node/schema.rs b/crates/grida-canvas/src/node/schema.rs index 43aad1b09a..8c19265f80 100644 --- a/crates/grida-canvas/src/node/schema.rs +++ b/crates/grida-canvas/src/node/schema.rs @@ -6,7 +6,6 @@ use crate::shape::*; use crate::vectornetwork::*; use math2::rect::Rectangle; use math2::transform::AffineTransform; - // Re-export the ID types from the id module pub use crate::node::id::{NodeId, NodeIdGenerator, UserNodeId}; @@ -850,6 +849,7 @@ pub struct ContainerNodeRec { pub layout_child: Option, pub corner_radius: RectangularCornerRadius, + pub corner_smoothing: CornerSmoothing, pub fills: Paints, pub strokes: Paints, pub stroke_width: f32, @@ -948,6 +948,7 @@ pub struct RectangleNodeRec { pub transform: AffineTransform, pub size: Size, pub corner_radius: RectangularCornerRadius, + pub corner_smoothing: CornerSmoothing, pub fills: Paints, pub strokes: Paints, pub stroke_width: f32, @@ -959,16 +960,6 @@ pub struct RectangleNodeRec { pub layout_child: Option, } -impl RectangleNodeRec { - pub fn to_own_shape(&self) -> RRectShape { - RRectShape { - width: self.size.width, - height: self.size.height, - corner_radius: self.corner_radius, - } - } -} - impl NodeFillsMixin for RectangleNodeRec { fn set_fill(&mut self, fill: Paint) { self.fills = Paints::new([fill]); @@ -1016,15 +1007,33 @@ impl NodeGeometryMixin for RectangleNodeRec { impl NodeShapeMixin for RectangleNodeRec { fn to_shape(&self) -> Shape { - Shape::RRect(self.to_own_shape()) + if self.corner_radius.is_zero() { + return Shape::Rect(RectShape { + width: self.size.width, + height: self.size.height, + }); + } + if self.corner_smoothing.is_zero() { + return Shape::RRect(RRectShape { + width: self.size.width, + height: self.size.height, + corner_radius: self.corner_radius, + }); + } + return Shape::OrthogonalSmoothRRect(OrthogonalSmoothRRectShape { + width: self.size.width, + height: self.size.height, + corner_radius: self.corner_radius, + corner_smoothing: self.corner_smoothing, + }); } fn to_path(&self) -> skia_safe::Path { - build_rrect_path(&self.to_own_shape()) + (&self.to_shape()).into() } fn to_vector_network(&self) -> VectorNetwork { - build_rrect_vector_network(&self.to_own_shape()) + (&self.to_shape()).into() } } @@ -1076,6 +1085,7 @@ pub struct ImageNodeRec { pub transform: AffineTransform, pub size: Size, pub corner_radius: RectangularCornerRadius, + pub corner_smoothing: CornerSmoothing, /// Single image fill - intentionally not supporting multiple fills to align with /// web development patterns where `` elements have one image source. pub fill: ImagePaint, @@ -1239,7 +1249,7 @@ impl NodeShapeMixin for EllipseNodeRec { } fn to_vector_network(&self) -> VectorNetwork { - self.to_shape().to_vector_network() + (&self.to_shape()).into() } } diff --git a/crates/grida-canvas/src/painter/geometry.rs b/crates/grida-canvas/src/painter/geometry.rs index 8bbbb40f88..ab7e3a4478 100644 --- a/crates/grida-canvas/src/painter/geometry.rs +++ b/crates/grida-canvas/src/painter/geometry.rs @@ -85,6 +85,10 @@ impl PainterShape { Shape::Ellipse(shape) => { PainterShape::from_oval(Rect::from_xywh(0.0, 0.0, shape.width, shape.height)) } + Shape::Rect(shape) => { + PainterShape::from_rect(Rect::from_xywh(0.0, 0.0, shape.width, shape.height)) + } + Shape::RRect(shape) => PainterShape::from_rrect(shape.into()), _ => PainterShape::from_path(shape.into()), } } @@ -183,14 +187,8 @@ pub fn build_shape(node: &Node, bounds: &Rectangle) -> PainterShape { PainterShape::from_shape(&shape) } Node::Rectangle(n) => { - let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); - let r = n.corner_radius; - if !r.is_zero() { - let rrect = build_rrect(&n.to_own_shape()); - PainterShape::from_rrect(rrect) - } else { - PainterShape::from_rect(rect) - } + let shape = n.to_shape(); + PainterShape::from_shape(&shape) } Node::Container(n) => { // ALWAYS use resolved bounds from GeometryCache @@ -199,14 +197,23 @@ pub fn build_shape(node: &Node, bounds: &Rectangle) -> PainterShape { let r = n.corner_radius; if !r.is_zero() { - // Build RRect with resolved dimensions - let shape = RRectShape { - width, - height, - corner_radius: n.corner_radius, - }; - let rrect = build_rrect(&shape); - PainterShape::from_rrect(rrect) + // Check if corner smoothing is enabled + if n.corner_smoothing.value() > 0.0 { + let smooth = OrthogonalSmoothRRectShape { + width, + height, + corner_radius: n.corner_radius, + corner_smoothing: n.corner_smoothing, + }; + PainterShape::from_path(build_orthogonal_smooth_rrect_path(&smooth)) + } else { + let rrect = build_rrect(&RRectShape { + width, + height, + corner_radius: n.corner_radius, + }); + PainterShape::from_rrect(rrect) + } } else { let rect = Rect::from_xywh(0.0, 0.0, width, height); PainterShape::from_rect(rect) @@ -215,8 +222,19 @@ pub fn build_shape(node: &Node, bounds: &Rectangle) -> PainterShape { Node::Image(n) => { let r = n.corner_radius; if !r.is_zero() { - let rrect = build_rrect(&n.to_own_shape()); - PainterShape::from_rrect(rrect) + // Check if corner smoothing is enabled + if n.corner_smoothing.value() > 0.0 { + let smooth = OrthogonalSmoothRRectShape { + width: n.size.width, + height: n.size.height, + corner_radius: r, + corner_smoothing: n.corner_smoothing, + }; + PainterShape::from_path(build_orthogonal_smooth_rrect_path(&smooth)) + } else { + let rrect = build_rrect(&n.to_own_shape()); + PainterShape::from_rrect(rrect) + } } else { let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); PainterShape::from_rect(rect) diff --git a/crates/grida-canvas/src/shape/mod.rs b/crates/grida-canvas/src/shape/mod.rs index fcfcab8b9d..a2e235059a 100644 --- a/crates/grida-canvas/src/shape/mod.rs +++ b/crates/grida-canvas/src/shape/mod.rs @@ -8,6 +8,7 @@ pub mod rect; pub mod regular_polygon; pub mod regular_star; pub mod rrect; +pub mod srrect_orthogonal; pub mod stroke; pub mod stroke_varwidth; pub mod vector; @@ -22,6 +23,7 @@ pub use rect::*; pub use regular_polygon::*; pub use regular_star::*; pub use rrect::*; +pub use srrect_orthogonal::*; pub use stroke::*; pub use stroke_varwidth::*; pub use vector::*; @@ -29,7 +31,9 @@ pub use vector::*; use crate::vectornetwork::*; pub enum Shape { + Rect(RectShape), RRect(RRectShape), + OrthogonalSmoothRRect(OrthogonalSmoothRRectShape), SimplePolygon(SimplePolygonShape), Ellipse(EllipseShape), EllipticalRingSector(EllipticalRingSectorShape), @@ -42,7 +46,9 @@ pub enum Shape { impl Into for &Shape { fn into(self) -> skia_safe::Path { match self { + Shape::Rect(shape) => shape.into(), Shape::RRect(shape) => build_rrect_path(&shape), + Shape::OrthogonalSmoothRRect(shape) => build_orthogonal_smooth_rrect_path(&shape), Shape::SimplePolygon(shape) => build_simple_polygon_path(&shape), Shape::Ellipse(shape) => build_ellipse_path(&shape), Shape::EllipticalRingSector(shape) => build_ring_sector_path(&shape), @@ -54,11 +60,14 @@ impl Into for &Shape { } } -impl Shape { - /// Convert this shape into a [`VectorNetwork`]. - pub fn to_vector_network(&self) -> VectorNetwork { +impl Into for &Shape { + fn into(self) -> VectorNetwork { match self { + Shape::Rect(shape) => build_rect_vector_network(shape), Shape::RRect(shape) => build_rrect_vector_network(shape), + Shape::OrthogonalSmoothRRect(shape) => { + build_orthogonal_smooth_rrect_vector_network(shape) + } Shape::SimplePolygon(shape) => build_simple_polygon_vector_network(shape), Shape::Ellipse(shape) => build_ellipse_vector_network(shape), Shape::EllipticalRingSector(_shape) => { diff --git a/crates/grida-canvas/src/shape/rect.rs b/crates/grida-canvas/src/shape/rect.rs index 452b2813a6..4742f5c345 100644 --- a/crates/grida-canvas/src/shape/rect.rs +++ b/crates/grida-canvas/src/shape/rect.rs @@ -1,3 +1,5 @@ +use super::vn::*; + pub struct RectShape { /// width of the box pub width: f32, @@ -19,3 +21,30 @@ impl Into for &RectShape { path } } + +pub fn build_rect_vector_network(shape: &RectShape) -> VectorNetwork { + let w = shape.width; + let h = shape.height; + + // 4 vertices (corners) + let vertices = vec![ + (0.0, 0.0), // 0: top-left + (w, 0.0), // 1: top-right + (w, h), // 2: bottom-right + (0.0, h), // 3: bottom-left + ]; + + // 4 line segments forming a closed rectangle + let segments = vec![ + VectorNetworkSegment::ab(0, 1), // top edge + VectorNetworkSegment::ab(1, 2), // right edge + VectorNetworkSegment::ab(2, 3), // bottom edge + VectorNetworkSegment::ab(3, 0), // left edge (close) + ]; + + VectorNetwork { + vertices, + segments, + regions: vec![], + } +} diff --git a/crates/grida-canvas/src/shape/rrect.rs b/crates/grida-canvas/src/shape/rrect.rs index 7ce8938b99..0c62b9414f 100644 --- a/crates/grida-canvas/src/shape/rrect.rs +++ b/crates/grida-canvas/src/shape/rrect.rs @@ -11,6 +11,12 @@ pub struct RRectShape { pub corner_radius: RectangularCornerRadius, } +impl Into for &RRectShape { + fn into(self) -> skia_safe::RRect { + build_rrect(self) + } +} + pub fn build_rrect(shape: &RRectShape) -> skia_safe::RRect { let irect = skia_safe::Rect::from_xywh(0.0, 0.0, shape.width, shape.height); diff --git a/crates/grida-canvas/src/shape/srrect_orthogonal.rs b/crates/grida-canvas/src/shape/srrect_orthogonal.rs new file mode 100644 index 0000000000..7f8cb6fff7 --- /dev/null +++ b/crates/grida-canvas/src/shape/srrect_orthogonal.rs @@ -0,0 +1,252 @@ +//! Orthogonal smooth rounded rectangle +//! +//! Implements extensible corner smoothing optimized for rectangles (90° corners). +//! Uses hybrid Bézier-Arc-Bézier construction where corners extend along edges +//! based on smoothing parameter: corner_extent = (1 + smoothing) * radius +//! +//! Limitations: +//! - Orthogonal corners only (90° angles) +//! - Circular corners (uses min(rx, ry)) +//! - Cannot generalize to arbitrary shapes + +use super::vn::VectorNetwork; +use crate::cg::types::*; + +/// Rectangular shape with smooth corners optimized for orthogonal (90°) angles. +/// +/// This implementation extends corners along edges as smoothing increases, +/// producing visually smooth transitions using a Bézier-Arc-Bézier hybrid. +pub struct OrthogonalSmoothRRectShape { + pub width: f32, + pub height: f32, + pub corner_radius: RectangularCornerRadius, + pub corner_smoothing: CornerSmoothing, +} + +struct CornerParams { + /// Radius of the circular arc portion + radius: f32, + /// Total length the corner occupies on each edge + p: f32, + /// Bézier control point distances + a: f32, + b: f32, + c: f32, + d: f32, + /// Angle for the circular arc + #[allow(dead_code)] + angle_circle: f32, + /// Angle for the Bézier transition + angle_bezier: f32, +} + +fn compute_corner_params(radius: f32, smoothness: f32, shortest_side: f32) -> CornerParams { + let smoothness = smoothness.clamp(0.0, 1.0); + let radius = radius.min(shortest_side / 2.0).max(0.0); + + // Key formula: corner extends beyond radius when smoothing applied + let p = f32::min(shortest_side / 2.0, (1.0 + smoothness) * radius); + + // Compute angles based on smoothness + let (angle_circle, angle_bezier) = if radius > shortest_side / 4.0 { + let change_percentage = (radius - shortest_side / 4.0) / (shortest_side / 4.0); + ( + 90.0 * (1.0 - smoothness * (1.0 - change_percentage)), + 45.0 * smoothness * (1.0 - change_percentage), + ) + } else { + (90.0 * (1.0 - smoothness), 45.0 * smoothness) + }; + + // Compute Bézier control point distances + let angle_bez_rad = angle_bezier.to_radians(); + let angle_circ_rad = angle_circle.to_radians(); + + let d_to_c = angle_bez_rad.tan(); + let longest = radius * (angle_bez_rad / 2.0).tan(); + let l = (angle_circ_rad / 2.0).sin() * radius * 2.0f32.sqrt(); + + let c = longest * angle_bez_rad.cos(); + let d = c * d_to_c; + let b = ((p - l) - (1.0 + d_to_c) * c) / 3.0; + let a = 2.0 * b; + + CornerParams { + radius, + p, + a, + b, + c, + d, + angle_circle, + angle_bezier, + } +} + +pub fn build_orthogonal_smooth_rrect_path(shape: &OrthogonalSmoothRRectShape) -> skia_safe::Path { + let mut path = skia_safe::Path::new(); + + let w = shape.width; + let h = shape.height; + let smoothness = shape.corner_smoothing.value(); + let shortest_side = f32::min(w, h); + + // Get effective radius for each corner (min of rx, ry) + let tl_r = f32::min(shape.corner_radius.tl.rx, shape.corner_radius.tl.ry).max(0.0); + let tr_r = f32::min(shape.corner_radius.tr.rx, shape.corner_radius.tr.ry).max(0.0); + let br_r = f32::min(shape.corner_radius.br.rx, shape.corner_radius.br.ry).max(0.0); + let bl_r = f32::min(shape.corner_radius.bl.rx, shape.corner_radius.bl.ry).max(0.0); + + let tl = compute_corner_params(tl_r, smoothness, shortest_side); + let tr = compute_corner_params(tr_r, smoothness, shortest_side); + let br = compute_corner_params(br_r, smoothness, shortest_side); + let bl = compute_corner_params(bl_r, smoothness, shortest_side); + + let center_x = w / 2.0; + + // Start at top center + path.move_to((center_x, 0.0)); + + // Top-right section + path.line_to((f32::max(w / 2.0, w - tr.p), 0.0)); + + if tr.radius > 0.0 { + // Bézier transition into arc + path.cubic_to( + (w - (tr.p - tr.a), 0.0), + (w - (tr.p - tr.a - tr.b), 0.0), + (w - (tr.p - tr.a - tr.b - tr.c), tr.d), + ); + + // Circular arc + let arc_rect = + skia_safe::Rect::from_xywh(w - tr.radius * 2.0, 0.0, tr.radius * 2.0, tr.radius * 2.0); + let start_angle = 270.0 + tr.angle_bezier; + let sweep_angle = 90.0 - 2.0 * tr.angle_bezier; + path.arc_to(arc_rect, start_angle, sweep_angle, false); + + // Bézier transition out of arc + path.cubic_to( + (w, tr.p - tr.a - tr.b), + (w, tr.p - tr.a), + (w, f32::min(h / 2.0, tr.p)), + ); + } + + // Right-bottom section + path.line_to((w, f32::max(h / 2.0, h - br.p))); + + if br.radius > 0.0 { + path.cubic_to( + (w, h - (br.p - br.a)), + (w, h - (br.p - br.a - br.b)), + (w - br.d, h - (br.p - br.a - br.b - br.c)), + ); + + let arc_rect = skia_safe::Rect::from_xywh( + w - br.radius * 2.0, + h - br.radius * 2.0, + br.radius * 2.0, + br.radius * 2.0, + ); + let start_angle = br.angle_bezier; + let sweep_angle = 90.0 - 2.0 * br.angle_bezier; + path.arc_to(arc_rect, start_angle, sweep_angle, false); + + path.cubic_to( + (w - (br.p - br.a - br.b), h), + (w - (br.p - br.a), h), + (f32::max(w / 2.0, w - br.p), h), + ); + } + + // Bottom-left section + path.line_to((f32::min(w / 2.0, bl.p), h)); + + if bl.radius > 0.0 { + path.cubic_to( + (bl.p - bl.a, h), + (bl.p - bl.a - bl.b, h), + (bl.p - bl.a - bl.b - bl.c, h - bl.d), + ); + + let arc_rect = + skia_safe::Rect::from_xywh(0.0, h - bl.radius * 2.0, bl.radius * 2.0, bl.radius * 2.0); + let start_angle = 90.0 + bl.angle_bezier; + let sweep_angle = 90.0 - 2.0 * bl.angle_bezier; + path.arc_to(arc_rect, start_angle, sweep_angle, false); + + path.cubic_to( + (0.0, h - (bl.p - bl.a - bl.b)), + (0.0, h - (bl.p - bl.a)), + (0.0, f32::max(h / 2.0, h - bl.p)), + ); + } + + // Left-top section + path.line_to((0.0, f32::min(h / 2.0, tl.p))); + + if tl.radius > 0.0 { + path.cubic_to( + (0.0, tl.p - tl.a), + (0.0, tl.p - tl.a - tl.b), + (tl.d, tl.p - tl.a - tl.b - tl.c), + ); + + let arc_rect = skia_safe::Rect::from_xywh(0.0, 0.0, tl.radius * 2.0, tl.radius * 2.0); + let start_angle = 180.0 + tl.angle_bezier; + let sweep_angle = 90.0 - 2.0 * tl.angle_bezier; + path.arc_to(arc_rect, start_angle, sweep_angle, false); + + path.cubic_to( + (tl.p - tl.a - tl.b, 0.0), + (tl.p - tl.a, 0.0), + (f32::min(w / 2.0, tl.p), 0.0), + ); + } + + path.close(); + path +} + +pub fn build_orthogonal_smooth_rrect_vector_network( + _shape: &OrthogonalSmoothRRectShape, +) -> VectorNetwork { + // Fallback: build path and convert to VN (keeps editor/export functional). + // Later: emit structured quarter-corner segments. + let path = build_orthogonal_smooth_rrect_path(_shape); + VectorNetwork::from(&path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_corner_params_no_smoothing() { + let params = compute_corner_params(50.0, 0.0, 200.0); + assert_eq!(params.p, 50.0); // p equals radius when smoothness=0 + assert_eq!(params.angle_circle, 90.0); + assert_eq!(params.angle_bezier, 0.0); + } + + #[test] + fn test_corner_params_max_smoothing() { + let params = compute_corner_params(50.0, 1.0, 200.0); + assert_eq!(params.p, 100.0); // p = (1+1)*50 = 100 when smoothness=1 + assert_eq!(params.angle_circle, 0.0); + assert_eq!(params.angle_bezier, 45.0); + } + + #[test] + fn test_orthogonal_smooth_rrect_path_is_closed() { + let shape = OrthogonalSmoothRRectShape { + width: 100.0, + height: 100.0, + corner_radius: RectangularCornerRadius::circular(20.0), + corner_smoothing: CornerSmoothing::new(0.6), + }; + let path = build_orthogonal_smooth_rrect_path(&shape); + assert!(!path.is_empty()); + } +} diff --git a/crates/math2/tests/bezier.rs b/crates/math2/tests/bezier.rs index a53703991e..c346545df8 100644 --- a/crates/math2/tests/bezier.rs +++ b/crates/math2/tests/bezier.rs @@ -1,16 +1,5 @@ use math2::bezier_a2c; -/// Converts the output of `bezier_a2c` to an SVG path string. -fn a2c_to_svg_path(x1: f32, y1: f32, data: &[f32]) -> String { - let mut path = format!("M {} {}", x1, y1); - for chunk in data.chunks(6) { - if let [c1x, c1y, c2x, c2y, x, y] = *chunk { - path.push_str(&format!(" C {} {}, {} {}, {} {}", c1x, c1y, c2x, c2y, x, y)); - } - } - path -} - /// Helper to prepend the start point to the cubic bezier points. fn get_bezier_points(x1: f32, y1: f32, data: &[f32]) -> Vec { let mut out = Vec::with_capacity(2 + data.len()); @@ -20,6 +9,17 @@ fn get_bezier_points(x1: f32, y1: f32, data: &[f32]) -> Vec { out } +// /// Converts the output of `bezier_a2c` to an SVG path string. +// fn a2c_to_svg_path(x1: f32, y1: f32, data: &[f32]) -> String { +// let mut path = format!("M {} {}", x1, y1); +// for chunk in data.chunks(6) { +// if let [c1x, c1y, c2x, c2y, x, y] = *chunk { +// path.push_str(&format!(" C {} {}, {} {}, {} {}", c1x, c1y, c2x, c2y, x, y)); +// } +// } +// path +// } + // enable this later. for some reason, the output path data has percision differences in different machines. // #[test] // fn simple_arc_svg_path() { diff --git a/docs/math/g2-curve-blending.md b/docs/math/g2-curve-blending.md new file mode 100644 index 0000000000..b579f40958 --- /dev/null +++ b/docs/math/g2-curve-blending.md @@ -0,0 +1,151 @@ +# G² Curve Blending + +_A.k.a._ **Curvature‑Continuous Corner Blending** (design systems often call this “continuous corner smoothing”). + +This document formalizes the problem of replacing a sharp join between two curve segments with a **curvature‑continuous (G²)** transition. It consolidates established techniques from CAGD (Computer Aided Geometric Design) and relates them to practical implementations used in modern editors. + +--- + +## 1. Problem statement + +Given two $C^1$ planar curve segments $\gamma_1:[0,1]\!\to\!\mathbb{R}^2$ and $\gamma_2:[0,1]\!\to\!\mathbb{R}^2$ that meet at a vertex $P$ (with incoming unit tangent $\mathbf{t}_1$ and outgoing unit tangent $\mathbf{t}_2$), construct a transition curve $C:[0,1]\!\to\!\mathbb{R}^2$ such that: + +- **Positional continuity:** $C(0)=P_1$, $C(1)=P_2$ for trimmed points $P_1\in\gamma_1$, $P_2\in\gamma_2$, +- **Tangent continuity (G¹):** $C'(0)\parallel \mathbf{t}_1$ and $C'(1)\parallel \mathbf{t}_2$, +- **Curvature continuity (G²):** $\kappa_C(0)=\kappa_1(P_1)$ and $\kappa_C(1)=\kappa_2(P_2)$, where $\kappa$ denotes signed curvature. + +A normalized **smoothing parameter** $s\in[0,1]$ controls the trim distances along $\gamma_1,\gamma_2$ (and/or a shape parameter), mapping UI intent to geometry. Typical ranges: $s\approx 0.3\!-\!0.7$. + +> **Terminology.** $C^k$ denotes equality of derivatives in a fixed parameterization. **$G^k$** denotes geometric continuity (tangent/curvature agreement irrespective of parameterization). For corner blending, $G^2$ is the target. + +--- + +## 2. Curvature formulas used in constraints + +For a parametric curve $\mathbf{r}(u)$: + +- $\mathbf{T}=\dfrac{\mathbf{r}'}{\|\mathbf{r}'\|}$,  $\kappa=\dfrac{\|\mathbf{r}'\times \mathbf{r}''\|}{\|\mathbf{r}'\|^3}$ (in 2D, treat $\times$ as scalar $x_1y_2-x_2y_1$). + +For a **cubic Bézier** $B(u)=\sum_{i=0}^3 \binom{3}{i}(1-u)^{3-i}u^i P_i$: + +- $B'(0)=3(P_1-P_0)$,  $B''(0)=6(P_0-2P_1+P_2)$ ⇒ + $\displaystyle \kappa_{B}(0)=\frac{|(P_1-P_0)\times (P_2-2P_1+P_0)|}{\|P_1-P_0\|^3}$. +- Analogously at $u=1$: replace $(P_0,P_1,P_2)$ with $(P_3,P_2,P_1)$. + +These formulas make the **G² constraints** algebraic in the control points. + +--- + +## 3. Canonical constructions + +### 3.1 Superelliptic fillet (orthogonal or near‑orthogonal edges) + +Use a quarter of the **superellipse**: + +$$ +\left|\frac{x}{a}\right|^n+\left|\frac{y}{b}\right|^n=1,\qquad n\ge 2. +$$ + +- Parameters $a,b>0$ are trim distances along the two edges; pick $n>2$ for vanishing curvature at the endpoints. +- **Splice property:** at $(a,0)$ and $(0,b)$ the tangent aligns with the axes, and for $n>2$ the curvature tends to $0$, matching the straight‑edge curvature. Thus the splice to straight edges is **$C^2$** (hence $G^2$). +- Special cases: $n=2$ (circular fillet); $n\approx 5$ reproduces Apple‑style “icon” corners. + +**Parametrization (first quadrant):** + +$$ +x(t)=a\,|\cos t|^{2/n},\quad y(t)=b\,|\sin t|^{2/n},\quad t\in[0,\tfrac{\pi}{2}]. +$$ + +**Notes.** This is analytic and extremely stable for rectangles/frames; for highly acute or obtuse angles, prefer §3.2/§3.3. + +--- + +### 3.2 Biarc blends (two circular arcs) + +A **biarc** joins two trimmed points by two circular arcs that share a point and tangent. + +- Always achieves **$G^1$**; with additional constraints one can equalize curvature at the internal join to approach **$G^2$**, but this is not guaranteed for all angles/lengths. +- Efficient and robust; widely used in CAD/CAM. + +Use biarcs when performance and robustness trump strict $G^2$ requirements. + +--- + +### 3.3 Cubic Bézier $G^2$ corner blend (general, Bézier‑native) + +Construct two cubics $B_1, B_2$ meeting at a join point $M$: + +- Set end points: $B_1(0)=P_1$, $B_1(1)=M$,  $B_2(0)=M$, $B_2(1)=P_2$. +- Align tangents with trimmed edge directions: $P_1^+=P_1+\alpha\,\mathbf{t}_1$,  $P_2^- = P_2-\beta\,\mathbf{t}_2$, with $\alpha,\beta>0$. +- Choose an internal tangent direction $\mathbf{t}_M$ (e.g., angle‑bisector) and set $M^- = M - \mu\,\mathbf{t}_M$, $M^+ = M + \nu\,\mathbf{t}_M$. +- Enforce **$G^1$** at $M$: $B_1'(1)\parallel \mathbf{t}_M \parallel B_2'(0)$. +- Enforce **equal curvature** at $M$ using the endpoint formulas in §2 to solve for $(\mu,\nu)$ (and optionally relate $\alpha,\beta$ to match endpoint curvature to the incident edges). + +This yields a strictly **$G^2$** Bézier‑only construction compatible with editors whose primitive is cubic Bézier. + +> **Practical recipe.** Given a smoothing amount $s$, set trim distances $d_1=s\,L_1$, $d_2=s\,L_2$ along the incident segments (lengths $L_i$). Take $\alpha=k_1 d_1$, $\beta=k_2 d_2$ with constants tuned for visual uniformity (e.g., $k_1=k_2\approx \tfrac{2}{3}$). Solve for $(\mu,\nu)$ so that $\kappa_{B_1}(1)=\kappa_{B_2}(0)$. + +--- + +### 3.4 Clothoid (Euler‑spiral) blends (analytic $G^2$ with linear curvature) + +A **clothoid** has curvature varying linearly with arc length: $\kappa(s)=\kappa_0+\lambda s$. + +- Connect two trimmed points by a pair (or a single) clothoid segment(s) meeting $G^2$ conditions. +- Position is expressed via **Fresnel integrals**. This is a classic choice in road/rail design and robotics for fair transitions. + +Clothoids are highly aesthetic and strictly $G^2$, but involve special functions. + +--- + +## 4. Smoothing parameterization + +Expose a UI parameter $s\in[0,1]$ and map it to geometric quantities: + +- **Trim lengths:** $d_i = s\,\min(\alpha L_i,\, d_{\max})$ (clamped for short edges). +- **Superelliptic $n$ (optional):** a monotone heuristic such as $n(s)=2+8s^2$ (documented as heuristic; not a standard). +- **Angle adaptivity:** reduce $s$ for very acute/concave corners to prevent self‑intersection. + +--- + +## 5. Robustness & implementation notes + +- **Concave corners.** Place the blend along the interior bisector; clamp trim to avoid inversion/self‑intersection. +- **Curved inputs.** Either flatten to a polyline (fast), or compute blends in the tangent frames of the original curves (higher fidelity). +- **Stroking.** Offset by re‑tessellating the **blended fill outline** at stroke width; do not assume parallel curves. +- **Caching.** Cache per (path hash, $s$, mode) and per common angle buckets. + +--- + +## 6. Relationship to common terms + +- **Rounded corners (circular fillet):** $G^1$ only, constant radius. +- **Superellipse / squircle:** a global analytic curve; the quarter‑superellipse in §3.1 provides an analytic $C^\infty$ fillet for orthogonal edges. +- **“Figma corner smoothing”:** a **Bézier‑based $G^2$ corner blend** akin to §3.3 (two cubics per corner with a smoothing factor). + +--- + +## 7. Modes (for engines) + +```rust +pub enum CornerBlendMode { + /// General-purpose, Bézier-native G² construction (two cubics). + BezierG2, + /// Analytic superelliptic fillet for (near) orthogonal edges. + Superelliptic, + /// Biarc (fast, robust G¹; near-G² with tuning). + Biarc, + /// Clothoid-based (analytic G²; uses Fresnel integrals). + Clothoid, +} +``` + +--- + +## 8. References (selected) + +- Farin, _Curves and Surfaces for CAGD_ — curve continuity and Bézier endpoint curvature. +- Hoschek & Lasser, _Fundamentals of Computer Aided Geometric Design_ — blending and fairness. +- Meek & Walton, “Approximating smooth planar curves by arc splines,” _J. Comput. Appl. Math._, 1994 — biarcs. +- Ahn et al., “Interpolating clothoid splines,” _Graphical Models_, 2011 — clothoid blends and Fresnel integrals. +- Lamé, “Memoire sur la théorie des surfaces isothermes,” 1818 — superellipse. diff --git a/docs/math/superellipse.md b/docs/math/superellipse.md new file mode 100644 index 0000000000..ae18187971 --- /dev/null +++ b/docs/math/superellipse.md @@ -0,0 +1,203 @@ +# Superellipse Mathematical Reference + +## Overview + +A **superellipse** (also called **Lamé curve**) is a family of closed curves that generalizes the ellipse. By varying the exponent parameter, superellipses can represent shapes ranging from diamonds to circles to rounded rectangles. + +Superellipses are widely used in modern design systems for creating smooth, continuous corners without the visual discontinuities of circular arc-based rounded rectangles. + +--- + +## Terminology Note + +The term **"squircle"** specifically refers to the case where **n = 4** (quartic superellipse). However, in design communities, "squircle" is often used loosely to describe any superellipse with smooth corners, including: + +- **True squircle**: $n = 4$ (quartic, Lamé's special quartic) +- **Apple's shape**: $n ≈ 5$ (quintic superellipse, NOT technically a squircle) + +This document describes the **general superellipse family** and focuses on the **quintic case (n ≈ 5)** commonly used in modern interface design. + +--- + +## Mathematical Definition + +A **superellipse** (Lamé curve) centered at the origin is defined as: + +$$ +\left| \frac{x}{a} \right|^n + \left| \frac{y}{b} \right|^n = 1 +$$ + +Where: + +- $a, b$: semi-major and semi-minor axes (when $a = b$, the shape is symmetric) +- $n > 0$: the **exponent** determining the shape's curvature + +### Shape Spectrum + +The exponent $n$ controls the shape's characteristics: + +| Exponent (n) | Shape | Description | +| ------------ | ---------------------- | -------------------------------------------------- | +| $n = 1$ | Diamond (rhombus) | Sharp corners at 45° | +| $n = 2$ | Circle (ellipse) | Perfect circular curvature | +| $n = 4$ | **Squircle** (quartic) | Mathematical definition of "squircle" | +| $n ≈ 5$ | Quintic superellipse | Apple's iOS/macOS icons (not technically squircle) | +| $n → ∞$ | Rectangle | Approaches sharp 90° corners | + +--- + +## Parametric Form + +A parametric representation useful for rendering: + +$$ +\begin{align} +x(t) &= a \cdot \text{sgn}(\cos t) \cdot |\cos t|^{2/n} \\ +y(t) &= b \cdot \text{sgn}(\sin t) \cdot |\sin t|^{2/n} +\end{align} +$$ + +Where $t \in [0, 2\pi]$. + +This formulation provides a smooth, continuous path for rasterization or vector path generation. + +--- + +## Visual Properties + +### Continuity + +For $n ≥ 2$: + +- **C¹ continuous** at all points (smooth tangents, no kinks) +- **C^∞ continuous** (infinitely differentiable) everywhere +- Curvature transitions are smooth and continuous + +This distinguishes superellipses from arc-based rounded rectangles, which have curvature discontinuities at the points where circular arcs meet straight edges. + +### Perceptual Characteristics + +- **Higher n values** (4-6): Softer, more organic appearance than circular arcs +- **Avoids optical bulge**: Circular rounded rectangles appear to "bulge" outward; superellipses maintain visual balance +- **Continuous curvature**: No visible transition points between corners and edges + +--- + +## Relation to Rounded Rectangles + +| Property | Rounded Rectangle (arc-based) | Superellipse | +| -------------------- | ----------------------------- | ----------------------------------------------- | +| Mathematical model | Piecewise (4 lines + 4 arcs) | Single implicit curve | +| Corner transition | Discrete arc joins | Continuous curvature | +| Curvature continuity | C¹ (curvature discontinuity) | C^∞ (infinitely smooth) | +| Exponent analogy | Fixed circular (n = 2) | Variable (typically n = 4-6 for design) | +| Implementation | Easier (native primitives) | Requires exponentiation or Bézier approximation | + +--- + +## Corner Smoothing Parameter + +Modern design tools often implement a **corner smoothing parameter** that interpolates between different superellipse exponents. + +### Parameter Model + +$$ +\text{corner\_smoothing} \in [0, 1] +$$ + +Typical mapping: + +- **0.0** → Circular rounded corners ($n = 2$) +- **≈0.6** → Quintic superellipse ($n ≈ 5$), visually matches Apple's icon shape +- **1.0** → Higher exponent (e.g., $n ≈ 10$), approaching rectangular + +### Empirical Exponent Mapping + +The following $n(s)$ relation is a **heuristic mapping** (not a mathematical standard): + +$$ +n(s) \approx 2 + 8s^2 +$$ + +Where: + +- $s$ is the smoothing factor $\in [0, 1]$ +- $n$ is the resulting superellipse exponent + +**Note**: This formula is empirical. Adjust constants based on perceptual requirements and testing. + +--- + +## Practical Implementation Notes + +### 1. Path Generation + +For rendering in systems like **Skia** or **HTML Canvas**, superellipses must be approximated using cubic Bézier curves, as they cannot be represented exactly in most graphics APIs. + +**Approximation approach**: + +- Use one or two cubic Bézier segments per corner (adaptive to desired accuracy) +- Handle lengths depend on the exponent $n$ and can be precomputed +- Higher $n$ values require more careful approximation + +### 2. Optimization Strategy + +```rust +if corner_smoothing <= 0.0 { + // Use SkRRect fast path (circular arcs) + // Native GPU acceleration +} else { + // Generate path using superellipse Bézier approximation + // Custom path rendering +} +``` + +### 3. Reference Constants + +For Apple-style quintic superellipse: + +```rust +// Apple's icon shape uses quintic superellipse, not squircle (n=4) +pub const APPLE_ICON_SMOOTHING: f32 = 0.6; // Heuristic smoothing value +pub const APPLE_ICON_EXPONENT: f32 = 5.0; // Quintic superellipse +``` + +For mathematical squircle: + +```rust +// True squircle definition (Lamé's special quartic) +pub const SQUIRCLE_EXPONENT: f32 = 4.0; // Quartic superellipse +``` + +### 4. Performance Tips + +- **Cache paths** per (radius, exponent, smoothing) tuple +- **Precompute Bézier control points** for common $n$ values (2, 4, 5, 6) +- **Use lookup tables** for $|\cos t|^{2/n}$ computations in GPU shaders +- **Tessellation**: For very high precision, consider adaptive tessellation based on curvature + +--- + +## References + +- Wikipedia: [Superellipse](https://en.wikipedia.org/wiki/Superellipse) +- Wikipedia: [Squircle](https://en.wikipedia.org/wiki/Squircle) +- Mathworld: [Superellipse](https://mathworld.wolfram.com/Superellipse.html) +- Liam Rosenfeld: [Apple Icon Quest](https://liamrosenfeld.com/posts/apple_icon_quest/) +- Marc Edwards (Bjango): _Continuous Corners in iOS_ + +--- + +## Summary + +**Superellipses** are a family of curves defined by the equation $\left|\frac{x}{a}\right|^n + \left|\frac{y}{b}\right|^n = 1$. + +**Key points**: + +- **Squircle** ($n=4$) is ONE specific case, not the general term +- **Apple icons** use $n≈5$ (quintic), which is NOT a squircle +- For design systems, model corner smoothing as a **continuous parameter** rather than Boolean +- Typical smoothing value: **0.6** (heuristic, corresponds to $n≈5$) +- Implement as: `corner_smoothing: f32` with Bézier approximation + +**Mathematical precision**: When $n ≥ 2$, superellipses are C^∞ continuous (infinitely differentiable), providing smooth, organic curves superior to arc-based rounded rectangles for interface design. diff --git a/editor/grida-canvas/editor.i.ts b/editor/grida-canvas/editor.i.ts index 60144667d6..c1a7b0f39c 100644 --- a/editor/grida-canvas/editor.i.ts +++ b/editor/grida-canvas/editor.i.ts @@ -2940,6 +2940,10 @@ export namespace editor.api { node_id: NodeID, cornerRadius: cg.CornerRadius ): void; + changeNodePropertyCornerSmoothing( + node_id: NodeID, + cornerSmoothing: number + ): void; changeNodePropertyCornerRadiusWithDelta( node_id: NodeID, delta: number diff --git a/editor/grida-canvas/editor.ts b/editor/grida-canvas/editor.ts index bc06b3f30c..055f8c6703 100644 --- a/editor/grida-canvas/editor.ts +++ b/editor/grida-canvas/editor.ts @@ -1662,6 +1662,17 @@ class EditorDocumentStore } } + changeNodePropertyCornerSmoothing( + node_id: editor.NodeID, + cornerSmoothing: number + ): void { + this.dispatch({ + type: "node/change/*", + node_id: node_id, + cornerSmoothing, + }); + } + changeNodePropertyCornerRadiusWithDelta( node_id: string, delta: number @@ -4280,7 +4291,7 @@ export class NodeProxy { node_id: this.node_id, rotation: value, }); - } + }; /** * {@link grida.program.nodes.UnknwonNode#opacity} diff --git a/editor/grida-canvas/reducers/node.reducer.ts b/editor/grida-canvas/reducers/node.reducer.ts index 1b0bc02403..da83b26c81 100644 --- a/editor/grida-canvas/reducers/node.reducer.ts +++ b/editor/grida-canvas/reducers/node.reducer.ts @@ -329,6 +329,11 @@ const safe_properties: Partial< (draft as UN).cornerRadiusBottomLeft = value; }, }), + cornerSmoothing: defineNodeProperty<"cornerSmoothing">({ + apply: (draft, value, prev) => { + (draft as UN).cornerSmoothing = cmath.clamp(value, 0, 1); + }, + }), pointCount: defineNodeProperty<"pointCount">({ assert: (node) => typeof node.pointCount === "number", apply: (draft, value, prev) => { diff --git a/packages/grida-canvas-schema/grida.ts b/packages/grida-canvas-schema/grida.ts index 333358d00f..eb8fd1c7db 100644 --- a/packages/grida-canvas-schema/grida.ts +++ b/packages/grida-canvas-schema/grida.ts @@ -1479,6 +1479,7 @@ export namespace grida.program.nodes { cornerRadiusTopRight?: number; cornerRadiusBottomLeft?: number; cornerRadiusBottomRight?: number; + cornerSmoothing?: number; } /**