diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 44e16249..55b7aa28 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -18,6 +18,28 @@ jobs:
env:
BUILD_ONLY: true
+ # Demos embedded in pages are Rust compiled to Wasm; see ./main.yml for the
+ # packaging step that runs on deploy.
+ check-wasm:
+ name: check (wasm)
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+ - name: install stable toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - name: restore cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: ./crates
+ - name: cargo check
+ working-directory: ./crates
+ # --list names the crates that declare a cdylib; --all-targets also
+ # covers each one's trunk-only dev bin.
+ run: cargo check $(./build.sh --list) --all-targets --target wasm32-unknown-unknown --locked
+
# If this fails, consider changing your text or adding something to .typos.toml.
typos:
runs-on: ubuntu-latest
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 6224c4cd..eab15d7d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -12,6 +12,25 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v4
+ - name: install stable toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - name: install wasm-bindgen
+ uses: taiki-e/install-action@v2
+ with:
+ # Must match the wasm-bindgen version resolved in crates/Cargo.lock.
+ tool: wasm-bindgen@0.2.128
+ - name: restore cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: ./crates
+ - name: build and package wasm
+ working-directory: ./crates
+ # Same script contributors run locally, so the two cannot drift. It
+ # builds only the crates that declare a cdylib, leaving native tools in
+ # this workspace alone, and writes into static/ for zola to pick up.
+ run: ./build.sh --release --locked
- name: build_and_deploy
# Also update in README.md and ./check.yml
uses: shalzz/zola-deploy-action@v0.20.0
diff --git a/.gitignore b/.gitignore
index 2217580d..06fda9e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ _site
.sass-cache
.jekyll-metadata
/public
+/static/rust
diff --git a/content/wiki/curves/squircle/apple_vs_superellipse.svg b/content/wiki/curves/squircle/apple_vs_superellipse.svg
new file mode 100644
index 00000000..019af68d
--- /dev/null
+++ b/content/wiki/curves/squircle/apple_vs_superellipse.svg
@@ -0,0 +1,18 @@
+
diff --git a/content/wiki/curves/squircle/index.md b/content/wiki/curves/squircle/index.md
new file mode 100644
index 00000000..6b28a5b8
--- /dev/null
+++ b/content/wiki/curves/squircle/index.md
@@ -0,0 +1,175 @@
++++
+title = "Squircles"
++++
+
+A squircle is a shape intermediate between a square and a circle.
+There is no single mathematical definition, rather there are a number of curves that fit this shape.
+All general squircles are parametrized, and can vary between circle and square.
+
+
+
+
Loading the interactive tester…
+
+
+
+
+
+
+## Superellipse
+
+The best known squircle is the superellipse, popularized by Piet Hein.
+Martin Gardner wrote a detailed narrative of its origin in a [Scientific American column](https://archive.org/details/mathematicalcarn00gard/page/240/mode/2up) in 1977.
+The text is replicated in [https://piethein.com/superellipse/](https://piethein.com/superellipse/) but that’s missing equations and figures.
+
+The superellipse has the formula $|x|^n + |y|^n = 1$ (we’ll use a unit radius for convenience).
+When $n=2$ it is a circle, and it approaches a square asymptotically as $n \rightarrow \infty$.
+
+## Measurement
+
+There is no standard measurement for squircles.
+For superellipses, the exponent is the most common parameter.
+A good choice is the coordinates of the midpoint of the quadrant, assuming a unit radius; here $x$ and $y$ are equal.
+The conversion formula is simple and intuitive: $x = 0.5^{1/n}$.
+This measurement is called "gauge" in the interactive demo above, and is the primary parameter; with the exception of some unreachable regions of parameter space, it should be possible to compare different squircle variants with similar gauge.
+
+## Flat-sided squircles
+
+There are two basic approaches to squircle creation.
+One is a single analytic curve, where curvature reaches zero at the poles, but is otherwise nonzero.
+The other is mixing straight line segments with corners.
+A significant advantage of the latter approach is that it can adapt to rectangles of arbitrary aspect ratio without distorting the corner shape.
+
+## The Apple squircle shape
+
+Squircles received renewed attention when Apple changed the icon shape from rounded rectangle to their own squircle in iOS 7 in 2013.
+There were several blog posts to analyze and recreate the shape.
+An early analysis suggested that it was a superellipse of exponent 5, but when people extracted the Béziers and looked more closely, that was found to be inaccurate.
+Rather, it’s a flat-sided squircle.
+
+For the raw Bézier path data of the Apple shape, the best source is the [PaintCode blog](https://www.paintcodeapp.com/blogpost/code-for-ios-7-rounded-rectangles).
+This blog correctly points out some bugs in that logic, and also has some details on behavior for oval rather than square aspect ratios.
+
+The Apple shape has a number of flaws, including one extraneous straight line segment, and a lack of symmetry.
+However, those flaws are minor, and really only reveal themselves under close analysis.
+
+The Apple shape has no additional parameter, so its variation is controlled entirely by the length of the flat side.
+With no flat side, it has a gauge fixed at 0.809.
+
+An extremely detailed analysis of the Apple shape is in [The Art of Continuous Corners].
+
+## The Figma squircle
+
+Figma published a blog post, [Desperately Seeking Squircles](https://www.figma.com/blog/desperately-seeking-squircles/), with an analysis of the Apple squircle and their own approximation.
+It is influential because Figma is an important design tool, because their writeup was compelling, and because there are any number of open source implementations of it, mostly TypeScript/JavaScript.
+
+ * [squircle-path-kit] from msurguy
+ * [figma-squircle](https://github.com/phamfoo/figma-squircle) from phamfoo
+ + [corner-smoothing](https://github.com/sanalabs/corner-smoothing) from sanalabs (uses figma-squircle)
+ + [squircle-js](https://github.com/bring-shrubbery/squircle-js) from bring-shrubbery (uses figma-squircle)
+ * [figma_squircle](https://github.com/aloisdeniel/figma_squircle) from aloisdeniel (Dart/Flutter)
+ * [Lisse](https://github.com/JaceThings/Lisse) from JaceThings
+
+The Figma blog contains a plot of the Béziers comprising a cleaned up version of iOS 7 rounded rectangle, revealing three Bézier segments per quadrant.
+It fixes the straight-line segment and the asymmetry, so is not an exact match.
+The middle segment is very close to an arc.
+The other segments are more problematic.
+They have zero curvature at the endpoints, so are G2 continuous with flat sides, but there is a curvature discontinuity with the middle (arc) section, and their curvature profile is not especially smooth.
+A reasonable guess is that it was drawn by hand to be approximately smooth.
+
+Without a flat side, the Figma squircle is only capable of a gauge between $\sqrt{0.5}$ (0.707) and 0.854.
+Gauges up to 1 are of course attainable by adding the flat side.
+
+## Clothoid squircles
+
+The Figma blog suggests “smoothed curvature profiles” which have a piecewise linear relationship between arc length and curvature.
+It then goes on to approximate them with cubic Bézier segments, but their approximation has fairly significant curvature discontinuities when joining to the circular arc.
+The clothoid squircle is worth describing explicitly, as it has G2 continuity (as opposed to G1 for the Figma approximation).
+
+The behavior is generally similar to the Figma variant.
+Without a flat side, it is only capable of a gauge between $\sqrt{0.5}$ (0.707) and 0.790.
+
+## The box decorations corner-shape spec
+
+Squircles got a big boost as they’re now standardized in CSS, as the [corner-shape](https://www.w3.org/TR/css-borders-4/#propdef-corner-shape) property of the [box decorations spec].
+These specify real superellipse corners, with additional tweaks and support for animation.
+
+### The Chromium superellipse approximation
+
+While the CSS spec mandates the actual superellipse shape, practical implementations will generally use a Bézier approximation.
+A blog post, [The corner cases of implementing CSS corner-shape in Blink](https://developer.chrome.com/blog/implementing-corner-shape), gives an efficient closed-form approximation, with two Bézier segments per quadrant.
+This formula, determined using symbolic regression, is parametrized, and handles exponents 2 and above well.
+It is exact for placing the midpoint (this is part of the formula), so is well calibrated in that regard.
+
+### Apple-like behavior with the corner-shape spec
+
+The early analysis of the Apple squircle shape as being approximately an exponent 5 superellipse was based on coarse shape only and didn't take into account its flat side.
+A much better match to the Apple shape is attainable using a superellipse with exponent 3 and a corresponding flat side length to match the gauge.
+This has G2 continuity and a similar curvature profile.
+This illustration was made using the Chromium approximation, so it should match the browser.
+
+
+
+Here's the CSS to accomplish the "close to Apple" squircle shape.
+Note that the parameter to the `superellipse()` function is the base-2 log of the exponent, so the value for an exponent of 3 is 1.585.
+
+```css
+.icon {
+ aspect-ratio: 1;
+ border-radius: 46.2%;
+ corner-shape: superellipse(1.585);
+}
+```
+
+## Continuity
+
+A superellipse of exponent $n$ has continuity $G(\lceil n \rceil - 1)$.
+This includes the flat-sided variants, as, for exponent > 2, the endpoint of the quadrant has zero curvature.
+A perfect circle (and indeed, any even integer exponent) without flat sides is of course the exception, as it has an infinitely high order of continuity.
+
+As a general observation, for visual smoothness, the shape should have G2 continuity.
+Of the variants discussed, only the clothoid and superellipse (for n > 2) have this property.
+The Chromium approximation comes close; it doesn't have zero curvature by construction when joining the flat part, but does at the corner join by symmetry (unlike the Figma approximation, which has an additional arc there).
+
+## Other squircles
+
+Quite a number of other curves can be pressed into service as squircles if need be.
+
+* The polynomial spiral (“spiro”) curve can do a reasonable flat-sided squircle with G2 continuity.
+See the “suitcase corners” section of [Raph's thesis], figure 7.2.
+
+* Conic sections (hyperbolas).
+These approximate a sharp corner but are only G1 continuous (if flat sided).
+
+* Fernández-Guasti squircle, defined by $x^2 + y^2 - s^2x^2y^2 = 1$.
+This is used in engineering but likely not in graphic design.
+
+* The [Wikipedia page on squircles](https://en.wikipedia.org/wiki/Squircle) has a “periodic squircle” which has very similar behavior to the Fernández-Guasti one (they are visually near indistinguishable).
+
+## Discussion questions:
+
+The offset curve of a squircle is not a squircle, but (a) it’s close, and (b) concentric squircles might be visually just as appealing or more so; for example a slightly rounded inner corner might look better than a sharp one if the curvature exceeds the stroke half-width.
+This may be a deeper discussion.
+
+The [blurred rounded rectangle approximation](https://raphlinus.github.io/graphics/2020/04/21/blurred-rounded-rects.html) is based on applying shading to a superellipse; each iso-line is in fact a superellipse.
+
+[squircle-path-kit]: https://msurguy.github.io/squircle-path-kit/
+[Raph's thesis]: https://levien.com/phd/phd.html
+[box decorations spec]: https://www.w3.org/TR/css-borders-4/
+[The Art of Continuous Corners]: https://tsuijunxi.github.io/en/2026/06/23/the-art-of-continuous-corners/
diff --git a/crates/.gitignore b/crates/.gitignore
new file mode 100644
index 00000000..77fdd3e5
--- /dev/null
+++ b/crates/.gitignore
@@ -0,0 +1,2 @@
+/target
+dist
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
new file mode 100644
index 00000000..dd731863
--- /dev/null
+++ b/crates/Cargo.lock
@@ -0,0 +1,411 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anymore"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a38750a2a9617d9f1a1428b408fb909f7256a2766c59b0b07993170959731bb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "color"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kurbo"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "smallvec",
+]
+
+[[package]]
+name = "linebender_resource_handle"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "peniko"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3c76095c9a636173600478e0373218c7b955335048c2bcd12dc6a79657649d8"
+dependencies = [
+ "color",
+ "kurbo",
+ "linebender_resource_handle",
+ "smallvec",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+
+[[package]]
+name = "squircle"
+version = "0.1.0"
+dependencies = [
+ "console_error_panic_hook",
+ "wasm-bindgen",
+ "xilem_web",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.128"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "xilem_core"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c4efbae5e6a11d3de577f0ebb673bfc957ee043f8b5a4cc216ffb9cf2619d5b"
+dependencies = [
+ "anymore",
+ "hashbrown",
+ "kurbo",
+ "tracing",
+]
+
+[[package]]
+name = "xilem_web"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ac36aab297215b87411857d1c471252be7eb689238e42da84562e8b44e8e9a"
+dependencies = [
+ "futures",
+ "peniko",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "xilem_core",
+]
diff --git a/crates/Cargo.toml b/crates/Cargo.toml
new file mode 100644
index 00000000..f801cdb0
--- /dev/null
+++ b/crates/Cargo.toml
@@ -0,0 +1,3 @@
+[workspace]
+members = [ 'squircle' ]
+resolver = '2'
diff --git a/crates/README.md b/crates/README.md
new file mode 100644
index 00000000..d8ded6e1
--- /dev/null
+++ b/crates/README.md
@@ -0,0 +1,71 @@
+# Crates
+
+Two kinds of crate live here.
+
+**Web demos** are interactive figures embedded in a page, written with
+[xilem_web](https://github.com/linebender/xilem/tree/main/xilem_web) and
+compiled to Wasm. A crate is treated as one exactly when it declares a
+`cdylib` target, which is the only thing `build.sh` keys off; nothing has
+to be listed anywhere.
+
+**Native tools** are everything else: programs run by hand to generate a
+figure whose output is committed, usually as an SVG under `static/`. They
+are never compiled for Wasm, and the site build ignores them.
+
+| Crate | Kind | Used by |
+| ---------- | -------- | ---------------------------------- |
+| `squircle` | web demo | `content/wiki/curves/squircle.md` |
+
+## Two builds from one crate
+
+A crate has a `cdylib` lib target and a `-dev` bin target, sharing all
+their code through the `rlib`:
+
+* The **lib** is what ships. `wasm-bindgen` turns it into a
+ `.js` / `_bg.wasm` pair in `static/rust/`, which zola copies to
+ the site root, and the page imports `/rust/.js` and calls the crate's
+ `#[wasm_bindgen] pub fn start(id: &str)` to mount it.
+* The **bin** exists only for `trunk serve`. It calls the same `start` with the
+ same element id as the page, so what you see under trunk is what the page
+ gets.
+
+Site builds pass `--lib`, so the dev bin is never compiled in CI, and select
+crates by `cdylib`, so native tools in this workspace are never built for Wasm.
+
+## Iterating
+
+For one demo, with no zola in the way:
+
+```sh
+cd crates/squircle && trunk serve
+```
+
+For the real page, run `zola serve` and rebuild the Wasm beside it:
+
+```sh
+crates/build.sh # once
+cargo watch -w crates -s crates/build.sh # or on every change
+```
+
+`static/rust/` is generated and git-ignored, so `build.sh` has to run at least
+once before `zola serve` will show a demo.
+
+Stylesheets live next to the crate that uses them (`squircle-demo.css`) and are
+copied into `static/rust/` by the same step, so trunk and the page share one
+file. The page supplies the site's theme variables; the trunk `index.html`
+supplies stand-ins for them.
+
+## Adding a crate
+
+Add it to `members` in `Cargo.toml`. If it declares a `cdylib` it is built and
+packaged as a web demo; if it does not, it is left to be run by hand. Either
+way there are no workflow changes to make.
+
+## Versions
+
+The `wasm-bindgen` CLI version must match the `wasm-bindgen` in `Cargo.lock`;
+both are pinned in `.github/workflows/main.yml`.
+
+```sh
+cargo install -f wasm-bindgen-cli --version 0.2.128
+```
diff --git a/crates/build.sh b/crates/build.sh
new file mode 100755
index 00000000..cd837fd4
--- /dev/null
+++ b/crates/build.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Build the web demos into static/rust/, where zola picks them up.
+#
+# crates/build.sh # debug, fast to compile
+# crates/build.sh --release # what CI ships
+# crates/build.sh --list # print the -p flags naming the web demos
+#
+# Any other arguments are passed through to cargo, which is how CI adds
+# --locked.
+#
+# Alongside `zola serve`, for live reload:
+# cargo watch -w crates -s crates/build.sh
+#
+# To iterate on a single demo without zola, use trunk instead:
+# cd crates/squircle && trunk serve
+set -euo pipefail
+# So the globs below expand to nothing rather than to themselves.
+shopt -s nullglob
+
+cd "$(dirname "$0")"
+
+# A crate is a web demo if it declares a cdylib target. Other crates in this
+# workspace are native tools -- figure generators run by hand, whose output is
+# committed as SVG -- and have no business being compiled for wasm.
+demos=()
+for manifest in */Cargo.toml; do
+ grep -qE '^[[:space:]]*crate-type[[:space:]]*=.*"cdylib"' "$manifest" || continue
+ demos+=(-p "$(sed -n 's/^name[[:space:]]*=[[:space:]]*"\(.*\)"/\1/p' "$manifest" | head -1)")
+done
+
+if [[ ${#demos[@]} -eq 0 ]]; then
+ echo "no crate here declares a cdylib target; nothing to build" >&2
+ exit 1
+fi
+
+if [[ "${1:-}" == "--list" ]]; then
+ echo "${demos[@]}"
+ exit 0
+fi
+
+profile_dir=debug
+for arg in "$@"; do
+ [[ "$arg" == "--release" ]] && profile_dir=release
+done
+
+if ! command -v wasm-bindgen >/dev/null; then
+ echo "wasm-bindgen not found. Install the version pinned in ../.github/workflows/main.yml:" >&2
+ echo " cargo install -f wasm-bindgen-cli --version 0.2.128" >&2
+ exit 1
+fi
+
+out=../static/rust
+mkdir -p "$out"
+
+# Stylesheets first, so a CSS-only edit lands before the (no-op) cargo build.
+for css in ./*/*.css; do cp "$css" "$out"; done
+
+# --lib skips each demo's trunk-only dev bin.
+cargo build "${demos[@]}" --lib --target wasm32-unknown-unknown "$@"
+
+for wasm in "target/wasm32-unknown-unknown/$profile_dir"/*.wasm; do
+ # A stale dev bin from a previous `trunk serve` can linger here.
+ case "$(basename "$wasm")" in *-dev.wasm) continue ;; esac
+ echo "packaging $(basename "$wasm")"
+ wasm-bindgen --target web --out-dir "$out" --no-typescript "$wasm"
+done
diff --git a/crates/rustfmt.toml b/crates/rustfmt.toml
new file mode 100644
index 00000000..1488df25
--- /dev/null
+++ b/crates/rustfmt.toml
@@ -0,0 +1,11 @@
+# LINEBENDER RUSTFMT CONFIG - v1
+# Ensure lines end with \n even if the git configuration core.autocrlf is not set to true
+newline_style = "Unix"
+
+# `Foobar { foo, bar }` is more readable than `Foo { foo: foo, bar: bar }`
+use_field_init_shorthand = true
+
+# Commented out because it is still unstable, but works fine in practice.
+# imports_granularity = "Module"
+
+# END LINEBENDER RUSTFMT CONFIG
diff --git a/crates/squircle/Cargo.toml b/crates/squircle/Cargo.toml
new file mode 100644
index 00000000..b24616d4
--- /dev/null
+++ b/crates/squircle/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "squircle"
+version = "0.1.0"
+description = "Interactive squircle tester for the Linebender wiki"
+keywords = ["graphics", "curve", "geometry", "squircle"]
+categories = ["graphics"]
+edition = "2024"
+license = "MIT OR Apache-2.0"
+publish = false
+
+[lib]
+# cdylib is what ships: wasm-bindgen turns it into the /rust/squircle.js the
+# wiki page imports. rlib is what lets the dev bin below link against the same
+# code, so `trunk serve` exercises the exact path the site does.
+crate-type = ["cdylib", "rlib"]
+
+[[bin]]
+# Local iteration only, via `trunk serve`; the site build never compiles it.
+# The name must differ from the lib's, or the two targets collide on
+# squircle.wasm. The `-dev` suffix is also what the packaging step skips.
+name = "squircle-dev"
+path = "src/main.rs"
+
+[dependencies]
+console_error_panic_hook = "0.1.7"
+# The wasm-bindgen CLI version pinned in .github/workflows/main.yml must match
+# the version resolved here; bump both together.
+wasm-bindgen = "0.2"
+xilem_web = "0.4"
diff --git a/crates/squircle/index.html b/crates/squircle/index.html
new file mode 100644
index 00000000..59fbd8b2
--- /dev/null
+++ b/crates/squircle/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Squircle tester
+
+
+
+
+
+
+
+
+
diff --git a/crates/squircle/squircle-demo.css b/crates/squircle/squircle-demo.css
new file mode 100644
index 00000000..0ffc3945
--- /dev/null
+++ b/crates/squircle/squircle-demo.css
@@ -0,0 +1,158 @@
+/* Styling for the squircle tester.
+ *
+ * Shared by two pages: content/wiki/curves/squircle.md, which links the copy
+ * that the build places in static/rust/, and crates/squircle/index.html, which
+ * links this file directly under `trunk serve`. Editing it here updates both.
+ *
+ * Colors come from custom properties rather than literals so the demo follows
+ * the site's light and dark themes; --fg2-color is the site's secondary text
+ * color, with a fallback for the standalone page.
+ */
+
+#squircle-demo-root {
+ /* One place to set the palette, so the SVG paths can never drift apart. */
+ --squircle-shape: #0b5fbd;
+ --squircle-grid: #e0e0e0;
+ --squircle-axis: #8a8a8a;
+
+ margin: 1.5rem 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ #squircle-demo-root {
+ --squircle-shape: #79b0f2;
+ --squircle-grid: #4a4a4a;
+ --squircle-axis: #909090;
+ }
+}
+
+#squircle-demo-root .squircle-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 1.25rem;
+ margin-bottom: 1rem;
+}
+
+#squircle-demo-root .squircle-choices {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.25rem 0.9rem;
+}
+
+#squircle-demo-root .squircle-control-label {
+ /* Full width inside its flex row, so the label sits on its own line and the
+ radios wrap underneath it as a block rather than trailing after it. */
+ flex: 1 0 100%;
+ color: var(--fg2-color, #767676);
+ font-size: 0.85rem;
+ white-space: nowrap;
+}
+
+#squircle-demo-root .squircle-slider .squircle-control-label {
+ flex: 0 0 auto;
+}
+
+#squircle-demo-root .squircle-choice {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ white-space: nowrap;
+}
+
+#squircle-demo-root .squircle-slider {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+#squircle-demo-root .squircle-slider input[type="range"] {
+ flex: 1 1 180px;
+ max-width: 260px;
+ accent-color: var(--squircle-shape);
+}
+
+#squircle-demo-root .squircle-readout {
+ font-family: ui-monospace, monospace;
+ font-variant-numeric: tabular-nums;
+ min-width: 5ch;
+}
+
+#squircle-demo-root .squircle-panels {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 1.5rem;
+}
+
+/* The shape is square, so it is held to a sensible width and the wider
+ curvature plot takes the remaining space. Below roughly 700px the two
+ panels stack instead. */
+#squircle-demo-root .squircle-panel {
+ flex: 1 1 320px;
+ min-width: 0;
+}
+
+/* The shape is square and carries far less detail than the plot, so it is held
+ to a modest width; otherwise it towers over the plot beside it and dwarfs the
+ text when the two panels stack. */
+#squircle-demo-root .squircle-panel:first-child {
+ flex: 0 1 300px;
+ max-width: 300px;
+}
+
+/* A viewBox with no width or height attribute gives the SVG its aspect ratio
+ from the viewBox, so this scales to the column without overflowing it. */
+#squircle-demo-root .squircle-figure {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+#squircle-demo-root .squircle-path {
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+#squircle-demo-root .squircle-path--shape,
+#squircle-demo-root .squircle-path--curvature {
+ fill: none;
+ stroke: var(--squircle-shape);
+}
+
+#squircle-demo-root .squircle-path--fill {
+ fill: var(--squircle-shape);
+ stroke: none;
+}
+
+#squircle-demo-root .squircle-grid {
+ fill: none;
+ stroke: var(--squircle-grid);
+}
+
+#squircle-demo-root .squircle-axis-label {
+ fill: var(--fg2-color, #767676);
+ font-size: 14px;
+ text-anchor: end;
+}
+
+#squircle-demo-root .squircle-axes {
+ fill: none;
+ stroke: var(--squircle-axis);
+}
+
+#squircle-demo-root .squircle-fallback {
+ margin-top: 0.5rem;
+ color: var(--fg2-color, #767676);
+ font-size: 0.85rem;
+}
+
+#squircle-demo-root .squircle-slider input[type="range"]:disabled {
+ opacity: 0.4;
+}
+
+/* A readout that does not apply to the current construction. */
+#squircle-demo-root .squircle-muted {
+ opacity: 0.4;
+}
diff --git a/crates/squircle/src/apple_squircle.rs b/crates/squircle/src/apple_squircle.rs
new file mode 100644
index 00000000..f6fb20fd
--- /dev/null
+++ b/crates/squircle/src/apple_squircle.rs
@@ -0,0 +1,86 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! The rounded rectangle Apple introduced in iOS 7.
+//!
+//! Unlike the other constructions here this one is not derived from a formula.
+//! It is Apple's own control points, recovered by PaintCode by walking the live
+//! `CGPath` with `CGPathApply` and published at
+//! .
+//! The numbers are the system's, not a fit to a picture of it.
+//!
+//! The published path takes a corner radius, which it clamps to a limit of
+//! `min(width, height) / 2 / 1.52866483`. This renders the square aspect ratio
+//! at exactly that limit, which is where the flat sides vanish and the corner
+//! curve runs from one edge midpoint to the next, so the shape is comparable
+//! with the others. Shorter radii leave a straight run along each side; that
+//! flat length is the parameter this construction really has, and it is not
+//! wired up yet, so [`Squircle::render`] ignores the gauge.
+//!
+//! A quadrant is four segments: a cubic, a short straight run, and two more
+//! cubics. It is not symmetric about the corner's diagonal -- the straight run
+//! sits on one side of it -- and it is not curvature continuous: curvature
+//! drops to zero across the straight run, and steps from 1.638 to 1.095 at the
+//! join between the last two cubics. The tangent turns by about 4.5 and 6.2
+//! degrees at the straight run's ends and 2.5 degrees at that last join.
+//!
+//! Those are properties of Apple's shape, not of the transcription, which is
+//! what makes this one worth having beside constructions that are smooth by
+//! design. Reproduce the path as published; do not tidy it.
+
+use xilem_web::svg::kurbo::{BezPath, Point};
+
+use crate::squircle::Squircle;
+
+pub struct AppleSquircle;
+
+/// Where this corner crosses its own diagonal.
+///
+/// Measured from the control points below by bisecting the middle cubic for
+/// x == y. The shape has no free parameter, so this is a fixed property of the
+/// data; it is what makes the gauge a readout rather than a control here.
+pub const CORNER_GAUGE: f64 = 0.80931;
+
+/// Half the side length, as a multiple of the corner radius.
+///
+/// The published code uses this constant to clamp the radius, and also as the
+/// coordinate of the point where each corner's curve begins. It writes that
+/// second use as 1.52866471 in places, which looks like rounding in whatever
+/// produced the listing; using the one value throughout puts the quadrant's
+/// ends exactly on the edge midpoints rather than a hundred-millionth off.
+const HALF_SIDE_IN_RADII: f64 = 1.52866483;
+
+/// A control point, in the units the published code uses.
+///
+/// Its coordinates are multiples of the corner radius, measured inwards from
+/// the corner. This takes the bottom right corner, which is the one whose
+/// published order already runs from one edge midpoint to the next in the
+/// direction the other constructions use, and scales it so the half side
+/// length is 1 and the shape's centre is the origin.
+fn pt(x: f64, y: f64) -> Point {
+ Point::new(
+ 1.0 - x / HALF_SIDE_IN_RADII,
+ 1.0 - y / HALF_SIDE_IN_RADII,
+ )
+}
+
+impl Squircle for AppleSquircle {
+ fn render(&self, _params: &[f64]) -> BezPath {
+ const K: f64 = HALF_SIDE_IN_RADII;
+ let mut result = BezPath::new();
+ result.move_to(pt(0.0, K));
+ result.curve_to(
+ pt(0.0, 1.08849323),
+ pt(0.0, 0.86840689),
+ pt(0.06549569, 0.66993493),
+ );
+ result.line_to(pt(0.07491111, 0.63149399));
+ result.curve_to(
+ pt(0.16905883, 0.37282392),
+ pt(0.37282392, 0.16905883),
+ pt(0.63149399, 0.07491111),
+ );
+ result.curve_to(pt(0.86840689, 0.0), pt(1.08849323, 0.0), pt(K, 0.0));
+ result
+ }
+}
diff --git a/crates/squircle/src/clothoid_squircle.rs b/crates/squircle/src/clothoid_squircle.rs
new file mode 100644
index 00000000..979d4bac
--- /dev/null
+++ b/crates/squircle/src/clothoid_squircle.rs
@@ -0,0 +1,89 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, FRAC_PI_4};
+
+use xilem_web::svg::kurbo::{BezPath, Point, Vec2};
+
+use crate::{euler::{EulerParams, EulerSeg}, squircle::{ProfileSample, Squircle}};
+
+pub struct ClothoidSquircle;
+
+// The `a` parameter controls smoothness and varies from 0 (circular arc)
+// to 1 (two clothoids back to back, no arc segment).
+//
+// Unscaled arc length is pi/2 (1 + a), but this scales the radius,
+// so it must be scaled back to a radius of 1.
+
+struct ClothoidStuff {
+ th: f64,
+ params: EulerParams,
+ xy: Vec2,
+}
+
+impl ClothoidStuff {
+ fn new(a: f64) -> Self {
+ let th = FRAC_PI_4 * a;
+ let params = EulerParams::from_k0_k1(th, 2.0 * th);
+ let xy_raw = Vec2::from_angle(-params.th(0.0)).rotate_scale(params.eval(1.0, 1e-12).to_vec2());
+ // xy coordinate of Euler spiral segment, starting at 0, 0.
+ let xy = (2.0 * th) * xy_raw;
+ Self { th, params, xy }
+ }
+
+ /// Reciprocal of cale factor to bring unscaled shape to radius of 1
+ fn inv_scale(&self) -> f64 {
+ let xy = Vec2::from_angle(-self.params.th(0.0)).rotate_scale(self.params.eval(1.0, 1e-12).to_vec2());
+ // xy coordinate of Euler spiral segment, starting at 0, 0.
+ let xy_euler = (2.0 * self.th) * xy;
+ let (s_arc, c_arc) = self.th.sin_cos();
+ let x_center = xy_euler.x + FRAC_1_SQRT_2 - s_arc;
+ let y_center = xy_euler.y + c_arc - FRAC_1_SQRT_2;
+ x_center + y_center
+ }
+}
+
+impl Squircle for ClothoidSquircle {
+ fn render(&self, params: &[f64]) -> xilem_web::svg::kurbo::BezPath {
+ const ACCURACY: f64 = 1e-4;
+ // hacky thing, probably better to do this upstream
+ let a = params[0];
+ let a = (a - 0.707) / (1.0 - 0.707);
+ let stuff = ClothoidStuff::new(a);
+ let scale = 1.0 / stuff.inv_scale();
+ let p0 = Point::new(1.0, 0.0);
+ let p1 = Point::new(1.0 - scale * stuff.xy.y, scale * stuff.xy.x);
+ let params = EulerParams::from_k0_k1(-stuff.th, -2.0 * stuff.th);
+ let seg = EulerSeg::from_params(p0, p1, params);
+ let mut result = BezPath::new();
+ result.move_to(p0);
+ result.extend(seg.to_cubics(ACCURACY));
+ let arc_params = EulerParams::from_k0_k1(-(FRAC_PI_2 - 2.0 * stuff.th), 0.0);
+ let p2 = Point::new(p1.y, p1.x);
+ let arc_seg2 = EulerSeg::from_params(p1, p2, arc_params);
+ result.extend(arc_seg2.to_cubics(ACCURACY));
+ let params2 = EulerParams::from_k0_k1(-stuff.th, 2.0 * stuff.th);
+ let p4 = Point::new(0.0, 1.0);
+ let seg2 = EulerSeg::from_params(p2, p4, params2);
+ result.extend(seg2.to_cubics(ACCURACY));
+ // It would be better to fix the above so it's the right direction, but I'm lazy
+ result
+ }
+
+ fn curvature_profile(&self, params: &[f64]) -> Vec {
+ let a = params[0];
+ // hacky thing, probably better to do this upstream
+ let a = (a - 0.707) / (1.0 - 0.707);
+ let stuff = ClothoidStuff::new(a);
+ let inv_scale = stuff.inv_scale();
+ let scale = 1.0 / inv_scale;
+ let mut result = vec![];
+ let es_s = scale * FRAC_PI_2 * a;
+ let arc_s = scale * FRAC_PI_2 * (1.0 - a);
+ result.push(ProfileSample::new(0.0, 0.0));
+ result.push(ProfileSample::new(es_s, inv_scale));
+ result.push(ProfileSample::new(es_s + arc_s, inv_scale));
+ result.push(ProfileSample::new(2.0 * es_s + arc_s, 0.0));
+ result
+ }
+}
diff --git a/crates/squircle/src/euler.rs b/crates/squircle/src/euler.rs
new file mode 100644
index 00000000..e164ebf0
--- /dev/null
+++ b/crates/squircle/src/euler.rs
@@ -0,0 +1,482 @@
+// Copyright 2021 The kurbo Authors.
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! A general kurbo implementation of Euler spirals.
+//! This file is lightly adapted from kurbo#169, which never got merged.
+
+#![allow(unused)]
+
+use xilem_web::svg::kurbo::{
+ Affine, CubicBez, Line, ParamCurve, ParamCurveArclen, ParamCurveCurvature, ParamCurveDeriv,
+ PathEl, Point, Vec2,
+};
+
+/// An Euler spiral segment.
+///
+/// This is only enabled when the `euler` feature is selected.
+#[derive(Clone, Copy, Debug)]
+pub struct EulerSeg {
+ p0: Point,
+ p1: Point,
+ params: EulerParams,
+}
+
+/// The derivative of an Euler spiral segment.
+#[derive(Clone, Copy)]
+pub struct EulerSegDeriv {
+ c0: f64,
+ c1: f64,
+ c2: f64,
+ scale: f64,
+}
+
+/// The second derivative of an Euler spiral segment.
+pub struct EulerSegDeriv2(EulerSegDeriv);
+
+/// Parameters for an Euler spiral segment. Does not include endpoint geometry.
+///
+/// This is something of an internal detail for [`EulerSeg`] and might not make
+/// it to the public interface. It's public here for experimentation.
+///
+/// It's entirely possible the disposition of this is to be inlined into `EulerSeg`.
+/// I'm not sure it's useful by itself.
+#[derive(Clone, Copy, Debug)]
+pub struct EulerParams {
+ k0: f64,
+ k1: f64,
+ chord: f64,
+ chth: f64,
+}
+
+/// A path consisting of piecewise Euler spiral segments.
+///
+/// TODO: develop this further, including implementing the [`Shape`][crate::Shape] trait.
+///
+/// This is only enabled when the `euler` feature is selected.
+pub struct EulerPath(Vec);
+
+/// An element of a piecewise Euler spiral path.
+#[derive(Clone, Copy, Debug)]
+pub enum EulerPathEl {
+ /// Start a new subpath at the given point.
+ MoveTo(Point),
+ /// A line segment to the given point.
+ LineTo(Point),
+ /// An Euler spiral segment to the given point.
+ EulerTo(EulerParams, Point),
+ /// Close the subpath.
+ ClosePath,
+}
+
+/// An iterator producing euler segments from a cubic bezier.
+///
+/// Discussion: should this be an anonymous (`from_fn`) type?
+pub struct CubicToEulerIter {
+ c: CubicBez,
+ tolerance: f64,
+ // [t0 * dt .. (t0 + 1) * dt] is the range we're
+ // currently considering.
+ t0: u64,
+ dt: f64,
+}
+
+fn integ_euler_12(k0: f64, k1: f64) -> (f64, f64) {
+ let t1_1 = k0;
+ let t1_2 = 0.5 * k1;
+ let t2_2 = t1_1 * t1_1;
+ let t2_3 = 2. * (t1_1 * t1_2);
+ let t2_4 = t1_2 * t1_2;
+ let t3_4 = t2_2 * t1_2 + t2_3 * t1_1;
+ let t3_6 = t2_4 * t1_2;
+ let t4_4 = t2_2 * t2_2;
+ let t4_5 = 2. * (t2_2 * t2_3);
+ let t4_6 = 2. * (t2_2 * t2_4) + t2_3 * t2_3;
+ let t4_7 = 2. * (t2_3 * t2_4);
+ let t4_8 = t2_4 * t2_4;
+ let t5_6 = t4_4 * t1_2 + t4_5 * t1_1;
+ let t5_8 = t4_6 * t1_2 + t4_7 * t1_1;
+ let t5_10 = t4_8 * t1_2;
+ let t6_6 = t4_4 * t2_2;
+ let t6_7 = t4_4 * t2_3 + t4_5 * t2_2;
+ let t6_8 = t4_4 * t2_4 + t4_5 * t2_3 + t4_6 * t2_2;
+ let t6_9 = t4_5 * t2_4 + t4_6 * t2_3 + t4_7 * t2_2;
+ let t6_10 = t4_6 * t2_4 + t4_7 * t2_3 + t4_8 * t2_2;
+ let t7_8 = t6_6 * t1_2 + t6_7 * t1_1;
+ let t7_10 = t6_8 * t1_2 + t6_9 * t1_1;
+ let t8_8 = t6_6 * t2_2;
+ let t8_9 = t6_6 * t2_3 + t6_7 * t2_2;
+ let t8_10 = t6_6 * t2_4 + t6_7 * t2_3 + t6_8 * t2_2;
+ let t9_10 = t8_8 * t1_2 + t8_9 * t1_1;
+ let t10_10 = t8_8 * t2_2;
+ let mut u = 1.;
+ u -= (1. / 24.) * t2_2 + (1. / 160.) * t2_4;
+ u += (1. / 1920.) * t4_4 + (1. / 10752.) * t4_6 + (1. / 55296.) * t4_8;
+ u -= (1. / 322560.) * t6_6 + (1. / 1658880.) * t6_8 + (1. / 8110080.) * t6_10;
+ u += (1. / 92897280.) * t8_8 + (1. / 454164480.) * t8_10;
+ u -= 2.4464949595157930e-11 * t10_10;
+ let mut v = (1. / 12.) * t1_2;
+ v -= (1. / 480.) * t3_4 + (1. / 2688.) * t3_6;
+ v += (1. / 53760.) * t5_6 + (1. / 276480.) * t5_8 + (1. / 1351680.) * t5_10;
+ v -= (1. / 11612160.) * t7_8 + (1. / 56770560.) * t7_10;
+ v += 2.4464949595157932e-10 * t9_10;
+ (u, v)
+}
+
+#[doc(hidden)]
+/// Computation of the Euler spiral integral using subdivision.
+pub fn integ_euler_12n(mut k0: f64, mut k1: f64, n: usize) -> (f64, f64) {
+ let th1 = k0;
+ let th2 = 0.5 * k1;
+ let ds = (n as f64).recip();
+
+ k0 *= ds;
+ k1 *= ds;
+
+ let mut x = 0.0;
+ let mut y = 0.0;
+ let s0 = 0.5 * ds - 0.5;
+
+ for i in 0..n {
+ let s = s0 + ds * (i as f64);
+ let km0 = k1 * s + k0;
+ let km1 = k1 * ds;
+
+ let (u, v) = integ_euler_12(km0, km1);
+
+ let th = (th2 * s + th1) * s;
+ let cth = th.cos();
+ let sth = th.sin();
+
+ x += cth * u - sth * v;
+ y += cth * v + sth * u;
+ }
+ (x * ds, y * ds)
+}
+
+/// Evaluate the Euler spiral integral.
+///
+/// Compute the following integral to the desired accuracy.
+///
+/// $$
+/// \int_{-0.5}^{0.5} \exp(i(k_0 s + 1/2 k_1 s^2)) ds
+/// $$
+///
+/// This is discussed in section 8.1 of [Raph's thesis], and the error bounds
+/// are validated in the notebook attached to the parallel curve blog post.
+///
+/// [Raph's thesis]: https://www.levien.com/phd/thesis.pdf
+pub fn integ_euler(k0: f64, k1: f64, accuracy: f64) -> (f64, f64) {
+ let c1 = k1.abs();
+ let c0 = k0.abs() + 0.5 * c1;
+ let est_err_raw = 0.006 * c0 * c0 + 0.029 * c1;
+ // Fun performance note: if the accuracy were always known at compile time,
+ // it would be theoretically cheaper to compare against accuracy^(1/6), which
+ // is computed anyway in the subdivision case. But the cost of the powi(6) is
+ // basically not measurable, and the cost of the ^(1/6) is ballpark double
+ // the integration itself.
+ if est_err_raw.powi(6) < accuracy {
+ integ_euler_12(k0, k1)
+ } else {
+ let n = (est_err_raw / accuracy.powf(1.0 / 6.0)).ceil() as usize;
+ integ_euler_12n(k0, k1, n)
+ }
+}
+
+impl EulerParams {
+ /// Find the Euler spiral parameters for the given deflection.
+ ///
+ /// TODO: use research for direct solution.
+ ///
+ /// Discussion question: should this take an accuracy parameter?
+ /// This version basically hardcodes 1e-9.
+ pub fn fit_euler(th0: f64, th1: f64) -> EulerParams {
+ // Note: we could skip the solving for very small deflection
+ let mut k1_old = 0.0;
+ let dth = th1 - th0;
+ let k0 = th0 + th1;
+ let mut k1 = (6.0 - (1. / 70.) * dth * dth - 0.1 * k0 * k0) * dth;
+ let mut error_old = dth;
+ for _ in 0..10 {
+ let (u, v) = integ_euler(k0, k1, 1e-12);
+ let chth = v.atan2(u);
+ let error = dth - (0.25 * k1 - 2.0 * chth);
+ if error.abs() < 1e-9 {
+ let chord = u.hypot(v);
+ return EulerParams {
+ k0,
+ k1,
+ chord,
+ chth,
+ };
+ }
+ let new_k1 = k1 + (k1_old - k1) * error / (error - error_old);
+ k1_old = k1;
+ error_old = error;
+ k1 = new_k1;
+ }
+ panic!("fit_euler diverged on {}, {}", th0, th1);
+ }
+
+ /// Create `EulerParams` from k0 and k1 parameters.
+ pub fn from_k0_k1(k0: f64, k1: f64) -> EulerParams {
+ let (u, v) = integ_euler(k0, k1, 1e-12);
+ let chth = v.atan2(u);
+ let chord = u.hypot(v);
+ EulerParams {
+ k0,
+ k1,
+ chord,
+ chth,
+ }
+ }
+
+ /// Determine tangent angle at the given parameter.
+ ///
+ /// The sign may be confusing, but it matches the spiro code. When `t = 0`,
+ /// the result is `-th0`, and when `t = 1`, the result is `th1`.
+ pub fn th(&self, t: f64) -> f64 {
+ let u = t - 0.5;
+ (0.5 * self.k1 * u + self.k0) * u - self.chth
+ }
+
+ /// Evaluate the curve at the given parameter.
+ ///
+ /// The parameter is in the range 0..1, and the result goes from (0, 0) to (1, 0).
+ pub fn eval(&self, t: f64, accuracy: f64) -> Point {
+ let th_m = self.th(t * 0.5);
+ let k0 = self.k0;
+ let k1 = self.k1;
+ let (u, v) = integ_euler((k0 + k1 * 0.5 * (t - 1.0)) * t, k1 * t * t, accuracy);
+ let s = t / self.chord * th_m.sin();
+ let c = t / self.chord * th_m.cos();
+ let x = u * c - v * s;
+ let y = -v * c - u * s;
+ Point::new(x, y)
+ }
+}
+
+impl EulerSeg {
+ /// Create a new Euler segment.
+ ///
+ /// TODO: document the conventions. An SVG would be especially nice.
+ pub fn new(p0: Point, p1: Point, th0: f64, th1: f64) -> EulerSeg {
+ let params = EulerParams::fit_euler(th0, th1);
+ EulerSeg { p0, p1, params }
+ }
+
+ /// Create an Euler segment from a cubic Bézier.
+ ///
+ /// The curve is fit according to G1 geometric Hermite interpolation, in
+ /// other words the endpoints and tangents match the given curve.
+ pub fn from_cubic(c: CubicBez) -> EulerSeg {
+ let d01 = c.p1 - c.p0;
+ let d23 = c.p3 - c.p2;
+ let d03 = c.p3 - c.p0;
+ let th0 = d03.cross(d01).atan2(d03.dot(d01));
+ let th1 = d23.cross(d03).atan2(d23.dot(d03));
+ let params = EulerParams::fit_euler(th0, th1);
+ EulerSeg {
+ p0: c.p0,
+ p1: c.p3,
+ params,
+ }
+ }
+
+ /// Create a segment from params and endpoints.
+ ///
+ /// Mostly used for experimentation.
+ #[doc(hidden)]
+ pub fn from_params(p0: Point, p1: Point, params: EulerParams) -> EulerSeg {
+ EulerSeg { p0, p1, params }
+ }
+
+ /// Report whether the segment is a straight line.
+ pub fn is_line(&self) -> bool {
+ self.params.k0 == 0.0 && self.params.k1 == 0.0
+ }
+
+ /// Convert to cubic beziers.
+ pub fn to_cubics(&self, accuracy: f64) -> impl Iterator {
+ let this = *self;
+ let mut t0_int = 0usize;
+ let mut dt = 1.0;
+ let mut p0 = self.p0;
+ let chord_atan = (self.p1 - self.p0).atan2();
+ let thresh = accuracy * self.params.chord / (self.p1 - self.p0).hypot();
+ std::iter::from_fn(move || {
+ let t0 = (t0_int as f64) * dt;
+ if t0 == 1.0 {
+ return None;
+ }
+ loop {
+ let t1 = t0 + dt;
+ let k0 = dt * (this.params.k0 + 0.5 * (t0 + t1 - 1.0) * this.params.k1);
+ let k1 = dt * dt * this.params.k1;
+ let a0 = k0.abs();
+ let a1 = k1.abs();
+ // Error metric empirically determined, using `fit_cubic_plot` in example.
+ let err = 1.5e-5 * a0.powi(5)
+ + 6e-4 * a0 * a0 * a1
+ + 1e-4 * a0 * a1 * a1
+ + 3e-6 * a1.powi(3);
+ // TODO: scale error by arc length
+ if err * dt <= thresh {
+ let p1 = if t1 == 1.0 { this.p1 } else { this.eval(t1) };
+
+ let dp = p1 - p0;
+ // Transform to take (0, 0) - (1, 0) chord to p0 - p1.
+ let a = Affine::new([dp.x, dp.y, -dp.y, dp.x, p0.x, p0.y]);
+
+ // Note: it's possible to this with rotation and normalization,
+ // avoiding the trig.
+ let d_atan = chord_atan - dp.atan2();
+ let th0 = d_atan - this.params.th(t0);
+ let th1 = -d_atan + this.params.th(t1);
+ let v0 = Vec2::from_angle(th0);
+ let c0 = Point::new(0., 0.);
+ let c1 = c0 + 2. / 3. / (1. + v0.x) * v0;
+ let c3 = Point::new(1., 0.);
+ let v1 = Vec2::from_angle(-th1);
+ let c2 = c3 - 2. / 3. / (1. + v1.x) * v1;
+
+ // Advance subdivision parameters
+ t0_int += 1;
+ let shift = t0_int.trailing_zeros();
+ t0_int >>= shift;
+ dt *= (1 << shift) as f64;
+ p0 = p1;
+
+ return Some(PathEl::CurveTo(a * c1, a * c2, p1));
+ }
+ t0_int *= 2;
+ dt *= 0.5;
+ }
+ })
+ }
+
+}
+
+
+impl ParamCurve for EulerSeg {
+ fn eval(&self, t: f64) -> Point {
+ // The accuracy here is somewhat arbitrary, but should be adequate
+ // for most work, and not entail loss of efficiency.
+ let Point { x, y } = self.params.eval(t, 1e-9);
+ let chord = self.p1 - self.p0;
+ Point::new(
+ self.p0.x + chord.x * x - chord.y * y,
+ self.p0.y + chord.x * y + chord.y * x,
+ )
+ }
+
+ fn subsegment(&self, range: std::ops::Range) -> Self {
+ let p0 = self.eval(range.start);
+ let p1 = self.eval(range.end);
+ let dt = range.end - range.start;
+ let k0 = dt * (self.params.k0 + 0.5 * (range.start + range.end - 1.0) * self.params.k1);
+ let k1 = dt * dt * self.params.k1;
+ let params = EulerParams::from_k0_k1(k0, k1);
+ EulerSeg { p0, p1, params }
+ }
+
+ fn start(&self) -> Point {
+ self.p0
+ }
+
+ fn end(&self) -> Point {
+ self.p1
+ }
+}
+
+impl ParamCurveArclen for EulerSeg {
+ /// The arc length of the curve.
+ ///
+ /// Note that this implementation is fast and accurate.
+ fn arclen(&self, _accuracy: f64) -> f64 {
+ (self.p1 - self.p0).hypot() / self.params.chord
+ }
+
+ /// The parameter that results in the given arc length.
+ ///
+ /// This implementation is also fast and accurate.
+ fn inv_arclen(&self, arclen: f64, _accuracy: f64) -> f64 {
+ arclen * self.params.chord / (self.p1 - self.p0).hypot()
+ }
+}
+
+impl ParamCurveDeriv for EulerSeg {
+ type DerivResult = EulerSegDeriv;
+
+ fn deriv(&self) -> Self::DerivResult {
+ let EulerParams { k0, k1, chth, .. } = self.params;
+ EulerSegDeriv {
+ c0: 0.5 * k0 - 0.125 * k1 + chth + (self.p1 - self.p0).atan2(),
+ c1: -k0 + 0.5 * k1,
+ c2: -0.5 * k1,
+ scale: self.arclen(0.0),
+ }
+ }
+}
+
+impl ParamCurveCurvature for EulerSeg {
+ fn curvature(&self, t: f64) -> f64 {
+ (self.params.k0 + (t - 0.5) * self.params.k1) * self.params.chord
+ / (self.p1 - self.p0).hypot()
+ }
+}
+
+impl ParamCurve for EulerSegDeriv {
+ fn eval(&self, t: f64) -> Point {
+ let theta = self.c0 + t * self.c1 + t * t * self.c2;
+ (self.scale * Vec2::from_angle(theta)).to_point()
+ }
+
+ fn subsegment(&self, range: std::ops::Range) -> Self {
+ let t0 = range.start;
+ let t1 = range.end;
+ let dt = t1 - t0;
+ EulerSegDeriv {
+ c0: self.c0 + t0 * self.c1 + t0 * t0 * self.c2,
+ c1: dt * (self.c1 + t0 * self.c2),
+ c2: dt * dt * self.c2,
+ scale: dt * self.scale,
+ }
+ }
+}
+
+impl ParamCurveDeriv for EulerSegDeriv {
+ type DerivResult = EulerSegDeriv2;
+
+ fn deriv(&self) -> Self::DerivResult {
+ EulerSegDeriv2(*self)
+ }
+}
+
+impl ParamCurve for EulerSegDeriv2 {
+ fn eval(&self, t: f64) -> Point {
+ let p = self.0.eval(t);
+ let scale = self.0.c1 + 2.0 * t * self.0.c2;
+ Point::new(-p.y * scale, p.x * scale)
+ }
+
+ fn subsegment(&self, range: std::ops::Range) -> Self {
+ EulerSegDeriv2(self.0.subsegment(range))
+ }
+}
+
+// TODO: other ParamCurve traits.
+
+impl From for EulerSeg {
+ fn from(l: Line) -> EulerSeg {
+ EulerSeg {
+ p0: l.p0,
+ p1: l.p1,
+ params: EulerParams {
+ k0: 0.,
+ k1: 0.,
+ chord: 1.,
+ chth: 0.,
+ },
+ }
+ }
+}
diff --git a/crates/squircle/src/figma_squircle.rs b/crates/squircle/src/figma_squircle.rs
new file mode 100644
index 00000000..4b10428b
--- /dev/null
+++ b/crates/squircle/src/figma_squircle.rs
@@ -0,0 +1,139 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! An implementation of the Figma squircle
+//! This is adapted from squircle-path-kit.
+
+use std::f64::consts::PI;
+
+use xilem_web::svg::kurbo::{Affine, BezPath, CubicBez, Point, Vec2};
+
+use crate::{euler::{EulerParams, EulerSeg}, squircle::Squircle};
+
+pub struct FigmaSquircle;
+
+// Only implements the "squircle" type
+struct Corner {
+ start_point: Point,
+ end_point: Point,
+ in_bezier: CubicBez,
+ // original has arc segments, but we'll use Euler
+ reduced_sweep: f64,
+ out_bezier: CubicBez,
+}
+
+fn compute_corner(prev: Point, curr: Point, next: Point, radius: f64, smoothness: f64, budget: f64) -> Corner {
+ let dir_in = (prev - curr).normalize();
+ let dir_out = (next - curr).normalize();
+ let d = dir_in.dot(dir_out).min(1.0).max(-1.0);
+ let phi = d.acos();
+ let half_phi = 0.5 * phi;
+
+ // logic elided to result in sharp corner
+ let sin_half = half_phi.sin();
+ let tan_half = half_phi.tan();
+
+ let mut q = radius / tan_half;
+ let mut xi = smoothness.min(1.0).max(0.0);
+
+ if q > budget {
+ q = budget;
+ xi = 0.0;
+ } else {
+ let p = (1.0 + xi) * q;
+ if p > budget {
+ xi = budget / (q - 1.0);
+ }
+ }
+ let p = (1.0 + xi) * q;
+ let effective_radius = q * tan_half;
+ let bisector = (dir_in + dir_out).normalize();
+ let center = curr + (effective_radius / sin_half) * bisector;
+
+ let tangent_in = curr + q * dir_in;
+ let tangent_out = curr + q * dir_out;
+
+ let radial_in = (tangent_in - center).normalize();
+ // We don't use this because we only do one direction, and transform later.
+ let _is_ccw = radial_in.cross(dir_in) > 0.0;
+
+ let start_angle = radial_in.atan2();
+ let radial_out = (tangent_out - center).normalize();
+ let end_angle = radial_out.atan2();
+
+ let sweep = end_angle - start_angle;
+ // TODO: modulo 2pi, respecting is_ccw
+
+ let turn = PI - phi;
+ let beta = (turn * 0.5) * xi;
+ let t = effective_radius * (beta * 0.5).tan();
+
+ let a_plus_b = p - (q - t);
+ let b = a_plus_b / 3.0;
+ let a = 2.0 * b;
+
+ let reduced_sweep = sweep * (1.0 - xi);
+ let mid_angle = start_angle + sweep * 0.5;
+ let r_start = mid_angle - reduced_sweep * 0.5;
+ let r_end = r_start + reduced_sweep;
+
+ let arc_start_pt = center + effective_radius * Vec2::from_angle(r_start);
+ let arc_end_pt = center + effective_radius * Vec2::from_angle(r_end);
+
+ let start_point = curr + p * dir_in;
+ let end_point = curr + p * dir_out;
+ let in_bezier = CubicBez::new(start_point, curr + (p - a) * dir_in, curr + (q - t) * dir_in, arc_start_pt);
+ let out_bezier = CubicBez::new(arc_end_pt, curr + (q - t) * dir_out, curr + (p - a) * dir_out, end_point);
+ Corner {
+ start_point,
+ end_point,
+ in_bezier,
+ reduced_sweep,
+ out_bezier,
+ }
+}
+
+impl Corner {
+ fn to_bez_path(&self) -> BezPath {
+ let mut result = BezPath::new();
+ result.move_to(self.start_point);
+ result.curve_to(self.in_bezier.p1, self.in_bezier.p2, self.in_bezier.p3);
+ // TODO: arc segments
+ let arc_params = EulerParams::from_k0_k1(-self.reduced_sweep, 0.0);
+ let arc_seg = EulerSeg::from_params(self.in_bezier.p3, self.out_bezier.p0, arc_params);
+ const ACCURACY: f64 = 0.1;
+ result.extend(arc_seg.to_cubics(ACCURACY));
+ result.curve_to(self.out_bezier.p1, self.out_bezier.p2, self.out_bezier.p3);
+ result
+ }
+}
+
+/// Reproduces a corner that has been spot-verified against squircle-path-kit.
+///
+/// Kept as a reference for anyone re-checking the port; not used by the demo.
+#[allow(dead_code)]
+pub fn test_corner() -> BezPath {
+ let prev = Point::new(160., 0.);
+ let curr = Point::new(320., 0.);
+ let next = Point::new(320., 90.);
+ let budget = 90.0; // Not sure how to set this.
+ let corner = compute_corner(prev, curr, next, 48., 0.68, budget);
+ corner.to_bez_path()
+}
+
+impl Squircle for FigmaSquircle {
+ // The strategy here is to render a verifiable path, then convert
+ // into the form required.
+ fn render(&self, params: &[f64]) -> BezPath {
+ // Same hack as clothoid; probably should fix this for real
+ let smooth = (params[0] - 0.707) / (1.0 - 0.707);
+ let prev = Point::new(-10., 0.);
+ let curr = Point::ORIGIN;
+ let next = Point::new(0., 10.);
+ let budget = 10.0;
+ let corner = compute_corner(prev, curr, next, 1.0, smooth, budget);
+ let scale = 1.0 / corner.end_point.y;
+ let aff = Affine::new([0., scale, -scale, 0., 1., 1.]);
+ aff * corner.to_bez_path()
+ }
+}
diff --git a/crates/squircle/src/lib.rs b/crates/squircle/src/lib.rs
new file mode 100644
index 00000000..225004ae
--- /dev/null
+++ b/crates/squircle/src/lib.rs
@@ -0,0 +1,64 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! An interactive tester for squircle constructions, embedded in the wiki.
+//!
+//! The crate is compiled to Wasm and mounted into a host element by [`start`];
+//! see `content/wiki/curves/squircle.md` for the embedding side, and
+//! `../index.html` for the standalone `trunk serve` one.
+//!
+//! Colors and sizing are deliberately *not* decided here. Every drawn path
+//! carries a CSS class, and the page stylesheet supplies the stroke color, so
+//! the demo picks up the site's light and dark themes instead of hardcoding a
+//! palette that only works on one of them.
+
+mod apple_squircle;
+mod clothoid_squircle;
+mod euler;
+mod figma_squircle;
+mod squircle;
+mod view;
+
+use wasm_bindgen::prelude::wasm_bindgen;
+use xilem_web::App;
+
+use crate::squircle::Squircles;
+
+/// The state driving the tester.
+#[derive(Debug)]
+pub(crate) struct AppState {
+ /// Which construction is drawn.
+ choice: Squircles,
+ /// Overall gauge: how far the shape reaches along the 45 degree diagonal,
+ /// as a fraction of the half-width. A circle is `FRAC_1_SQRT_2`, a square
+ /// is 1. Between `squircle::GAUGE_MIN` and `squircle::GAUGE_MAX`.
+ gauge: f64,
+ /// Straight run along each half edge, as a fraction of what the gauge
+ /// allows, in the range [0, 1].
+ flat: f64,
+ /// Draw a single corner rather than the whole four-fold shape.
+ zoom: bool,
+ /// Fill the shape rather than stroking its outline.
+ fill: bool,
+}
+
+impl Default for AppState {
+ fn default() -> Self {
+ Self {
+ choice: Squircles::default(),
+ gauge: 0.841,
+ flat: 0.0,
+ zoom: false,
+ fill: false,
+ }
+ }
+}
+
+/// Mount the tester into the element with the given id.
+#[wasm_bindgen]
+pub fn start(id: &str) {
+ console_error_panic_hook::set_once();
+
+ let root = xilem_web::get_element_by_id(id);
+ App::new(root, AppState::default(), view::app_logic).run();
+}
diff --git a/crates/squircle/src/main.rs b/crates/squircle/src/main.rs
new file mode 100644
index 00000000..5521ae2d
--- /dev/null
+++ b/crates/squircle/src/main.rs
@@ -0,0 +1,11 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Standalone entry point for `trunk serve`, for iterating without zola.
+//!
+//! It mounts into the same element id the wiki page uses, so this and the
+//! embedded build run identical code.
+
+fn main() {
+ squircle::start("squircle-demo-root");
+}
diff --git a/crates/squircle/src/squircle.rs b/crates/squircle/src/squircle.rs
new file mode 100644
index 00000000..3e683284
--- /dev/null
+++ b/crates/squircle/src/squircle.rs
@@ -0,0 +1,390 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Generic trait for squircles
+
+use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, LN_2, SQRT_2};
+
+use xilem_web::svg::kurbo::{
+ Affine, BezPath, Line, ParamCurve, ParamCurveArclen, ParamCurveCurvature, PathSeg, Point,
+ common::solve_itp,
+};
+
+use crate::{
+ apple_squircle::AppleSquircle, clothoid_squircle::ClothoidSquircle,
+ figma_squircle::FigmaSquircle,
+};
+
+pub struct ProfileSample {
+ s: f64,
+ k: f64,
+}
+
+pub trait Squircle {
+ // Render one quadrant from (1, 0) to (0, 1)
+ fn render(&self, params: &[f64]) -> BezPath;
+
+ fn curvature_profile(&self, params: &[f64]) -> Vec {
+ let path = self.render(params);
+ let mut result = vec![];
+ let mut s0 = 0.0;
+ for seg in path.segments() {
+ match seg {
+ PathSeg::Line(l) => {
+ result.push(ProfileSample::new(s0, 0.0));
+ result.push(ProfileSample::new(s0 + l.length(), 0.0));
+ }
+ PathSeg::Cubic(c) => {
+ const N: usize = 50;
+ for i in 0..=N {
+ let t = i as f64 / N as f64;
+ let s = s0 + c.subsegment(0.0..t).arclen(1e-9);
+ let k = -c.curvature(t);
+ result.push(ProfileSample { s, k });
+ }
+ }
+ _ => todo!(),
+ }
+ s0 += seg.arclen(1e-9);
+ }
+ result
+ }
+}
+
+/// Smallest overall gauge the tester offers.
+pub const GAUGE_MIN: f64 = 0.707;
+/// Largest overall gauge the tester offers.
+pub const GAUGE_MAX: f64 = 0.999;
+
+/// A circular corner, the roundest any of these reach.
+const CORNER_MIN: f64 = FRAC_1_SQRT_2;
+
+/// A straight run along each half edge plus a corner profile scaled into the
+/// square left over, so `gauge = c + h * (1 - c)`.
+#[derive(Clone, Copy, Debug)]
+pub struct Corner {
+ /// Where the corner profile crosses its own diagonal.
+ pub c: f64,
+ /// Length of the straight run along each half edge, as a fraction of the
+ /// half width.
+ pub h: f64,
+ /// What to hand the construction to land on `c`.
+ param: f64,
+}
+
+impl Corner {
+ /// Turns the two slider positions into a corner.
+ ///
+ /// The gauge always holds. The flat picks the corner within what the gauge
+ /// allows, a flat of 1 being exactly a circular corner; where the construction's
+ /// family has no such corner it makes the nearest one it can and the flat
+ /// takes up the difference.
+ pub fn resolve(choice: Squircles, gauge: f64, flat: f64) -> Self {
+ let h_max = ((gauge - CORNER_MIN) / (1.0 - CORNER_MIN)).max(0.0);
+ let wanted = flat.clamp(0.0, 1.0) * h_max;
+ let (c, param) = choice.solve_corner((gauge - wanted) / (1.0 - wanted));
+ Self {
+ c,
+ h: ((gauge - c) / (1.0 - c)).max(0.0),
+ param,
+ }
+ }
+}
+
+/// The exponent `n` of the superellipse `|x|^n + |y|^n = 1` whose quadrant
+/// crosses its diagonal at `c`.
+///
+/// A circular corner is 2; the usual squircle is 4, at a gauge near 0.841.
+pub fn superellipse_exponent(c: f64) -> f64 {
+ -LN_2 / c.ln()
+}
+
+/// Where a quadrant crosses its own diagonal, which is the gauge.
+pub fn diagonal_crossing(path: &BezPath) -> f64 {
+ let diag = Line::new(Point::ZERO, Point::new(1.0, 1.0));
+ path.segments()
+ .flat_map(|seg| {
+ seg.intersect_line(diag)
+ .into_iter()
+ .map(move |hit| seg.eval(hit.segment_t).x)
+ })
+ .fold(f64::NAN, f64::max)
+}
+
+/// Assembles a full quadrant: a straight run, the corner profile scaled into
+/// the corner square, and another straight run.
+pub fn quadrant(choice: Squircles, corner: Corner) -> BezPath {
+ let Corner { h, param, .. } = corner;
+ let profile = choice.render(&[param]);
+ if h <= 0.0 {
+ return profile;
+ }
+ let scaled = Affine::translate((h, h)) * Affine::scale(1.0 - h) * profile;
+ let mut result = BezPath::new();
+ result.move_to((1.0, 0.0));
+ result.line_to((1.0, h));
+ // The scaled profile starts on the point just added, so drop its move_to.
+ result.extend(scaled.elements().iter().skip(1).copied());
+ result.line_to((0.0, 1.0));
+ result
+}
+
+/// The curvature profile of the assembled quadrant.
+///
+/// Scaling by `s` multiplies arc length by `s` and divides curvature by `s`, so
+/// the corner's profile transforms directly rather than being resampled.
+pub fn quadrant_profile(choice: Squircles, corner: Corner) -> Vec {
+ let Corner { h, param, .. } = corner;
+ let inner = choice.curvature_profile(&[param]);
+ if h <= 0.0 {
+ return inner;
+ }
+ let scale = 1.0 - h;
+ // `render_profile` drops non-finite samples, so the corner's length comes
+ // from the last one with a finite `s`; the flats still have to be placed.
+ let corner_len = inner
+ .iter()
+ .rev()
+ .find(|sample| sample.s.is_finite())
+ .map_or(0.0, |sample| sample.s);
+ let end = h + corner_len * scale;
+ let mut result = Vec::with_capacity(inner.len() + 4);
+ result.push(ProfileSample::new(0.0, 0.0));
+ result.push(ProfileSample::new(h, 0.0));
+ for sample in inner {
+ result.push(ProfileSample {
+ s: h + sample.s * scale,
+ k: sample.k / scale,
+ });
+ }
+ result.push(ProfileSample::new(end, 0.0));
+ result.push(ProfileSample::new(end + h, 0.0));
+ result
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+pub enum Squircles {
+ #[default]
+ Superellipse,
+ ChromiumApprox,
+ Clothoid,
+ Figma,
+ Apple,
+}
+
+impl Squircles {
+ /// The name shown on this construction's radio button.
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Superellipse => "Superellipse",
+ Self::ChromiumApprox => "Chromium approximation",
+ Self::Clothoid => "Clothoid",
+ Self::Figma => "Figma",
+ Self::Apple => "Apple",
+ }
+ }
+
+ /// Whether the superellipse exponent describes this construction's corner.
+ pub fn has_exponent(self) -> bool {
+ matches!(self, Self::Superellipse | Self::ChromiumApprox)
+ }
+
+ /// The corner nearest `c` this construction can make, and the parameter for it.
+ ///
+ /// The superellipse and Chromium take the gauge as is, and Apple has one
+ /// corner. The clothoid and Figma take a smoothness, so those are solved
+ /// for; the clothoid's family stops near 0.79, well short of the slider.
+ fn solve_corner(self, c: f64) -> (f64, f64) {
+ match self {
+ Self::Superellipse | Self::ChromiumApprox => (c, c),
+ Self::Apple => (crate::apple_squircle::CORNER_GAUGE, 0.0),
+ Self::Clothoid | Self::Figma => {
+ let crossing = |p: f64| diagonal_crossing(&self.render(&[p]));
+ let (a, b) = (GAUGE_MIN, GAUGE_MAX);
+ let (ya, yb) = (crossing(a) - c, crossing(b) - c);
+ // Negated so a NaN measurement takes this branch too.
+ if !(ya * yb <= 0.0) {
+ let p = if ya.abs() < yb.abs() { a } else { b };
+ return (crossing(p), p);
+ }
+ // solve_itp wants f(a) < 0 < f(b); flip if the mapping descends.
+ let flip = if ya > 0.0 { -1.0 } else { 1.0 };
+ let p = solve_itp(
+ |p| flip * (crossing(p) - c),
+ a,
+ b,
+ 1e-9,
+ 1,
+ 0.2 / (b - a),
+ flip * ya,
+ flip * yb,
+ );
+ (c, p)
+ }
+ }
+ }
+
+ /// The corner this construction is pinned to, if it has no shape parameter.
+ pub fn fixed_corner(self) -> Option {
+ match self {
+ Self::Apple => Some(crate::apple_squircle::CORNER_GAUGE),
+ _ => None,
+ }
+ }
+}
+
+impl Squircle for Squircles {
+ fn render(&self, params: &[f64]) -> BezPath {
+ match self {
+ Self::Superellipse => Superellipse.render(params),
+ Self::ChromiumApprox => ChromiumApprox.render(params),
+ Self::Clothoid => ClothoidSquircle.render(params),
+ Self::Figma => FigmaSquircle.render(params),
+ Self::Apple => AppleSquircle.render(params),
+ }
+ }
+
+ fn curvature_profile(&self, params: &[f64]) -> Vec {
+ match self {
+ Self::Superellipse => Superellipse.curvature_profile(params),
+ Self::ChromiumApprox => ChromiumApprox.curvature_profile(params),
+ Self::Clothoid => ClothoidSquircle.curvature_profile(params),
+ Self::Figma => FigmaSquircle.curvature_profile(params),
+ Self::Apple => AppleSquircle.curvature_profile(params),
+ }
+ }
+}
+
+pub struct Superellipse;
+
+impl Squircle for Superellipse {
+ fn render(&self, params: &[f64]) -> BezPath {
+ let gauge = params[0];
+ let exp_adjust = gauge.ln() * (-1.0 / SQRT_2.ln());
+ const N: usize = 50;
+ let mut result = BezPath::new();
+ for i in 0..=N {
+ let th = i as f64 * (FRAC_PI_2 / N as f64);
+ let (v, u) = if i == N {
+ (1.0, 0.0)
+ } else {
+ th.sin_cos()
+ };
+ let x = u.powf(exp_adjust);
+ let y = v.powf(exp_adjust);
+ let p = Point::new(x, y);
+ if i == 0 {
+ result.move_to(p);
+ } else {
+ result.line_to(p);
+ }
+ }
+ result
+ }
+
+ fn curvature_profile(&self, params: &[f64]) -> Vec {
+ let gauge = params[0];
+ let exp_adjust = gauge.ln() * (-1.0 / SQRT_2.ln());
+ let exp = 2.0 / exp_adjust;
+ const N: usize = 200;
+ let mut last_pt = Point::new(1.0, 0.0);
+ let mut result = vec![];
+ let mut s = 0.0;
+ // The profile is symmetric about the diagonal, so sweeping a half
+ // quadrant would carry the same information. It sweeps the full
+ // quadrant anyway, to put this on the same arc length axis as the
+ // constructions that use the default `curvature_profile`.
+ for i in 0..=N {
+ let th = i as f64 * (FRAC_PI_2 / N as f64);
+ // Pinned as `render` pins it: the last `th` lands a ulp past
+ // `FRAC_PI_2`, so `cos` goes negative, `powf` returns NaN, and that
+ // NaN reaches `s` through the chord below and stays there.
+ let (v, u) = if i == N { (1.0, 0.0) } else { th.sin_cos() };
+ let x = u.powf(exp_adjust);
+ let y = v.powf(exp_adjust);
+ let p = Point::new(x, y);
+ // This is an underestimate but good enough for visualization.
+ s += p.distance(last_pt);
+ let g = 2.0 - 2.0 * exp_adjust;
+ let mut k = (exp - 1.0) * (u * v).powf(g) * (u.powf(2.0 + g) + (v.powf(2.0 + g))).powf(-1.5);
+ if !k.is_finite() {
+ k = 1.0;
+ }
+ result.push(ProfileSample { s, k });
+ last_pt = p;
+ }
+ result
+ }
+}
+
+pub struct ChromiumApprox;
+
+// Adapted closely from https://developer.chrome.com/blog/implementing-corner-shape
+fn calc_superellipse(k: f64) -> (f64, f64) {
+ const P0: f64 = 1.2430920942724248;
+ const P1: f64 = 2.010479023614843;
+ const P2: f64 = 0.32922901179443753;
+ const P3: f64 = 0.2823023142212073;
+ const P4: f64 = 1.3473704261055421;
+ const P5: f64 = 2.9149468637949814;
+ const P6: f64 = 0.9106507102917086;
+
+ let s = k.log2();
+ let slope = P0 + (P6 - P0) * 0.5 * (1.0 + (P5 * (s - P1)).tanh());
+ let base = 1.0 / (1.0 + (slope * P1).exp());
+ let logistic = 1.0 / (1.0 + (slope * (P1 - s)).exp());
+
+ let a = (logistic - base) / (1.0 - base);
+ let b = P2 * (-P3 * s.powf(P4)).exp();
+ (a, b)
+}
+
+impl Squircle for ChromiumApprox {
+ fn render(&self, params: &[f64]) -> BezPath {
+ let gauge = params[0];
+ let k = (0.5f64).ln() / gauge.ln();
+ let (a, b) = calc_superellipse(k);
+ let mut result = BezPath::new();
+ result.move_to((1.0, 0.0));
+ result.curve_to((1.0, a), (gauge + b, gauge - b), (gauge, gauge));
+ result.curve_to((gauge - b, gauge + b), (a, 1.0), (0.0, 1.0));
+ result
+ }
+}
+
+/// Builds a polyline of a curvature profile in (arc length, curvature) space.
+///
+/// The caller is responsible for mapping this into screen coordinates, so that
+/// the plot's axes and its data stay in step.
+///
+/// Samples that are not finite are dropped, and a gap starts a new subpath so
+/// the line is never drawn straight through the discontinuity. Curvature really
+/// is undefined at points these constructions can reach: at the bottom of the
+/// gauge range the Figma corner collapses to a cubic whose first three control
+/// points coincide, so its derivative is zero there and its curvature is 0/0.
+/// Emitting one NaN would cost the whole curve rather than one point, because a
+/// browser rejects an entire SVG `d` attribute that contains one.
+pub fn render_profile(profile: &[ProfileSample]) -> BezPath {
+ let mut result = BezPath::new();
+ let mut pen_down = false;
+ for sample in profile {
+ if !sample.s.is_finite() || !sample.k.is_finite() {
+ pen_down = false;
+ continue;
+ }
+ let p = Point::new(sample.s, sample.k);
+ if pen_down {
+ result.line_to(p);
+ } else {
+ result.move_to(p);
+ pen_down = true;
+ }
+ }
+ result
+}
+
+impl ProfileSample {
+ pub fn new(s: f64, k: f64) -> Self {
+ Self { s, k }
+ }
+}
diff --git a/crates/squircle/src/view.rs b/crates/squircle/src/view.rs
new file mode 100644
index 00000000..bb2379f2
--- /dev/null
+++ b/crates/squircle/src/view.rs
@@ -0,0 +1,408 @@
+// Copyright 2026 the Linebender Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! The view layer for the squircle tester.
+//!
+//! Two panels are drawn side by side: the shape itself, and the curvature
+//! profile of one quadrant. Both are plain SVG with a `viewBox` and no
+//! intrinsic size, so the page stylesheet controls how large they are and they
+//! reflow on narrow screens.
+//!
+//! Every stroked path carries a CSS class. Stroke colors set here are only
+//! fallbacks for when the stylesheet is missing; `squircle-demo.css` overrides
+//! them per theme.
+
+use xilem_web::{
+ DomView, input_event_target_value,
+ elements::{
+ html::{div, input, label, span},
+ svg::{g, svg, text},
+ },
+ interfaces::{Element, HtmlInputElement, SvgGeometryElement, SvgPathElement},
+ svg::{
+ kurbo::{Affine, BezPath, Point, Shape, Stroke},
+ peniko::color::palette::css,
+ },
+};
+
+use crate::{
+ AppState,
+ squircle::{
+ Corner, GAUGE_MAX, GAUGE_MIN, Squircles, quadrant, quadrant_profile, render_profile,
+ superellipse_exponent,
+ },
+};
+
+/// Side of the square `viewBox` the shape is drawn into.
+const SHAPE_VIEW: f64 = 560.0;
+/// Margin between the shape and the edge of its `viewBox`.
+const SHAPE_MARGIN: f64 = 40.0;
+
+/// Width of the curvature plot's `viewBox`.
+const PLOT_W: f64 = 560.0;
+/// Height of the curvature plot's `viewBox`.
+const PLOT_H: f64 = 300.0;
+/// Number of gridline divisions along each axis of the curvature plot.
+const PLOT_DIVISIONS: usize = 4;
+
+/// Gap between a curvature label and the axis, in `viewBox` units.
+const LABEL_GAP: f64 = 8.0;
+
+/// Labels for the curvature axis, one per gridline.
+fn curvature_labels(transform: Affine, k_max: f64) -> impl DomView + use<> {
+ let labels: Vec<_> = (1..=PLOT_DIVISIONS)
+ .map(|i| {
+ let k = k_max * i as f64 / PLOT_DIVISIONS as f64;
+ let at = transform * Point::new(0.0, k);
+ let shown = if k.fract() == 0.0 {
+ format!("{k:.0}")
+ } else {
+ format!("{k:.1}")
+ };
+ text(shown)
+ .attr("x", format!("{:.1}", at.x - LABEL_GAP))
+ .attr("y", format!("{:.1}", at.y + 4.0))
+ .class("squircle-axis-label")
+ })
+ .collect();
+ g(labels)
+}
+
+/// A tick up from the arc length axis at `s_end`, where the curve stops.
+///
+/// In the axis' style, since it reports where the quadrant ends rather than
+/// anything about the profile, and the curve's trailing flat runs along the
+/// axis. In `viewBox` units so it keeps one size as the curvature axis
+/// rescales; up only, since curvature is never negative here.
+fn end_tick(transform: Affine, s_end: f64) -> BezPath {
+ let foot = transform * Point::new(s_end, 0.0);
+ let mut path = BezPath::new();
+ path.move_to((foot.x, foot.y));
+ path.line_to((foot.x, foot.y - END_TICK));
+ path
+}
+
+/// Maximum of the curvature plot's arc length axis.
+///
+/// A quadrant is at most 2 long, reached only by the degenerate square corner.
+/// Absolute rather than fitted to the profile, whose length varies with both
+/// sliders: the axis would otherwise rescale mid-drag, and the flats would read
+/// as a share of a moving total rather than at their true length.
+const PLOT_S_MAX: f64 = 2.0;
+
+/// Length of the tick marking where the curve ends, in `viewBox` units.
+const END_TICK: f64 = 6.0;
+
+/// Steps the curvature axis can take.
+///
+/// Curvature runs from about 1 at the circular end of the gauge range to
+/// several hundred as the shape approaches a square, so no single fixed axis
+/// serves the whole slider. Three steps cover it while rescaling only twice, so
+/// the plot is still for most of a drag rather than resizing under the cursor.
+/// Past the top step the curve is left to run off the plot: stretching the axis
+/// to a peak in the hundreds would flatten everything below it onto the
+/// baseline, which hides the shape of the profile that the plot exists to show.
+const CURVATURE_STEPS: [f64; 3] = [2.0, 20.0, 200.0];
+
+/// The smallest [`CURVATURE_STEPS`] entry that contains `peak`, else the largest.
+fn curvature_axis_max(peak: f64) -> f64 {
+ if !peak.is_finite() {
+ return CURVATURE_STEPS[0];
+ }
+ for step in CURVATURE_STEPS {
+ if peak <= step {
+ return step;
+ }
+ }
+ CURVATURE_STEPS[CURVATURE_STEPS.len() - 1]
+}
+
+/// Maps the unit square the shapes are defined in onto the shape `viewBox`.
+fn shape_transform() -> Affine {
+ let scale = SHAPE_VIEW - 2.0 * SHAPE_MARGIN;
+ Affine::translate((SHAPE_MARGIN, SHAPE_MARGIN)) * Affine::scale(scale)
+}
+
+/// Maps (arc length, curvature) onto the curvature plot's `viewBox`.
+///
+/// The vertical scale is negated because SVG's y axis points down.
+fn plot_transform(s_max: f64, k_max: f64) -> Affine {
+ let left = 56.0;
+ let right = PLOT_W - 24.0;
+ let top = 28.0;
+ let bottom = PLOT_H - 48.0;
+ Affine::new([
+ (right - left) / s_max,
+ 0.0,
+ 0.0,
+ -(bottom - top) / k_max,
+ left,
+ bottom,
+ ])
+}
+
+/// The two axis lines of the curvature plot, in (arc length, curvature) space.
+fn axes_path(s_max: f64, k_max: f64) -> BezPath {
+ let mut path = BezPath::new();
+ path.move_to((0.0, k_max));
+ path.line_to((0.0, 0.0));
+ path.line_to((s_max, 0.0));
+ path
+}
+
+/// Gridlines dividing each axis into [`PLOT_DIVISIONS`] equal parts.
+fn grid_path(s_max: f64, k_max: f64) -> BezPath {
+ let mut path = BezPath::new();
+ for i in 1..=PLOT_DIVISIONS {
+ let fraction = i as f64 / PLOT_DIVISIONS as f64;
+ path.move_to((s_max * fraction, 0.0));
+ path.line_to((s_max * fraction, k_max));
+ path.move_to((0.0, k_max * fraction));
+ path.line_to((s_max, k_max * fraction));
+ }
+ path
+}
+
+/// Replicates one quadrant into the full four-fold shape.
+///
+/// The quadrant occupies the unit square, so each copy is scaled by a half and
+/// rotated into place; the result again occupies the unit square. The first
+/// element of each copy but the first is skipped, because it repeats the point
+/// the previous copy ended on.
+fn quadruple_up(path: &BezPath) -> BezPath {
+ const AFFS: [Affine; 4] = [
+ Affine::new([0.5, 0., 0., 0.5, 0.5, 0.5]),
+ Affine::new([0.0, 0.5, -0.5, 0.0, 0.5, 0.5]),
+ Affine::new([-0.5, 0., 0., -0.5, 0.5, 0.5]),
+ Affine::new([0.0, -0.5, 0.5, 0.0, 0.5, 0.5]),
+ ];
+ let mut result = BezPath::new();
+ for (i, aff) in AFFS.iter().enumerate() {
+ result.extend(
+ path.elements()
+ .iter()
+ .skip((i > 0) as usize)
+ .map(|el| *aff * *el),
+ );
+ }
+ result.close_path();
+ result
+}
+
+/// One labelled radio button selecting a construction.
+fn choice_radio(state: &AppState, choice: Squircles) -> impl DomView + use<> {
+ label((
+ input(())
+ .type_("radio")
+ .name("squircle-choice")
+ .checked(state.choice == choice)
+ .on_input(move |state: &mut AppState, _| state.choice = choice),
+ choice.name(),
+ ))
+ .class("squircle-choice")
+}
+
+/// One labelled range slider.
+fn slider(
+ id: &'static str,
+ caption: &'static str,
+ value: f64,
+ readout: String,
+ disabled: bool,
+ on_change: F,
+) -> impl DomView + use {
+ div((
+ label(caption)
+ .attr("for", id)
+ .class("squircle-control-label"),
+ input(())
+ .attr("id", id)
+ .type_("range")
+ .attr("min", "0")
+ .attr("max", "1000")
+ .attr("step", "1")
+ .attr("value", (value * 1000.0).round() as i32)
+ .disabled(disabled)
+ .on_input(move |state: &mut AppState, event| {
+ if let Some(raw) = input_event_target_value(&event) {
+ if let Ok(parsed) = raw.parse::() {
+ on_change(state, parsed * 1e-3);
+ }
+ }
+ }),
+ span(readout).class("squircle-readout"),
+ ))
+ .class("squircle-slider")
+}
+
+/// One label-and-value row, for a number the tester reports but cannot set.
+fn readout_row(
+ caption: &'static str,
+ value: String,
+ muted: bool,
+) -> impl DomView + use<> {
+ let tone = if muted { "squircle-muted" } else { "squircle-live" };
+ div((
+ span(caption).class(["squircle-control-label", tone]),
+ span(value).class(["squircle-readout", tone]),
+ ))
+ .class("squircle-slider")
+}
+
+/// The control row above the panels.
+fn controls(state: &AppState, corner: Corner) -> impl DomView + use<> {
+ // Apple's corner is fixed, so its flat follows from the gauge.
+ let flat_fixed = state.choice.fixed_corner().is_some();
+
+ let choices = div((
+ choice_radio(state, Squircles::Superellipse),
+ choice_radio(state, Squircles::ChromiumApprox),
+ choice_radio(state, Squircles::Clothoid),
+ choice_radio(state, Squircles::Figma),
+ choice_radio(state, Squircles::Apple),
+ ))
+ .class("squircle-choices")
+ .attr("role", "radiogroup")
+ .attr("aria-label", "Variant");
+
+ let zoom = label((
+ input(())
+ .type_("checkbox")
+ .checked(state.zoom)
+ .on_input(|state: &mut AppState, _| state.zoom = !state.zoom),
+ "Zoom to a single corner",
+ ))
+ .class("squircle-choice");
+
+ let fill = label((
+ input(())
+ .type_("checkbox")
+ .checked(state.fill)
+ .on_input(|state: &mut AppState, _| state.fill = !state.fill),
+ "Fill",
+ ))
+ .class("squircle-choice");
+
+ // The gauge slider spans [GAUGE_MIN, GAUGE_MAX]; the helper takes 0..1.
+ let gauge_span = GAUGE_MAX - GAUGE_MIN;
+ let gauge_slider = slider(
+ "squircle-gauge",
+ "Gauge",
+ (state.gauge - GAUGE_MIN) / gauge_span,
+ format!("{:.3}", state.gauge),
+ false,
+ move |state, t| state.gauge = GAUGE_MIN + t * gauge_span,
+ );
+
+ // h runs 0..h_max as the gauge runs c..GAUGE_MAX, when the corner is fixed.
+ let flat_position = if flat_fixed {
+ ((state.gauge - corner.c) / (GAUGE_MAX - corner.c)).clamp(0.0, 1.0)
+ } else {
+ state.flat
+ };
+ let flat_slider = slider(
+ "squircle-flat",
+ "Flat",
+ flat_position,
+ format!("{:.3}", corner.h),
+ flat_fixed,
+ |state, t| state.flat = t,
+ );
+
+ let has_exponent = state.choice.has_exponent();
+ let exponent = readout_row(
+ "Exponent",
+ if has_exponent {
+ format!("{:.3}", superellipse_exponent(corner.c))
+ } else {
+ "\u{2014}".to_string()
+ },
+ !has_exponent,
+ );
+
+ div((choices, zoom, fill, gauge_slider, flat_slider, exponent)).class("squircle-controls")
+}
+
+/// The shape panel.
+fn shape_panel(state: &AppState, corner: Corner) -> impl DomView + use<> {
+ let mut shape = quadrant(state.choice, corner);
+ if state.zoom {
+ // A lone quadrant is open, so filling it needs the two radii.
+ if state.fill {
+ shape.line_to((0.0, 0.0));
+ shape.close_path();
+ }
+ } else {
+ shape = quadruple_up(&shape);
+ }
+
+ let (mode, stroke, fill) = if state.fill {
+ ("squircle-path--fill", css::TRANSPARENT, css::STEEL_BLUE)
+ } else {
+ ("squircle-path--shape", css::STEEL_BLUE, css::TRANSPARENT)
+ };
+ let drawing = svg(g((shape_transform() * shape)
+ .stroke(stroke, Stroke::new(2.0))
+ .fill(fill)
+ .class(["squircle-path", mode])))
+ .attr("viewBox", format!("0 0 {SHAPE_VIEW} {SHAPE_VIEW}"))
+ .class("squircle-figure")
+ .attr("role", "img")
+ .attr("aria-label", "The selected squircle construction");
+
+ div(drawing).class("squircle-panel")
+}
+
+/// The curvature panel: curvature against arc length along one quadrant.
+fn curvature_panel(state: &AppState, corner: Corner) -> impl DomView + use<> {
+ let profile = render_profile(&quadrant_profile(state.choice, corner));
+
+ // Only the curvature axis is fitted; arc length stays absolute.
+ let bounds = profile.bounding_box();
+ let k_max = curvature_axis_max(bounds.y1);
+ // Clamped so that a degenerate profile cannot put the tick outside the plot.
+ let s_end = bounds.x1.clamp(0.0, PLOT_S_MAX);
+ let transform = plot_transform(PLOT_S_MAX, k_max);
+
+ let drawing = svg(g((
+ (transform * grid_path(PLOT_S_MAX, k_max))
+ .stroke(css::GAINSBORO, Stroke::new(1.0))
+ .fill(css::TRANSPARENT)
+ .class("squircle-grid"),
+ (transform * axes_path(PLOT_S_MAX, k_max))
+ .stroke(css::GRAY, Stroke::new(1.5))
+ .fill(css::TRANSPARENT)
+ .class("squircle-axes"),
+ (transform * profile)
+ .stroke(css::STEEL_BLUE, Stroke::new(2.0))
+ .fill(css::TRANSPARENT)
+ .class(["squircle-path", "squircle-path--curvature"]),
+ // Last, so it reads over the curve where the two meet.
+ end_tick(transform, s_end)
+ .stroke(css::GRAY, Stroke::new(1.5))
+ .fill(css::TRANSPARENT)
+ .class(["squircle-axes", "squircle-tick"]),
+ curvature_labels(transform, k_max),
+ )))
+ .attr("viewBox", format!("0 0 {PLOT_W} {PLOT_H}"))
+ .class("squircle-figure")
+ .attr("role", "img")
+ .attr(
+ "aria-label",
+ "Curvature plotted against arc length along one quadrant",
+ );
+
+ div(drawing).class("squircle-panel")
+}
+
+/// Top-level view.
+pub(crate) fn app_logic(state: &mut AppState) -> impl DomView + use<> {
+ // Resolved once: inverting the clothoid and Figma parameters costs renders.
+ let corner = Corner::resolve(state.choice, state.gauge, state.flat);
+ div((
+ controls(state, corner),
+ div((shape_panel(state, corner), curvature_panel(state, corner)))
+ .class("squircle-panels"),
+ ))
+ .class("squircle-demo")
+}