From 374aeea791c77dc765b56824025c4d568d6225c4 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 28 Oct 2025 20:03:47 +0900 Subject: [PATCH 1/7] add squircle.md math reference --- docs/math/squircle.md | 146 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/math/squircle.md diff --git a/docs/math/squircle.md b/docs/math/squircle.md new file mode 100644 index 0000000000..1008d5fed4 --- /dev/null +++ b/docs/math/squircle.md @@ -0,0 +1,146 @@ +# Squircle (Superellipse) Mathematical Reference + +## Overview + +A **squircle** is a shape intermediate between a square and a circle, forming a smooth, continuous curve with no discrete corners. It is mathematically modeled as a special case of the **superellipse** (or Lamé curve), and is widely used in design systems such as **Apple's iOS icons**. + +--- + +## Mathematical Definition + +A general **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 (for a squircle, usually $a = b$) +- $n > 0$: the **exponent** determining the shape's curvature + +### Common cases + +| Shape | Exponent (n) | Description | +| -------------------------- | ------------ | ------------------------------------------------------------------------------ | +| Circle | 2 | Perfect circular curvature | +| Apple squircle | ≈ 5 | Used in iOS/macOS app icons (approximation; not officially specified by Apple) | +| Rounded rectangle (approx) | → ∞ | Approaches square corners | + +Thus, the **squircle** can be seen as a **superellipse with n ≈ 5**. + +--- + +## Parametric Form + +A parametric form is 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 + +- **C¹ continuous** at all points (smooth tangents) +- smooth (C^∞) curvature; curvature is continuous everywhere for n ≥ 2. + +### Perceptual result + +- Softer and more organic than circular arcs +- Used to avoid the “optical bulge” of simple rounded rectangles + +--- + +## Relation to Rounded Rectangles + +| Property | Rounded Rectangle | Squircle | +| -------------------- | ---------------------------- | ----------------------------------------------- | +| Corner transition | Arc joins (circular) | Continuous curvature (superelliptic) | +| Edge definition | Piecewise (4 lines + 4 arcs) | Single implicit curve | +| Curvature continuity | C¹ (discontinuous curvature) | C² (smooth curvature) | +| Math simplicity | Easier | Requires exponentiation or Bézier approximation | + +--- + +## Corner Smoothing Parameter + +Modern design tools implement a **corner smoothing parameter** that blends between a rounded rectangle and a squircle. + +### Parameter model + +$$ +\text{corner\_smoothing} \in [0, 1] +$$ + +- **0.0** → Pure circular rounded rect +- ≈0.6 → visually matches Apple‑style icon masks (heuristic; fits often correspond to n ≈ 5). +- **1.0** → Maximum smoothing (approaching uniform superellipse) + +The following n(s) relation is a heuristic mapping (not standard). + +Empirical mapping between smoothing factor $s$ and superellipse exponent $n$: + +$$ +n(s) \approx 2 + 8s^2 +$$ + +Adjust constants as needed for perceptual fit. + +--- + +## Practical Implementation Notes + +### 1. Path Generation + +For rendering in systems like **Skia** or **HTML Canvas**, you’ll need to approximate the superellipse using cubic Béziers. + +A full squircle corner can be composed of one or two cubic Bézier segments per corner (adaptive to desired accuracy). The handle length depends on the smoothing parameter and can be precomputed for performance. + +### 2. Optimization Strategy + +```rust +if corner_smoothing <= 0.0 { + // Use SkRRect fast path +} else { + // Generate path using superellipse / Bézier approximation +} +``` + +### 3. Constants for Apple‑like Squircles + +```rust +pub const SQUIRCLE_SMOOTHING_APPLE: f32 = 0.6; +pub const SQUIRCLE_EXPONENT_APPLE: f32 = 5.0; +``` + +### 4. Performance Tips + +- Cache paths per (radius, smoothing) pair +- Precompute control-point ratios for common smoothing values +- Approximate exponentiation with lookup tables for GPU shaders + +--- + +## References + +- Wikipedia: [Superellipse / Squircle](https://en.wikipedia.org/wiki/Squircle) +- Liam Rosenfeld: [Apple Icon Quest](https://liamrosenfeld.com/posts/apple_icon_quest/) +- Marc Edwards (Bjango): _Continuous Corners in iOS_ + +--- + +**Summary:** +A _squircle_ is a superellipse ($n≈5$) used to achieve continuous, organic corners. +In design engines, model it as a rectangle with a **continuous smoothing factor** rather than a Boolean — `corner_smoothing: f32`. +A common visual default is ≈ 0.6 (heuristic, matches Apple‑style corners). From b325a015e5176e605fc3ed09fbe63316b258bbc9 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 28 Oct 2025 20:18:21 +0900 Subject: [PATCH 2/7] mv --- docs/math/squircle.md | 146 --------------------------- docs/math/superellipse.md | 203 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 146 deletions(-) delete mode 100644 docs/math/squircle.md create mode 100644 docs/math/superellipse.md diff --git a/docs/math/squircle.md b/docs/math/squircle.md deleted file mode 100644 index 1008d5fed4..0000000000 --- a/docs/math/squircle.md +++ /dev/null @@ -1,146 +0,0 @@ -# Squircle (Superellipse) Mathematical Reference - -## Overview - -A **squircle** is a shape intermediate between a square and a circle, forming a smooth, continuous curve with no discrete corners. It is mathematically modeled as a special case of the **superellipse** (or Lamé curve), and is widely used in design systems such as **Apple's iOS icons**. - ---- - -## Mathematical Definition - -A general **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 (for a squircle, usually $a = b$) -- $n > 0$: the **exponent** determining the shape's curvature - -### Common cases - -| Shape | Exponent (n) | Description | -| -------------------------- | ------------ | ------------------------------------------------------------------------------ | -| Circle | 2 | Perfect circular curvature | -| Apple squircle | ≈ 5 | Used in iOS/macOS app icons (approximation; not officially specified by Apple) | -| Rounded rectangle (approx) | → ∞ | Approaches square corners | - -Thus, the **squircle** can be seen as a **superellipse with n ≈ 5**. - ---- - -## Parametric Form - -A parametric form is 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 - -- **C¹ continuous** at all points (smooth tangents) -- smooth (C^∞) curvature; curvature is continuous everywhere for n ≥ 2. - -### Perceptual result - -- Softer and more organic than circular arcs -- Used to avoid the “optical bulge” of simple rounded rectangles - ---- - -## Relation to Rounded Rectangles - -| Property | Rounded Rectangle | Squircle | -| -------------------- | ---------------------------- | ----------------------------------------------- | -| Corner transition | Arc joins (circular) | Continuous curvature (superelliptic) | -| Edge definition | Piecewise (4 lines + 4 arcs) | Single implicit curve | -| Curvature continuity | C¹ (discontinuous curvature) | C² (smooth curvature) | -| Math simplicity | Easier | Requires exponentiation or Bézier approximation | - ---- - -## Corner Smoothing Parameter - -Modern design tools implement a **corner smoothing parameter** that blends between a rounded rectangle and a squircle. - -### Parameter model - -$$ -\text{corner\_smoothing} \in [0, 1] -$$ - -- **0.0** → Pure circular rounded rect -- ≈0.6 → visually matches Apple‑style icon masks (heuristic; fits often correspond to n ≈ 5). -- **1.0** → Maximum smoothing (approaching uniform superellipse) - -The following n(s) relation is a heuristic mapping (not standard). - -Empirical mapping between smoothing factor $s$ and superellipse exponent $n$: - -$$ -n(s) \approx 2 + 8s^2 -$$ - -Adjust constants as needed for perceptual fit. - ---- - -## Practical Implementation Notes - -### 1. Path Generation - -For rendering in systems like **Skia** or **HTML Canvas**, you’ll need to approximate the superellipse using cubic Béziers. - -A full squircle corner can be composed of one or two cubic Bézier segments per corner (adaptive to desired accuracy). The handle length depends on the smoothing parameter and can be precomputed for performance. - -### 2. Optimization Strategy - -```rust -if corner_smoothing <= 0.0 { - // Use SkRRect fast path -} else { - // Generate path using superellipse / Bézier approximation -} -``` - -### 3. Constants for Apple‑like Squircles - -```rust -pub const SQUIRCLE_SMOOTHING_APPLE: f32 = 0.6; -pub const SQUIRCLE_EXPONENT_APPLE: f32 = 5.0; -``` - -### 4. Performance Tips - -- Cache paths per (radius, smoothing) pair -- Precompute control-point ratios for common smoothing values -- Approximate exponentiation with lookup tables for GPU shaders - ---- - -## References - -- Wikipedia: [Superellipse / Squircle](https://en.wikipedia.org/wiki/Squircle) -- Liam Rosenfeld: [Apple Icon Quest](https://liamrosenfeld.com/posts/apple_icon_quest/) -- Marc Edwards (Bjango): _Continuous Corners in iOS_ - ---- - -**Summary:** -A _squircle_ is a superellipse ($n≈5$) used to achieve continuous, organic corners. -In design engines, model it as a rectangle with a **continuous smoothing factor** rather than a Boolean — `corner_smoothing: f32`. -A common visual default is ≈ 0.6 (heuristic, matches Apple‑style corners). 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. From 21cde5eb07d376de8157c145250b321e858f94c9 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 28 Oct 2025 20:46:26 +0900 Subject: [PATCH 3/7] add g2 docs --- docs/math/g2-curve-blending.md.md | 151 ++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/math/g2-curve-blending.md.md diff --git a/docs/math/g2-curve-blending.md.md b/docs/math/g2-curve-blending.md.md new file mode 100644 index 0000000000..b579f40958 --- /dev/null +++ b/docs/math/g2-curve-blending.md.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. From b21f070354f4e0f9863a4c9c90bdce84e6313934 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 28 Oct 2025 21:26:12 +0900 Subject: [PATCH 4/7] type corner smoothing --- crates/grida-canvas/src/cg/types.rs | 40 +++++++++++++++++++++++++ crates/grida-canvas/src/io/io_grida.rs | 1 + crates/grida-canvas/src/node/factory.rs | 1 + crates/grida-canvas/src/node/schema.rs | 1 + 4 files changed, 43 insertions(+) diff --git a/crates/grida-canvas/src/cg/types.rs b/crates/grida-canvas/src/cg/types.rs index b3805bab9f..26d21700d5 100644 --- a/crates/grida-canvas/src/cg/types.rs +++ b/crates/grida-canvas/src/cg/types.rs @@ -960,6 +960,46 @@ 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 + } +} + +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_grida.rs b/crates/grida-canvas/src/io/io_grida.rs index 231013e861..4eb5909676 100644 --- a/crates/grida-canvas/src/io/io_grida.rs +++ b/crates/grida-canvas/src/io/io_grida.rs @@ -1456,6 +1456,7 @@ impl From for Node { node.base.corner_radius_bottom_right, node.base.corner_radius_bottom_left, ), + corner_smoothing: Default::default(), fills: merge_paints(node.base.fill, node.base.fills), strokes: merge_paints(node.base.stroke, node.base.strokes), stroke_width: node.base.stroke_width, diff --git a/crates/grida-canvas/src/node/factory.rs b/crates/grida-canvas/src/node/factory.rs index 5a30e0c5f9..e5d74b47e8 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, diff --git a/crates/grida-canvas/src/node/schema.rs b/crates/grida-canvas/src/node/schema.rs index 43aad1b09a..102166d41c 100644 --- a/crates/grida-canvas/src/node/schema.rs +++ b/crates/grida-canvas/src/node/schema.rs @@ -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, From a09831331ed538c4f432fea927ead1f43c96b79f Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 29 Oct 2025 16:19:14 +0900 Subject: [PATCH 5/7] corner smoothing --- .../grida-canvas/benches/bench_rectangles.rs | 1 + .../examples/golden_corner_smoothing.rs | 110 ++++++++ .../examples/golden_layout_flex.rs | 1 + .../examples/golden_layout_flex_alignment.rs | 1 + .../examples/golden_layout_flex_padding.rs | 1 + .../examples/golden_layout_padding.rs | 1 + .../grida-canvas/goldens/corner_smoothing.png | Bin 0 -> 27388 bytes crates/grida-canvas/src/cg/types.rs | 5 + crates/grida-canvas/src/io/io_figma.rs | 6 + crates/grida-canvas/src/io/io_grida.rs | 99 ++++++- crates/grida-canvas/src/node/factory.rs | 2 + crates/grida-canvas/src/node/schema.rs | 39 +-- crates/grida-canvas/src/painter/geometry.rs | 54 ++-- crates/grida-canvas/src/shape/mod.rs | 15 +- crates/grida-canvas/src/shape/rect.rs | 29 ++ crates/grida-canvas/src/shape/rrect.rs | 6 + .../src/shape/srrect_orthogonal.rs | 254 ++++++++++++++++++ crates/math2/tests/bezier.rs | 22 +- 18 files changed, 598 insertions(+), 48 deletions(-) create mode 100644 crates/grida-canvas/examples/golden_corner_smoothing.rs create mode 100644 crates/grida-canvas/goldens/corner_smoothing.png create mode 100644 crates/grida-canvas/src/shape/srrect_orthogonal.rs 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 0000000000000000000000000000000000000000..bdce220f5633f635b7adc6a3e18817ebd27088ef GIT binary patch literal 27388 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 bHg{e;;5?Qqybf68pbq7&dphMe?VtZ2i>X4_ literal 0 HcmV?d00001 diff --git a/crates/grida-canvas/src/cg/types.rs b/crates/grida-canvas/src/cg/types.rs index 26d21700d5..548a25f4e4 100644 --- a/crates/grida-canvas/src/cg/types.rs +++ b/crates/grida-canvas/src/cg/types.rs @@ -993,6 +993,11 @@ impl CornerSmoothing { pub fn value(self) -> f32 { self.0 } + + #[inline] + pub fn is_zero(&self) -> bool { + self.0 == 0.0 + } } impl Default for CornerSmoothing { 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 4eb5909676..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,7 +1459,7 @@ impl From for Node { node.base.corner_radius_bottom_right, node.base.corner_radius_bottom_left, ), - corner_smoothing: Default::default(), + 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, @@ -1554,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, @@ -3925,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 e5d74b47e8..e2df8b293a 100644 --- a/crates/grida-canvas/src/node/factory.rs +++ b/crates/grida-canvas/src/node/factory.rs @@ -160,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, @@ -297,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 102166d41c..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, @@ -960,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]); @@ -1017,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() } } @@ -1077,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, @@ -1240,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..72487da1d9 --- /dev/null +++ b/crates/grida-canvas/src/shape/srrect_orthogonal.rs @@ -0,0 +1,254 @@ +//! 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 { + // TODO: Implement vector network representation + VectorNetwork { + vertices: vec![], + segments: vec![], + regions: vec![], + } +} + +#[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() { From 4ed128ab8059dcc54ad59cd3e3c9a105dd2492af Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 29 Oct 2025 17:10:40 +0900 Subject: [PATCH 6/7] corner smoothing spec --- editor/grida-canvas/editor.i.ts | 4 ++++ editor/grida-canvas/editor.ts | 13 ++++++++++++- editor/grida-canvas/reducers/node.reducer.ts | 5 +++++ packages/grida-canvas-schema/grida.ts | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) 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..a5daac25c7 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 = value; + }, + }), 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; } /** From c04864cbdb15876b5018f03d3d624afd2fc0cead Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 29 Oct 2025 17:33:30 +0900 Subject: [PATCH 7/7] chore --- crates/grida-canvas/src/shape/srrect_orthogonal.rs | 10 ++++------ .../{g2-curve-blending.md.md => g2-curve-blending.md} | 0 editor/grida-canvas/reducers/node.reducer.ts | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) rename docs/math/{g2-curve-blending.md.md => g2-curve-blending.md} (100%) diff --git a/crates/grida-canvas/src/shape/srrect_orthogonal.rs b/crates/grida-canvas/src/shape/srrect_orthogonal.rs index 72487da1d9..7f8cb6fff7 100644 --- a/crates/grida-canvas/src/shape/srrect_orthogonal.rs +++ b/crates/grida-canvas/src/shape/srrect_orthogonal.rs @@ -212,12 +212,10 @@ pub fn build_orthogonal_smooth_rrect_path(shape: &OrthogonalSmoothRRectShape) -> pub fn build_orthogonal_smooth_rrect_vector_network( _shape: &OrthogonalSmoothRRectShape, ) -> VectorNetwork { - // TODO: Implement vector network representation - VectorNetwork { - vertices: vec![], - segments: vec![], - regions: vec![], - } + // 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)] diff --git a/docs/math/g2-curve-blending.md.md b/docs/math/g2-curve-blending.md similarity index 100% rename from docs/math/g2-curve-blending.md.md rename to docs/math/g2-curve-blending.md diff --git a/editor/grida-canvas/reducers/node.reducer.ts b/editor/grida-canvas/reducers/node.reducer.ts index a5daac25c7..da83b26c81 100644 --- a/editor/grida-canvas/reducers/node.reducer.ts +++ b/editor/grida-canvas/reducers/node.reducer.ts @@ -331,7 +331,7 @@ const safe_properties: Partial< }), cornerSmoothing: defineNodeProperty<"cornerSmoothing">({ apply: (draft, value, prev) => { - (draft as UN).cornerSmoothing = value; + (draft as UN).cornerSmoothing = cmath.clamp(value, 0, 1); }, }), pointCount: defineNodeProperty<"pointCount">({