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
131 changes: 86 additions & 45 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/grida-canvas-wasm/lib/bin/grida-canvas-wasm.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm
Git LFS file not shown
11 changes: 11 additions & 0 deletions crates/grida-canvas-wasm/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ export namespace types {
vertices: VectorNetworkVertex[];
segments: VectorNetworkSegment[];
}

/**
* Result of flattening a shape node to a vector network.
*
* When `corner_radius` is present, the vector network contains straight
* segments and corner radius should be applied as a rendering effect.
* When absent, corner geometry is baked into the vector network as curves.
*/
export interface FlattenResult extends VectorNetwork {
corner_radius?: number;
}
}

// ====================================================================================================
Expand Down
9 changes: 8 additions & 1 deletion crates/grida-canvas-wasm/lib/modules/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,15 @@ export class Scene {
/**
* Convert a node into a vector network representation.
* Supports primitive shapes and text nodes.
*
* Returns a flatten result containing the vector network and an optional
* corner radius. When `corner_radius` is present, it means the vector
* network has straight segments and corner radius should be applied as
* a rendering effect. When absent, curves are baked into the geometry.
*/
toVectorNetwork(id: string): types.VectorNetwork | null {
toVectorNetwork(
id: string
): types.FlattenResult | null {
this._assertAlive();
const [ptr, len] = this._alloc_string(id);
const outptr = this.module._to_vector_network(this.appptr, ptr, len - 1);
Expand Down
2 changes: 1 addition & 1 deletion crates/grida-canvas-wasm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@grida/canvas-wasm",
"version": "0.90.0-canary.8",
"version": "0.90.0-canary.9",
"private": false,
"description": "WASM bindings for Grida Canvas",
"keywords": [
Expand Down
4 changes: 2 additions & 2 deletions crates/grida-canvas-wasm/src/wasm_application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,8 +552,8 @@ pub unsafe extern "C" fn to_vector_network(
return std::ptr::null();
};

if let Some(vn) = app.to_vector_network(&id) {
if let Ok(json) = serde_json::to_string(&vn) {
if let Some(result) = app.to_vector_network(&id) {
if let Ok(json) = serde_json::to_string(&result) {
return alloc_len_prefixed(json.as_bytes());
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/grida-canvas/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ native-clock-tick = []
criterion = "0.5"
clap = { version = "4.5.39", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
rendiff = "0.2"
imgref = "1.10"

[[bench]]
name = "bench_rectangles"
Expand Down
100 changes: 100 additions & 0 deletions crates/grida-canvas/examples/golden_corner_radius_backends.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! # Corner Radius Backends: Visual Comparison
//!
//! This example demonstrates that Skia's native rrect and the `corner_path`
//! PathEffect produce **different curves** for the same corner radius value.
//!
//! Output: `goldens/corner_radius_backends.png`
//!
//! The image shows three columns at two different radii:
//! - **Red** — Native `SkRRect` (conic arcs, true circular corners)
//! - **Blue** — `PathEffect::corner_path` on a sharp rect path
//! - **Overlay** — Both drawn with 50% opacity to visualize the difference
//!
//! See `shape/corner.rs` for full documentation of this limitation.

use cg::cg::types::RectangularCornerRadius;
use cg::shape::*;
use skia_safe::{surfaces, Color, Color4f, ColorSpace, Paint};

fn main() {
let radii = [40.0_f32, 80.0_f32];
let shape_w = 300.0_f32;
let shape_h = 300.0_f32;
let pad = 30.0_f32;
let col_w = shape_w + pad;
let row_h = shape_h + pad;

let canvas_w = (col_w * 3.0 + pad) as i32;
let canvas_h = (row_h * radii.len() as f32 + pad) as i32;

let mut surface = surfaces::raster_n32_premul((canvas_w, canvas_h)).expect("surface");
let canvas = surface.canvas();
canvas.clear(Color::WHITE);

// Paints
let mut paint_rrect = Paint::new(
Color4f::new(0.84, 0.36, 0.36, 1.0),
&ColorSpace::new_srgb(),
);
paint_rrect.set_anti_alias(true);

let mut paint_corner = Paint::new(
Color4f::new(0.20, 0.47, 0.76, 1.0),
&ColorSpace::new_srgb(),
);
paint_corner.set_anti_alias(true);

let mut paint_rrect_overlay = paint_rrect.clone();
paint_rrect_overlay.set_alpha_f(0.5);
let mut paint_corner_overlay = paint_corner.clone();
paint_corner_overlay.set_alpha_f(0.5);

for (row, &radius) in radii.iter().enumerate() {
let y = pad + row as f32 * row_h;

// Build paths
let rect_shape = RectShape {
width: shape_w,
height: shape_h,
};
let rect_path: skia_safe::Path = (&rect_shape).into();
let corner_effect_path = build_corner_radius_path(&rect_path, radius);

let rrect_shape = RRectShape {
width: shape_w,
height: shape_h,
corner_radius: RectangularCornerRadius::circular(radius),
};
let rrect_path = build_rrect_path(&rrect_shape);

// Col 1: Native rrect (red)
canvas.save();
canvas.translate((pad, y));
canvas.draw_path(&rrect_path, &paint_rrect);
canvas.restore();

// Col 2: corner_path effect (blue)
canvas.save();
canvas.translate((pad + col_w, y));
canvas.draw_path(&corner_effect_path, &paint_corner);
canvas.restore();

// Col 3: Overlay
canvas.save();
canvas.translate((pad + col_w * 2.0, y));
canvas.draw_path(&rrect_path, &paint_rrect_overlay);
canvas.draw_path(&corner_effect_path, &paint_corner_overlay);
canvas.restore();
}

let image = surface.image_snapshot();
let data = image
.encode(None, skia_safe::EncodedImageFormat::PNG, None)
.unwrap();
let out_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/goldens/corner_radius_backends.png"
);
std::fs::write(out_path, data.as_bytes()).unwrap();
println!("Written to {out_path}");
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion crates/grida-canvas/src/helpers/webfont_helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ pub async fn fetch_webfont(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Err
}

#[cfg(target_arch = "wasm32")]
pub async fn fetch_webfont(url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
pub async fn fetch_webfont(_url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Stub for wasm
Err("Webfont fetching not supported in wasm".into())
}
21 changes: 21 additions & 0 deletions crates/grida-canvas/src/io/io_grida.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,27 @@ impl From<VectorNetwork> for JSONVectorNetwork {
}
}

/// Result of flattening a shape to a vector network.
///
/// When `corner_radius` is `Some`, the vector network contains straight
/// segments and the corner radius should be applied as a rendering effect
/// (e.g. polygon, star). These shapes use `corner_path` PathEffect for
/// rendering, so the effect is preserved.
///
/// When `corner_radius` is `None`, the corner geometry is already baked
/// into the vector network as Bézier curves (e.g. rectangle, ellipse).
/// Rectangles always bake because their native rrect rendering uses conic
/// arcs, which differ from the `corner_path` PathEffect. See
/// [`crate::shape::build_corner_radius_path`] for details.
#[derive(Debug, Serialize)]
pub struct JSONFlattenResult {
#[serde(flatten)]
pub vector_network: JSONVectorNetwork,
/// Uniform corner radius to apply as a rendering effect, if applicable.
#[serde(skip_serializing_if = "Option::is_none")]
pub corner_radius: Option<f32>,
}

#[derive(Debug, Deserialize)]
pub struct JSONLineNode {
#[serde(flatten)]
Expand Down
1 change: 1 addition & 0 deletions crates/grida-canvas/src/layout/into_taffy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ impl From<LayoutPositioningBasis> for Rect<LengthPercentageAuto> {
top: LengthPercentageAuto::length(inset.top),
bottom: LengthPercentageAuto::length(inset.bottom),
},
#[allow(deprecated)]
LayoutPositioningBasis::Anchored => {
unreachable!("Anchored positioning is not supported")
}
Comment thread
softmarshmallow marked this conversation as resolved.
Expand Down
8 changes: 5 additions & 3 deletions crates/grida-canvas/src/node/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ impl LayoutPositioningBasis {
match self {
Self::Cartesian(point) => Some(point.x),
Self::Inset(inset) => Some(inset.left),
#[allow(deprecated)]
Self::Anchored => unreachable!("Anchored positioning is not supported"),
}
}
Expand All @@ -788,6 +789,7 @@ impl LayoutPositioningBasis {
match self {
Self::Cartesian(point) => Some(point.y),
Self::Inset(inset) => Some(inset.top),
#[allow(deprecated)]
Self::Anchored => unreachable!("Anchored positioning is not supported"),
}
}
Expand Down Expand Up @@ -1813,7 +1815,7 @@ impl NodeShapeMixin for PolygonNodeRec {
}

fn to_vector_network(&self) -> VectorNetwork {
build_simple_polygon_vector_network(&self.to_own_shape())
(&self.to_shape()).into()
}
}

Expand Down Expand Up @@ -1930,7 +1932,7 @@ impl NodeShapeMixin for RegularPolygonNodeRec {
}

fn to_vector_network(&self) -> VectorNetwork {
build_regular_polygon_vector_network(&self.to_own_shape())
(&self.to_shape()).into()
}
}

Expand Down Expand Up @@ -2035,7 +2037,7 @@ impl NodeShapeMixin for RegularStarPolygonNodeRec {
}

fn to_vector_network(&self) -> VectorNetwork {
build_star_vector_network(&self.to_own_shape())
(&self.to_shape()).into()
}
}

Expand Down
10 changes: 5 additions & 5 deletions crates/grida-canvas/src/os/emscripten.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@ unsafe extern "C" {
unsafe extern "C" {
pub fn emscripten_request_animation_frame(
cb: ::std::option::Option<
unsafe extern "C" fn(time: f64, userData: *mut ::std::os::raw::c_void) -> bool,
unsafe extern "C" fn(time: f64, user_data: *mut ::std::os::raw::c_void) -> bool,
>,
userData: *mut ::std::os::raw::c_void,
user_data: *mut ::std::os::raw::c_void,
) -> ::std::os::raw::c_int;
}

unsafe extern "C" {
pub fn emscripten_cancel_animation_frame(requestAnimationFrameId: ::std::os::raw::c_int);
pub fn emscripten_cancel_animation_frame(request_animation_frame_id: ::std::os::raw::c_int);
}

unsafe extern "C" {
pub fn emscripten_request_animation_frame_loop(
cb: ::std::option::Option<
unsafe extern "C" fn(time: f64, userData: *mut ::std::os::raw::c_void) -> bool,
unsafe extern "C" fn(time: f64, user_data: *mut ::std::os::raw::c_void) -> bool,
>,
userData: *mut ::std::os::raw::c_void,
user_data: *mut ::std::os::raw::c_void,
);
}
65 changes: 65 additions & 0 deletions crates/grida-canvas/src/shape/corner.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
use skia_safe;

/// # Corner Radius: Two Distinct Backends
///
/// Grida has **two different corner rounding mechanisms** that produce
/// visually different curves. This is a known limitation.
///
/// ## Backend 1: `corner_path` PathEffect (this function)
///
/// Used by: **polygons, stars, and vector nodes with `corner_radius`**.
///
/// Skia's `PathEffect::corner_path(r)` walks the path and, at each sharp
/// vertex, cuts the two adjacent edges at distance `r` from the corner and
/// connects the cut points. The resulting corner is **not** a circular arc —
/// it is a simpler geometric cut that depends on the angle between the edges.
///
/// ## Backend 2: Native `SkRRect` (conic arcs)
///
/// Used by: **rectangles with corner radius** (the `RectangleNode` primitive).
///
/// Skia's `RRect` internally uses **conic curves** (rational quadratic Bézier
/// with weight `w = √2/2`) to draw true circular arcs at each corner. This
/// produces a geometrically precise quarter-circle.
///
/// ## Why they differ
///
/// The two approaches use mathematically distinct curve types:
///
/// - **`corner_path`** (`SkCornerPathEffect`): At each corner, cuts the two
/// adjacent edges at distance `r` from the vertex, then connects the cut
/// points with a **quadratic Bézier** (`quadTo`) whose control point is
/// the original corner vertex. This traces a **parabolic arc**.
///
/// - **Native rrect** (`SkRRect`): Uses **conic curves** (rational quadratic
/// Bézier with weight `w = √2/2`) at each corner, which trace a **true
/// circular arc** of radius `r`.
///
/// A parabolic arc and a circular arc through the same two endpoints with
/// the same corner vertex as control point are different curves. At large
/// radii (e.g. `r = 80` on a 400×400 rectangle), the difference is clearly
/// visible — roughly 1–2% of pixels differ, with entire pixels filled in
/// one but empty in the other.
///
/// ## Implications for flatten (shape → vector)
///
/// When flattening a rectangle to a vector node:
/// - **Do NOT** use "simple rect VN + `corner_radius`" for rectangles, because
/// the vector renderer applies `corner_path` (Backend 1), which would produce
/// a different shape than the original rrect (Backend 2).
/// - **Always bake** the rrect Bézier curves into the vector network geometry.
/// This guarantees pixel-identical output.
///
/// Polygons and stars are safe to flatten as "straight VN + `corner_radius`"
/// because their original rendering already uses `corner_path` (Backend 1).
///
/// ## Future work
///
/// `SkCornerPathEffect` is **pure CPU path manipulation** — it walks the path
/// vertices, computes cut points, and emits `quadTo` segments. There is no
/// GPU shader involved. This means we can replace it with a custom
/// implementation that emits **conic arcs** (matching `SkRRect`) instead of
/// quadratic Béziers, without any GPU/shader work and without significant
/// performance loss. The algorithm is ~150 lines of C++ (see
/// `SkCornerPathEffect.cpp`). A Rust port that uses `conicTo` instead of
/// `quadTo` at line 92 would unify both backends.
///
/// See also: `golden_corner_radius_backends` example for a visual comparison.
pub fn build_corner_radius_path(path: &skia_safe::Path, r: f32) -> skia_safe::Path {
let mut paint = skia_safe::Paint::default();
paint.set_path_effect(skia_safe::PathEffect::corner_path(r));
Expand Down
6 changes: 3 additions & 3 deletions crates/grida-canvas/src/shape/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl Into<VectorNetwork> for &Shape {
Shape::OrthogonalSmoothRRect(shape) => {
build_orthogonal_smooth_rrect_vector_network(shape)
}
Shape::SimplePolygon(shape) => build_simple_polygon_vector_network(shape),
Shape::SimplePolygon(shape) => build_simple_polygon_vector_geometry(shape).into(),
Shape::Ellipse(shape) => build_ellipse_vector_network(shape),
Shape::EllipticalRingSector(_shape) => {
todo!("Arc shape to vector network requires manual implementation")
Expand All @@ -80,8 +80,8 @@ impl Into<VectorNetwork> for &Shape {
todo!("Arc shape to vector network requires manual implementation")
}
Shape::EllipticalRing(shape) => build_ring_vector_network(shape),
Shape::RegularStarPolygon(shape) => build_star_vector_network(shape),
Shape::RegularPolygon(shape) => build_regular_polygon_vector_network(shape),
Shape::RegularStarPolygon(shape) => build_star_vector_geometry(shape).into(),
Shape::RegularPolygon(shape) => build_regular_polygon_vector_geometry(shape).into(),
}
}
}
Loading
Loading