Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/grida-canvas/benches/bench_rectangles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
110 changes: 110 additions & 0 deletions crates/grida-canvas/examples/golden_corner_smoothing.rs
Original file line number Diff line number Diff line change
@@ -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");
}
1 change: 1 addition & 0 deletions crates/grida-canvas/examples/golden_layout_flex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/grida-canvas/examples/golden_layout_flex_padding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/grida-canvas/examples/golden_layout_padding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Binary file added crates/grida-canvas/goldens/corner_smoothing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions crates/grida-canvas/src/cg/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions crates/grida-canvas/src/io/io_figma.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -951,6 +953,7 @@ impl FigmaConverter {
mask: None,
rotation: transform.rotation(),
corner_radius: RectangularCornerRadius::zero(),
corner_smoothing: Default::default(),
fills: self.convert_fills(Some(&section.fills.as_ref())),
strokes: Paints::default(),
stroke_width: 0.0,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions crates/grida-canvas/src/io/io_grida.rs
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,8 @@ pub struct JSONUnknownNodeProperties {
deserialize_with = "de_radius_option"
)]
pub corner_radius_bottom_left: Option<Radius>,
#[serde(rename = "cornerSmoothing", default)]
pub corner_smoothing: Option<f32>,

// fill
#[serde(rename = "fill")]
Expand Down Expand Up @@ -1233,6 +1235,7 @@ impl From<JSONContainerNode> 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,
Expand Down Expand Up @@ -1456,6 +1459,7 @@ impl From<JSONRectangleNode> 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,
Expand Down Expand Up @@ -1553,6 +1557,7 @@ impl From<JSONImageNode> 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,
Expand Down Expand Up @@ -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
Expand Down
Loading