From 308538a3713f8c3334d1b36aa8bc0a28f61713aa Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 02:50:43 +0000 Subject: [PATCH 1/9] Add interactive squircle tester to the wiki Adds a wiki page comparing four squircle constructions, with an embedded xilem_web demo for exploring them, following the approach prototyped in #131. Demos live in a crates/ workspace, matching the layout already used on the log-aesthetic branch. Each is a cdylib exporting start(id), which wasm-bindgen packages into static/rust/ for the page to import and mount. Each also has a bin target so `trunk serve` still works for local iteration against that same entry point; site builds pass --lib, so it is never compiled in CI. crates/build.sh is what both the deploy workflow and local use beside `zola serve` run, so the two cannot drift. It builds only the crates that declare a cdylib, so native tools that share this workspace are never compiled for wasm. The tester is ported from a standalone prototype, with the formatting reworked for embedding: the radios and the zoom toggle are labelled, the curvature profile moved out of the shape's coordinate space into its own panel, the SVGs carry a viewBox instead of fixed pixel sizes so they reflow, and stroke colors come from CSS custom properties so the demo follows the site's light and dark themes. Two fixes to the ported math: Superellipse::curvature_profile swept only a half quadrant, while the other constructions sweep a full one, so selecting it silently halved the plot's arc length axis. render_profile now drops non-finite samples and breaks the polyline across the gap. At the bottom of the gauge range the Figma corner collapses to a cubic whose first three control points coincide, making its curvature 0/0, and a single NaN makes a browser reject the whole SVG path. --- .github/workflows/check.yml | 22 ++ .github/workflows/main.yml | 19 + .gitignore | 1 + content/wiki/curves/squircle.md | 61 +++ crates/.gitignore | 2 + crates/Cargo.lock | 411 +++++++++++++++++++ crates/Cargo.toml | 3 + crates/README.md | 71 ++++ crates/build.sh | 66 ++++ crates/rustfmt.toml | 11 + crates/squircle/Cargo.toml | 29 ++ crates/squircle/index.html | 42 ++ crates/squircle/squircle-demo.css | 180 +++++++++ crates/squircle/src/clothoid_squircle.rs | 89 +++++ crates/squircle/src/euler.rs | 482 +++++++++++++++++++++++ crates/squircle/src/figma_squircle.rs | 139 +++++++ crates/squircle/src/lib.rs | 58 +++ crates/squircle/src/main.rs | 11 + crates/squircle/src/squircle.rs | 220 +++++++++++ crates/squircle/src/view.rs | 320 +++++++++++++++ 20 files changed, 2237 insertions(+) create mode 100644 content/wiki/curves/squircle.md create mode 100644 crates/.gitignore create mode 100644 crates/Cargo.lock create mode 100644 crates/Cargo.toml create mode 100644 crates/README.md create mode 100755 crates/build.sh create mode 100644 crates/rustfmt.toml create mode 100644 crates/squircle/Cargo.toml create mode 100644 crates/squircle/index.html create mode 100644 crates/squircle/squircle-demo.css create mode 100644 crates/squircle/src/clothoid_squircle.rs create mode 100644 crates/squircle/src/euler.rs create mode 100644 crates/squircle/src/figma_squircle.rs create mode 100644 crates/squircle/src/lib.rs create mode 100644 crates/squircle/src/main.rs create mode 100644 crates/squircle/src/squircle.rs create mode 100644 crates/squircle/src/view.rs 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.md b/content/wiki/curves/squircle.md new file mode 100644 index 00000000..0947cd0c --- /dev/null +++ b/content/wiki/curves/squircle.md @@ -0,0 +1,61 @@ ++++ +title = "Squircles" ++++ + +A squircle is a rounded rectangle whose corners blend into the straight edges without a visible seam. +A conventional rounded rectangle joins a circular arc to a line, which leaves curvature jumping from $1/r$ to zero at the join. +The eye picks that discontinuity up as a crease, so a number of different constructions have been proposed to smooth it out. + +This page compares four of them. +They are close enough in shape to be hard to tell apart directly, so the tester below also plots curvature against arc length, where the differences are obvious. + +## The gauge parameter + +All four constructions are controlled here by a single shape parameter, the *superellipse gauge*. +Working in a unit square, with the shape inscribed so that it touches $(1, 0)$ and $(0, 1)$, the gauge $g$ is the coordinate at which the shape crosses the diagonal: the curve passes through $(g, g)$. +A circle gives $g = 1/\sqrt{2} \approx 0.707$ and a square gives $g = 1$. + +This is the same quantity as the "Superellipse" column in [Curve families](@/wiki/curve_families.md), which makes it a convenient common axis for comparing families that are otherwise parameterized quite differently. + +
+

Loading the interactive tester…

+
+ + + + + +## Superellipse + +TODO: the defining equation, the relation between the exponent and the gauge, and why the curvature goes to zero at the axes. + +## Chromium approximation + +TODO: the two-cubic fit from [Implementing corner-shape], and how closely it tracks the true superellipse. + +## Clothoid + +TODO: a clothoid in from the edge, a circular arc through the corner, a clothoid back out, with the split between them as the smoothness parameter. + +## Figma + +TODO: the corner-smoothing construction, and how it differs from both of the above. + +[Implementing corner-shape]: https://developer.chrome.com/blog/implementing-corner-shape 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..fac2ceff --- /dev/null +++ b/crates/squircle/squircle-demo.css @@ -0,0 +1,180 @@ +/* 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; the SVG paths and the legend swatches both + read these, so they can never drift apart. */ + --squircle-shape: #0b5fbd; + --squircle-reference: #b5530c; + --squircle-grid: #e0e0e0; + --squircle-axis: #8a8a8a; + + margin: 1.5rem 0; +} + +@media (prefers-color-scheme: dark) { + #squircle-demo-root { + --squircle-shape: #79b0f2; + --squircle-reference: #f0a35e; + --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 { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} + +#squircle-demo-root .squircle-path--shape, +#squircle-demo-root .squircle-path--curvature { + stroke: var(--squircle-shape); +} + +/* Dashed as well as differently colored, so the two curves stay distinguishable + in print and for readers with a color vision deficiency. */ +#squircle-demo-root .squircle-path--reference { + stroke: var(--squircle-reference); + stroke-dasharray: 7 5; +} + +#squircle-demo-root .squircle-grid { + fill: none; + stroke: var(--squircle-grid); +} + +#squircle-demo-root .squircle-axes { + fill: none; + stroke: var(--squircle-axis); +} + +#squircle-demo-root .squircle-legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-top: 0.5rem; + font-size: 0.85rem; +} + +#squircle-demo-root .squircle-key { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +#squircle-demo-root .squircle-key::before { + content: ""; + width: 1.4rem; + height: 0; + border-top-width: 3px; + border-top-style: solid; +} + +#squircle-demo-root .squircle-key--shape::before { + border-top-color: var(--squircle-shape); +} + +#squircle-demo-root .squircle-key--reference::before { + border-top-color: var(--squircle-reference); + border-top-style: dashed; +} + +#squircle-demo-root .squircle-caption, +#squircle-demo-root .squircle-fallback { + margin-top: 0.5rem; + color: var(--fg2-color, #767676); + font-size: 0.85rem; +} diff --git a/crates/squircle/src/clothoid_squircle.rs b/crates/squircle/src/clothoid_squircle.rs new file mode 100644 index 00000000..880b4004 --- /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::{Affine, 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 + } +} \ No newline at end of file diff --git a/crates/squircle/src/euler.rs b/crates/squircle/src/euler.rs new file mode 100644 index 00000000..3ad93ea1 --- /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) +} + +/// Evaulate 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 thm = 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 * thm.sin(); + let c = t / self.chord * thm.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..abcd8373 --- /dev/null +++ b/crates/squircle/src/lib.rs @@ -0,0 +1,58 @@ +// 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 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 against the superellipse reference. + choice: Squircles, + /// Shape parameter, in the range [0.707, 0.999]. + /// + /// This is the "superellipse gauge": the distance from the centre to the + /// shape along the 45 degree diagonal, as a fraction of the half-width. A + /// circle is `FRAC_1_SQRT_2`, a square is 1. + gauge: f64, + /// Draw a single corner rather than the whole four-fold shape. + zoom: bool, +} + +impl Default for AppState { + fn default() -> Self { + Self { + choice: Squircles::default(), + gauge: 0.841, + zoom: 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..9924ec3f --- /dev/null +++ b/crates/squircle/src/squircle.rs @@ -0,0 +1,220 @@ +// Copyright 2026 the Linebender Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Generic trait for squircles + +use std::f64::consts::{FRAC_PI_2, SQRT_2}; + +use xilem_web::svg::kurbo::{ + BezPath, ParamCurve, ParamCurveArclen, ParamCurveCurvature, PathSeg, Point, +}; + +use crate::{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 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Squircles { + #[default] + Superellipse, + ChromiumApprox, + Clothoid, + Figma, +} + +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", + } + } +} + +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), + } + } + + 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), + } + } +} + +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); + let (v, u) = 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..74d96a77 --- /dev/null +++ b/crates/squircle/src/view.rs @@ -0,0 +1,320 @@ +// 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}, + }, + interfaces::{Element, HtmlInputElement, SvgGeometryElement, SvgPathElement}, + svg::{ + kurbo::{Affine, BezPath, Shape, Stroke}, + peniko::color::palette::css, + }, +}; + +use crate::{ + AppState, + squircle::{Squircle, Squircles, Superellipse, render_profile}, +}; + +/// 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; + +/// 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] +} + +/// Rounds `value` up to the next 1, 2 or 5 times a power of ten. +/// +/// Used for the arc length axis, which varies only between about 1.6 and 2 and +/// so settles on one value in practice. +fn nice_ceil(value: f64) -> f64 { + if !(value > 0.0) || !value.is_finite() { + return 1.0; + } + let magnitude = 10f64.powf(value.log10().floor()); + let normalized = value / magnitude; + let step = if normalized <= 1.0 { + 1.0 + } else if normalized <= 2.0 { + 2.0 + } else if normalized <= 5.0 { + 5.0 + } else { + 10.0 + }; + step * magnitude +} + +/// 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") + .attr("name", "squircle-choice") + .checked(state.choice == choice) + .on_input(move |state: &mut AppState, _| state.choice = choice), + choice.name(), + )) + .class("squircle-choice") +} + +/// The radio group, zoom toggle and gauge slider. +fn controls(state: &AppState) -> impl DomView + use<> { + let gauge = state.gauge; + + let choices = div(( + span("Construction").class("squircle-control-label"), + choice_radio(state, Squircles::Superellipse), + choice_radio(state, Squircles::ChromiumApprox), + choice_radio(state, Squircles::Clothoid), + choice_radio(state, Squircles::Figma), + )) + .class("squircle-choices"); + + 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 slider = div(( + label("Gauge") + .attr("for", "squircle-gauge") + .class("squircle-control-label"), + input(()) + .attr("id", "squircle-gauge") + .type_("range") + .attr("min", "707") + .attr("max", "999") + .attr("step", "1") + .attr("value", (gauge * 1000.0).round() as i32) + .on_input(|state: &mut AppState, event| { + if let Some(value) = input_event_target_value(&event) { + if let Ok(parsed) = value.parse::() { + state.gauge = parsed * 1e-3; + } + } + }), + span(format!("{gauge:.3}")).class("squircle-readout"), + )) + .class("squircle-slider"); + + div((choices, zoom, slider)).class("squircle-controls") +} + +/// The shape panel: the selected construction over a superellipse reference. +fn shape_panel(state: &AppState) -> impl DomView + use<> { + let params = [state.gauge]; + let mut shape = state.choice.render(¶ms); + let mut reference = Superellipse.render(¶ms); + if !state.zoom { + shape = quadruple_up(&shape); + reference = quadruple_up(&reference); + } + + let transform = shape_transform(); + let drawing = svg(g(( + (transform * reference) + .stroke(css::DARK_ORANGE, Stroke::new(1.5)) + .fill(css::TRANSPARENT) + .class(["squircle-path", "squircle-path--reference"]), + (transform * shape) + .stroke(css::STEEL_BLUE, Stroke::new(2.0)) + .fill(css::TRANSPARENT) + .class(["squircle-path", "squircle-path--shape"]), + ))) + .attr("viewBox", format!("0 0 {SHAPE_VIEW} {SHAPE_VIEW}")) + .class("squircle-figure") + .attr("role", "img") + .attr( + "aria-label", + "The selected squircle construction drawn over a superellipse of the same gauge", + ); + + let key = div(( + span("Selected construction") + .class(["squircle-key", "squircle-key--shape"]), + span("Superellipse reference") + .class(["squircle-key", "squircle-key--reference"]), + )) + .class("squircle-legend"); + + div((drawing, key)).class("squircle-panel") +} + +/// The curvature panel: curvature against arc length along one quadrant. +fn curvature_panel(state: &AppState) -> impl DomView + use<> { + let params = [state.gauge]; + let profile = render_profile(&state.choice.curvature_profile(¶ms)); + + // The profile is a polyline in (arc length, curvature) space, so its + // bounding box is exactly the data range the axes have to cover. + let bounds = profile.bounding_box(); + let s_max = nice_ceil(bounds.x1); + let k_max = curvature_axis_max(bounds.y1); + let overflows = bounds.y1 > k_max; + let transform = plot_transform(s_max, k_max); + + let drawing = svg(g(( + (transform * grid_path(s_max, k_max)) + .stroke(css::GAINSBORO, Stroke::new(1.0)) + .fill(css::TRANSPARENT) + .class("squircle-grid"), + (transform * axes_path(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"]), + ))) + .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", + ); + + let overflow_note = if overflows { + format!(" The peak of {:.0} runs off the top.", bounds.y1) + } else { + String::new() + }; + let caption = div(format!( + "Curvature, 0 to {k_max}, against arc length along one quadrant, 0 to {s_max}. \ + Gridlines divide each axis into {PLOT_DIVISIONS} equal parts.{overflow_note}" + )) + .class("squircle-caption"); + + div((drawing, caption)).class("squircle-panel") +} + +/// Top-level view. +pub(crate) fn app_logic(state: &mut AppState) -> impl DomView + use<> { + div(( + controls(state), + div((shape_panel(state), curvature_panel(state))) + .class("squircle-panels"), + )) + .class("squircle-demo") +} From 287991fdb0cc01d57221f4a98ebaad92143b71fc Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 04:49:24 +0000 Subject: [PATCH 2/9] Add the Apple squircle to the tester Adds the rounded rectangle Apple introduced in iOS 7 as a fifth construction, labelled "Apple". The control points are Apple's own, recovered by PaintCode by walking the live CGPath with CGPathApply, and published at https://www.paintcodeapp.com/blogpost/code-for-ios-7-rounded-rectangles This renders the square aspect ratio at the largest corner radius the construction allows, which is where the flat sides vanish and one corner spans a full quadrant, so it sits on the same footing as the other four. The flat side length is the parameter this shape really has; that is not wired up yet, so render ignores the gauge. The quadrant is neither symmetric about its diagonal nor curvature continuous. A short straight run sits on one side of the diagonal, and curvature steps from 1.638 to 1.095 at the join between the last two cubics. Both are properties of Apple's shape rather than of the transcription, so the module says to reproduce the path as published and not tidy it; the temptation is real, since mirroring the corner's other half makes it symmetric. Figma and Lisse both report the same asymmetry, and Figma's curvature figure plots a corner they had already repaired that way. --- crates/squircle/src/apple_squircle.rs | 79 +++++++++++++++++++++++++++ crates/squircle/src/lib.rs | 1 + crates/squircle/src/squircle.rs | 9 ++- crates/squircle/src/view.rs | 1 + 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 crates/squircle/src/apple_squircle.rs diff --git a/crates/squircle/src/apple_squircle.rs b/crates/squircle/src/apple_squircle.rs new file mode 100644 index 00000000..e798285a --- /dev/null +++ b/crates/squircle/src/apple_squircle.rs @@ -0,0 +1,79 @@ +// 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; + +/// 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/lib.rs b/crates/squircle/src/lib.rs index abcd8373..e3984eaa 100644 --- a/crates/squircle/src/lib.rs +++ b/crates/squircle/src/lib.rs @@ -12,6 +12,7 @@ //! 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; diff --git a/crates/squircle/src/squircle.rs b/crates/squircle/src/squircle.rs index 9924ec3f..125bdc27 100644 --- a/crates/squircle/src/squircle.rs +++ b/crates/squircle/src/squircle.rs @@ -9,7 +9,10 @@ use xilem_web::svg::kurbo::{ BezPath, ParamCurve, ParamCurveArclen, ParamCurveCurvature, PathSeg, Point, }; -use crate::{clothoid_squircle::ClothoidSquircle, figma_squircle::FigmaSquircle}; +use crate::{ + apple_squircle::AppleSquircle, clothoid_squircle::ClothoidSquircle, + figma_squircle::FigmaSquircle, +}; pub struct ProfileSample { s: f64, @@ -54,6 +57,7 @@ pub enum Squircles { ChromiumApprox, Clothoid, Figma, + Apple, } impl Squircles { @@ -64,6 +68,7 @@ impl Squircles { Self::ChromiumApprox => "Chromium approximation", Self::Clothoid => "Clothoid", Self::Figma => "Figma", + Self::Apple => "Apple", } } } @@ -75,6 +80,7 @@ impl Squircle for Squircles { Self::ChromiumApprox => ChromiumApprox.render(params), Self::Clothoid => ClothoidSquircle.render(params), Self::Figma => FigmaSquircle.render(params), + Self::Apple => AppleSquircle.render(params), } } @@ -84,6 +90,7 @@ impl Squircle for Squircles { Self::ChromiumApprox => ChromiumApprox.curvature_profile(params), Self::Clothoid => ClothoidSquircle.curvature_profile(params), Self::Figma => FigmaSquircle.curvature_profile(params), + Self::Apple => AppleSquircle.curvature_profile(params), } } } diff --git a/crates/squircle/src/view.rs b/crates/squircle/src/view.rs index 74d96a77..ffba6f4c 100644 --- a/crates/squircle/src/view.rs +++ b/crates/squircle/src/view.rs @@ -183,6 +183,7 @@ fn controls(state: &AppState) -> impl DomView + use<> { choice_radio(state, Squircles::ChromiumApprox), choice_radio(state, Squircles::Clothoid), choice_radio(state, Squircles::Figma), + choice_radio(state, Squircles::Apple), )) .class("squircle-choices"); From 412009d881b774b4959dbfd95dabf6f7b66c80cd Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 05:51:34 +0000 Subject: [PATCH 3/9] Add flat sides to the squircle tester A shape is a corner profile scaled into the square left by a straight run along each half edge. Corner::resolve maps the two sliders onto that; quadrant and quadrant_profile assemble the shape and its curvature. The gauge always holds, so the radio buttons compare constructions at one gauge. The flat picks the corner within what the gauge allows; where a construction's family has no such corner it makes the nearest one and the flat takes up the difference. The clothoid tops out near 0.79 and Apple has a single corner, so for those the flat is largely determined. The superellipse and the Chromium approximation take the gauge directly. The clothoid and Figma take a smoothness instead, so those are solved for with solve_itp against the measured diagonal crossing. Show the superellipse exponent where it applies, greyed elsewhere. Fix Superellipse::curvature_profile, which did not pin its endpoint the way render does: the last theta overshoots FRAC_PI_2, and powf of the negative cosine put a NaN into the arc length. The curvature plot's arc length axis is now absolute rather than fitted, with a tick where the curve ends. --- crates/squircle/squircle-demo.css | 11 ++ crates/squircle/src/apple_squircle.rs | 7 + crates/squircle/src/clothoid_squircle.rs | 4 +- crates/squircle/src/lib.rs | 14 +- crates/squircle/src/squircle.rs | 195 ++++++++++++++++++- crates/squircle/src/view.rs | 228 ++++++++++++++++------- 6 files changed, 386 insertions(+), 73 deletions(-) diff --git a/crates/squircle/squircle-demo.css b/crates/squircle/squircle-demo.css index fac2ceff..6c3b8350 100644 --- a/crates/squircle/squircle-demo.css +++ b/crates/squircle/squircle-demo.css @@ -178,3 +178,14 @@ color: var(--fg2-color, #767676); font-size: 0.85rem; } + +/* A construction whose corner is fixed has no gauge to set; the control stays + in place as a readout rather than vanishing and reflowing the row. */ +#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 index e798285a..f6fb20fd 100644 --- a/crates/squircle/src/apple_squircle.rs +++ b/crates/squircle/src/apple_squircle.rs @@ -34,6 +34,13 @@ 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 diff --git a/crates/squircle/src/clothoid_squircle.rs b/crates/squircle/src/clothoid_squircle.rs index 880b4004..979d4bac 100644 --- a/crates/squircle/src/clothoid_squircle.rs +++ b/crates/squircle/src/clothoid_squircle.rs @@ -3,7 +3,7 @@ use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, FRAC_PI_4}; -use xilem_web::svg::kurbo::{Affine, BezPath, Point, Vec2}; +use xilem_web::svg::kurbo::{BezPath, Point, Vec2}; use crate::{euler::{EulerParams, EulerSeg}, squircle::{ProfileSample, Squircle}}; @@ -86,4 +86,4 @@ impl Squircle for ClothoidSquircle { result.push(ProfileSample::new(2.0 * es_s + arc_s, 0.0)); result } -} \ No newline at end of file +} diff --git a/crates/squircle/src/lib.rs b/crates/squircle/src/lib.rs index e3984eaa..d0bfabbf 100644 --- a/crates/squircle/src/lib.rs +++ b/crates/squircle/src/lib.rs @@ -29,12 +29,23 @@ use crate::squircle::Squircles; pub(crate) struct AppState { /// Which construction is drawn against the superellipse reference. choice: Squircles, - /// Shape parameter, in the range [0.707, 0.999]. + /// Overall gauge, between `squircle::GAUGE_MIN` and `squircle::GAUGE_MAX`. /// /// This is the "superellipse gauge": the distance from the centre to the /// shape along the 45 degree diagonal, as a fraction of the half-width. A /// circle is `FRAC_1_SQRT_2`, a square is 1. + /// + /// Ignored for constructions whose corner is fixed, which derive their + /// gauge from [`AppState::flat`] instead; see + /// [`Corner::resolve`](crate::squircle::Corner::resolve). gauge: f64, + /// How much straight run to put along each edge, as a fraction of the most + /// the current gauge allows, in the range [0, 1]. + /// + /// It is a fraction rather than a length because the two are not + /// independent; [`Corner::resolve`](crate::squircle::Corner::resolve) + /// explains why. + flat: f64, /// Draw a single corner rather than the whole four-fold shape. zoom: bool, } @@ -44,6 +55,7 @@ impl Default for AppState { Self { choice: Squircles::default(), gauge: 0.841, + flat: 0.0, zoom: false, } } diff --git a/crates/squircle/src/squircle.rs b/crates/squircle/src/squircle.rs index 125bdc27..8f78b7ca 100644 --- a/crates/squircle/src/squircle.rs +++ b/crates/squircle/src/squircle.rs @@ -3,10 +3,11 @@ //! Generic trait for squircles -use std::f64::consts::{FRAC_PI_2, SQRT_2}; +use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, LN_2, SQRT_2}; use xilem_web::svg::kurbo::{ - BezPath, ParamCurve, ParamCurveArclen, ParamCurveCurvature, PathSeg, Point, + Affine, BezPath, Line, ParamCurve, ParamCurveArclen, ParamCurveCurvature, PathSeg, Point, + common::solve_itp, }; use crate::{ @@ -50,6 +51,136 @@ pub trait Squircle { } } +/// 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; + +/// The roundest corner the parametric constructions reach, where each becomes +/// a circular arc. +const CORNER_MIN: f64 = FRAC_1_SQRT_2; + +/// The two numbers that actually determine a shape. +/// +/// A shape is a straight run along each half edge plus a corner profile scaled +/// into the square that is left over, which gives `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, so switching construction never moves it. The + /// flat picks the corner within what the gauge allows, since + /// `gauge = c + h * (1 - c)` and `c` cannot go below a circular corner. A + /// flat of 1 is always exactly a circular corner. + /// + /// Where the construction's family has no corner that round or that square, + /// it makes the nearest one it can and the flat takes up the difference, so + /// the gauge still holds. Apple is that case at its extreme: one corner, so + /// the flat is entirely determined. + /// + /// This is the only place the parameterization is decided. + 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, + } + } + + /// Where the assembled shape crosses its diagonal. + pub fn gauge(self) -> f64 { + self.c + self.h * (1.0 - self.c) + } +} + +/// The exponent `n` of the superellipse `|x|^n + |y|^n = 1` whose quadrant +/// crosses its diagonal at `c`. +/// +/// This inverts the `exp_adjust` that [`Superellipse`] derives from its gauge: +/// a circular corner is 2, and the familiar Lame curve behind most squircles +/// is 4, at a gauge of about 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 a curve by `s` multiplies arc length by `s` and divides curvature by +/// `s`, so the corner's own profile transforms directly and does not have to be +/// resampled off the assembled path. +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 any non-finite sample, but the trailing flat still + // has to be placed, so the corner's length comes from the last sample that + // carries one rather than from whatever the profile happens to end on. + 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] @@ -71,6 +202,58 @@ impl Squircles { Self::Apple => "Apple", } } + + /// Whether this construction is a superellipse, or an approximation close + /// enough to one that the superellipse exponent describes its corner. + pub fn has_exponent(self) -> bool { + matches!(self, Self::Superellipse | Self::ChromiumApprox) + } + + /// The corner nearest `c` this construction can make, and the parameter + /// that makes it. + /// + /// The superellipse and the Chromium approximation take the gauge as is. + /// Apple has one corner. The clothoid and Figma take a smoothness, so those + /// are solved for; the clothoid's family stops around 0.79, well short of + /// the slider, and returns its nearest corner instead. + 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 gauge this construction is pinned to, for constructions that + /// have no shape parameter of their own. + pub fn fixed_corner(self) -> Option { + match self { + Self::Apple => Some(crate::apple_squircle::CORNER_GAUGE), + _ => None, + } + } } impl Squircle for Squircles { @@ -136,7 +319,13 @@ impl Squircle for Superellipse { // constructions that use the default `curvature_profile`. for i in 0..=N { let th = i as f64 * (FRAC_PI_2 / N as f64); - let (v, u) = th.sin_cos(); + // Pinned at the end exactly as `render` pins it. The last `th` + // lands a ulp past `FRAC_PI_2`, so `cos` returns about -1.2e-16, + // and `powf` of a negative base to a fractional power is NaN. That + // NaN reaches `s` through the chord below and stays there, so the + // sample carrying the end of the quadrant is dropped rather than + // plotted. + 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); diff --git a/crates/squircle/src/view.rs b/crates/squircle/src/view.rs index ffba6f4c..0c5f37aa 100644 --- a/crates/squircle/src/view.rs +++ b/crates/squircle/src/view.rs @@ -20,14 +20,17 @@ use xilem_web::{ }, interfaces::{Element, HtmlInputElement, SvgGeometryElement, SvgPathElement}, svg::{ - kurbo::{Affine, BezPath, Shape, Stroke}, + kurbo::{Affine, BezPath, Point, Shape, Stroke}, peniko::color::palette::css, }, }; use crate::{ AppState, - squircle::{Squircle, Squircles, Superellipse, render_profile}, + squircle::{ + Corner, GAUGE_MAX, GAUGE_MIN, Squircle, Squircles, Superellipse, quadrant, + quadrant_profile, render_profile, superellipse_exponent, + }, }; /// Side of the square `viewBox` the shape is drawn into. @@ -42,6 +45,41 @@ const PLOT_H: f64 = 300.0; /// Number of gridline divisions along each axis of the curvature plot. const PLOT_DIVISIONS: usize = 4; +/// A tick up from the arc length axis at `s_end`, where the curve stops. +/// +/// Axis furniture, and drawn in the axis' own style: it reports where the +/// quadrant ends, which is not something the curvature profile says about +/// itself. The curve's trailing flat runs along the axis, so without this the +/// end of one line over another is hard to place. +/// +/// Built in `viewBox` units rather than plot units, so it keeps one size as the +/// curvature axis rescales. It rises from the axis and does not cross below it. +/// Every construction here is convex, so curvature is never negative and the +/// plot has no use for the space under the axis; keeping the one mark that +/// could stray down out of it is a quiet reminder of that. +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 runs from (1, 0) to (0, 1) moving monotonically in both +/// coordinates inside the unit square, so it is at most 2 long, and only the +/// degenerate square corner reaches that. The axis is pinned to that bound +/// rather than fitted to the profile, because the actual length varies with +/// both sliders: an axis that tracked it would rescale under the cursor during +/// a drag, and two settings could not be compared by eye. Holding it absolute +/// is also what lets the flats be read off the plot at their true length, +/// rather than as a share of a total that is itself moving. +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 @@ -66,28 +104,6 @@ fn curvature_axis_max(peak: f64) -> f64 { CURVATURE_STEPS[CURVATURE_STEPS.len() - 1] } -/// Rounds `value` up to the next 1, 2 or 5 times a power of ten. -/// -/// Used for the arc length axis, which varies only between about 1.6 and 2 and -/// so settles on one value in practice. -fn nice_ceil(value: f64) -> f64 { - if !(value > 0.0) || !value.is_finite() { - return 1.0; - } - let magnitude = 10f64.powf(value.log10().floor()); - let normalized = value / magnitude; - let step = if normalized <= 1.0 { - 1.0 - } else if normalized <= 2.0 { - 2.0 - } else if normalized <= 5.0 { - 5.0 - } else { - 10.0 - }; - step * magnitude -} - /// 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; @@ -165,7 +181,7 @@ fn choice_radio(state: &AppState, choice: Squircles) -> impl DomView + label(( input(()) .type_("radio") - .attr("name", "squircle-choice") + .name("squircle-choice") .checked(state.choice == choice) .on_input(move |state: &mut AppState, _| state.choice = choice), choice.name(), @@ -173,9 +189,61 @@ fn choice_radio(state: &AppState, choice: Squircles) -> impl DomView + .class("squircle-choice") } -/// The radio group, zoom toggle and gauge slider. -fn controls(state: &AppState) -> impl DomView + use<> { - let gauge = state.gauge; +/// 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. +/// +/// A greyed row rather than a hidden one, so that switching construction does +/// not reflow the controls under the cursor. +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 radio group, zoom toggle, the two shape sliders and the exponent. +fn controls(state: &AppState, corner: Corner) -> impl DomView + use<> { + // Apple's corner is fixed, so its flat follows the gauge rather than + // setting it, and that slider becomes a readout. + let flat_fixed = state.choice.fixed_corner().is_some(); let choices = div(( span("Construction").class("squircle-control-label"), @@ -196,36 +264,54 @@ fn controls(state: &AppState) -> impl DomView + use<> { )) .class("squircle-choice"); - let slider = div(( - label("Gauge") - .attr("for", "squircle-gauge") - .class("squircle-control-label"), - input(()) - .attr("id", "squircle-gauge") - .type_("range") - .attr("min", "707") - .attr("max", "999") - .attr("step", "1") - .attr("value", (gauge * 1000.0).round() as i32) - .on_input(|state: &mut AppState, event| { - if let Some(value) = input_event_target_value(&event) { - if let Ok(parsed) = value.parse::() { - state.gauge = parsed * 1e-3; - } - } - }), - span(format!("{gauge:.3}")).class("squircle-readout"), - )) - .class("squircle-slider"); + // 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, + ); + + // Only a superellipse, or something approximating one, has an exponent; + // for the rest there is no number to show rather than a number to grey. + 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, slider)).class("squircle-controls") + div((choices, zoom, gauge_slider, flat_slider, exponent)).class("squircle-controls") } /// The shape panel: the selected construction over a superellipse reference. -fn shape_panel(state: &AppState) -> impl DomView + use<> { - let params = [state.gauge]; - let mut shape = state.choice.render(¶ms); - let mut reference = Superellipse.render(¶ms); +fn shape_panel(state: &AppState, corner: Corner) -> impl DomView + use<> { + let mut shape = quadrant(state.choice, corner); + // The reference stays a plain superellipse at the gauge the shape actually + // has, so it is a like-for-like comparison of the diagonal crossing. + let mut reference = Superellipse.render(&[corner.gauge()]); if !state.zoom { shape = quadruple_up(&shape); reference = quadruple_up(&reference); @@ -262,24 +348,24 @@ fn shape_panel(state: &AppState) -> impl DomView + use<> { } /// The curvature panel: curvature against arc length along one quadrant. -fn curvature_panel(state: &AppState) -> impl DomView + use<> { - let params = [state.gauge]; - let profile = render_profile(&state.choice.curvature_profile(¶ms)); +fn curvature_panel(state: &AppState, corner: Corner) -> impl DomView + use<> { + let profile = render_profile(&quadrant_profile(state.choice, corner)); - // The profile is a polyline in (arc length, curvature) space, so its - // bounding box is exactly the data range the axes have to cover. + // Only the curvature axis is fitted to the data; arc length keeps the + // absolute [`PLOT_S_MAX`] whatever the sliders do. let bounds = profile.bounding_box(); - let s_max = nice_ceil(bounds.x1); let k_max = curvature_axis_max(bounds.y1); let overflows = bounds.y1 > k_max; - let transform = plot_transform(s_max, k_max); + // 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(s_max, k_max)) + (transform * grid_path(PLOT_S_MAX, k_max)) .stroke(css::GAINSBORO, Stroke::new(1.0)) .fill(css::TRANSPARENT) .class("squircle-grid"), - (transform * axes_path(s_max, k_max)) + (transform * axes_path(PLOT_S_MAX, k_max)) .stroke(css::GRAY, Stroke::new(1.5)) .fill(css::TRANSPARENT) .class("squircle-axes"), @@ -287,6 +373,11 @@ fn curvature_panel(state: &AppState) -> impl DomView + use<> { .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"]), ))) .attr("viewBox", format!("0 0 {PLOT_W} {PLOT_H}")) .class("squircle-figure") @@ -302,8 +393,9 @@ fn curvature_panel(state: &AppState) -> impl DomView + use<> { String::new() }; let caption = div(format!( - "Curvature, 0 to {k_max}, against arc length along one quadrant, 0 to {s_max}. \ - Gridlines divide each axis into {PLOT_DIVISIONS} equal parts.{overflow_note}" + "Curvature, 0 to {k_max}, against arc length along one quadrant, 0 to {PLOT_S_MAX}. \ + Gridlines divide each axis into {PLOT_DIVISIONS} equal parts. A tick on \ + the arc length axis marks the end of the curve, at {s_end:.2}.{overflow_note}" )) .class("squircle-caption"); @@ -312,9 +404,11 @@ fn curvature_panel(state: &AppState) -> impl DomView + use<> { /// 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), - div((shape_panel(state), curvature_panel(state))) + controls(state, corner), + div((shape_panel(state, corner), curvature_panel(state, corner))) .class("squircle-panels"), )) .class("squircle-demo") From fee449af15612e3d32578f4d9905334b3059f2bc Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 06:36:01 +0000 Subject: [PATCH 4/9] Add a fill option and label the curvature axis Drop the superellipse reference and its legend, which were a development aid rather than something to ship. Add a fill checkbox. A zoomed quadrant is an open path, so filling that closes it through the centre. Label the curvature axis at each gridline, and drop the caption under the plot in favour of explaining it in prose. --- crates/squircle/squircle-demo.css | 57 ++++------------ crates/squircle/src/lib.rs | 3 + crates/squircle/src/squircle.rs | 5 -- crates/squircle/src/view.rs | 105 ++++++++++++++++-------------- 4 files changed, 73 insertions(+), 97 deletions(-) diff --git a/crates/squircle/squircle-demo.css b/crates/squircle/squircle-demo.css index 6c3b8350..4d8c2bf7 100644 --- a/crates/squircle/squircle-demo.css +++ b/crates/squircle/squircle-demo.css @@ -10,10 +10,8 @@ */ #squircle-demo-root { - /* One place to set the palette; the SVG paths and the legend swatches both - read these, so they can never drift apart. */ + /* One place to set the palette, so the SVG paths can never drift apart. */ --squircle-shape: #0b5fbd; - --squircle-reference: #b5530c; --squircle-grid: #e0e0e0; --squircle-axis: #8a8a8a; @@ -23,7 +21,6 @@ @media (prefers-color-scheme: dark) { #squircle-demo-root { --squircle-shape: #79b0f2; - --squircle-reference: #f0a35e; --squircle-grid: #4a4a4a; --squircle-axis: #909090; } @@ -114,21 +111,19 @@ } #squircle-demo-root .squircle-path { - fill: none; stroke-linecap: round; stroke-linejoin: round; } #squircle-demo-root .squircle-path--shape, #squircle-demo-root .squircle-path--curvature { + fill: none; stroke: var(--squircle-shape); } -/* Dashed as well as differently colored, so the two curves stay distinguishable - in print and for readers with a color vision deficiency. */ -#squircle-demo-root .squircle-path--reference { - stroke: var(--squircle-reference); - stroke-dasharray: 7 5; +#squircle-demo-root .squircle-path--fill { + fill: var(--squircle-shape); + stroke: none; } #squircle-demo-root .squircle-grid { @@ -136,51 +131,25 @@ 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-legend { - display: flex; - flex-wrap: wrap; - gap: 0.25rem 1rem; - margin-top: 0.5rem; - font-size: 0.85rem; -} - -#squircle-demo-root .squircle-key { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -#squircle-demo-root .squircle-key::before { - content: ""; - width: 1.4rem; - height: 0; - border-top-width: 3px; - border-top-style: solid; -} - -#squircle-demo-root .squircle-key--shape::before { - border-top-color: var(--squircle-shape); -} - -#squircle-demo-root .squircle-key--reference::before { - border-top-color: var(--squircle-reference); - border-top-style: dashed; -} - -#squircle-demo-root .squircle-caption, #squircle-demo-root .squircle-fallback { margin-top: 0.5rem; color: var(--fg2-color, #767676); font-size: 0.85rem; } -/* A construction whose corner is fixed has no gauge to set; the control stays - in place as a readout rather than vanishing and reflowing the row. */ +/* A derived control stays in place as a readout rather than vanishing and + reflowing the row. */ #squircle-demo-root .squircle-slider input[type="range"]:disabled { opacity: 0.4; } diff --git a/crates/squircle/src/lib.rs b/crates/squircle/src/lib.rs index d0bfabbf..b593d40a 100644 --- a/crates/squircle/src/lib.rs +++ b/crates/squircle/src/lib.rs @@ -48,6 +48,8 @@ pub(crate) struct AppState { 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 { @@ -57,6 +59,7 @@ impl Default for AppState { gauge: 0.841, flat: 0.0, zoom: false, + fill: false, } } } diff --git a/crates/squircle/src/squircle.rs b/crates/squircle/src/squircle.rs index 8f78b7ca..99839c87 100644 --- a/crates/squircle/src/squircle.rs +++ b/crates/squircle/src/squircle.rs @@ -99,11 +99,6 @@ impl Corner { param, } } - - /// Where the assembled shape crosses its diagonal. - pub fn gauge(self) -> f64 { - self.c + self.h * (1.0 - self.c) - } } /// The exponent `n` of the superellipse `|x|^n + |y|^n = 1` whose quadrant diff --git a/crates/squircle/src/view.rs b/crates/squircle/src/view.rs index 0c5f37aa..e4170b68 100644 --- a/crates/squircle/src/view.rs +++ b/crates/squircle/src/view.rs @@ -16,7 +16,7 @@ use xilem_web::{ DomView, input_event_target_value, elements::{ html::{div, input, label, span}, - svg::{g, svg}, + svg::{g, svg, text}, }, interfaces::{Element, HtmlInputElement, SvgGeometryElement, SvgPathElement}, svg::{ @@ -28,8 +28,8 @@ use xilem_web::{ use crate::{ AppState, squircle::{ - Corner, GAUGE_MAX, GAUGE_MIN, Squircle, Squircles, Superellipse, quadrant, - quadrant_profile, render_profile, superellipse_exponent, + Corner, GAUGE_MAX, GAUGE_MIN, Squircles, quadrant, quadrant_profile, render_profile, + superellipse_exponent, }, }; @@ -45,6 +45,29 @@ 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. /// /// Axis furniture, and drawn in the axis' own style: it reports where the @@ -264,6 +287,15 @@ fn controls(state: &AppState, corner: Corner) -> impl DomView + use<> )) .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( @@ -303,48 +335,37 @@ fn controls(state: &AppState, corner: Corner) -> impl DomView + use<> !has_exponent, ); - div((choices, zoom, gauge_slider, flat_slider, exponent)).class("squircle-controls") + div((choices, zoom, fill, gauge_slider, flat_slider, exponent)).class("squircle-controls") } -/// The shape panel: the selected construction over a superellipse reference. +/// The shape panel. fn shape_panel(state: &AppState, corner: Corner) -> impl DomView + use<> { let mut shape = quadrant(state.choice, corner); - // The reference stays a plain superellipse at the gauge the shape actually - // has, so it is a like-for-like comparison of the diagonal crossing. - let mut reference = Superellipse.render(&[corner.gauge()]); - if !state.zoom { + 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); - reference = quadruple_up(&reference); } - let transform = shape_transform(); - let drawing = svg(g(( - (transform * reference) - .stroke(css::DARK_ORANGE, Stroke::new(1.5)) - .fill(css::TRANSPARENT) - .class(["squircle-path", "squircle-path--reference"]), - (transform * shape) - .stroke(css::STEEL_BLUE, Stroke::new(2.0)) - .fill(css::TRANSPARENT) - .class(["squircle-path", "squircle-path--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 drawn over a superellipse of the same gauge", - ); + .attr("aria-label", "The selected squircle construction"); - let key = div(( - span("Selected construction") - .class(["squircle-key", "squircle-key--shape"]), - span("Superellipse reference") - .class(["squircle-key", "squircle-key--reference"]), - )) - .class("squircle-legend"); - - div((drawing, key)).class("squircle-panel") + div(drawing).class("squircle-panel") } /// The curvature panel: curvature against arc length along one quadrant. @@ -355,7 +376,6 @@ fn curvature_panel(state: &AppState, corner: Corner) -> impl DomView + // absolute [`PLOT_S_MAX`] whatever the sliders do. let bounds = profile.bounding_box(); let k_max = curvature_axis_max(bounds.y1); - let overflows = bounds.y1 > k_max; // 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); @@ -378,6 +398,7 @@ fn curvature_panel(state: &AppState, corner: Corner) -> impl DomView + .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") @@ -387,19 +408,7 @@ fn curvature_panel(state: &AppState, corner: Corner) -> impl DomView + "Curvature plotted against arc length along one quadrant", ); - let overflow_note = if overflows { - format!(" The peak of {:.0} runs off the top.", bounds.y1) - } else { - String::new() - }; - let caption = div(format!( - "Curvature, 0 to {k_max}, against arc length along one quadrant, 0 to {PLOT_S_MAX}. \ - Gridlines divide each axis into {PLOT_DIVISIONS} equal parts. A tick on \ - the arc length axis marks the end of the curve, at {s_end:.2}.{overflow_note}" - )) - .class("squircle-caption"); - - div((drawing, caption)).class("squircle-panel") + div(drawing).class("squircle-panel") } /// Top-level view. From 36b05063ce90b1ff43c9f4c80fc42afa00a0c811 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 07:17:11 +0000 Subject: [PATCH 5/9] Trim comments, and drop the variant group label The radio buttons read as a choice without a heading; the name survives as an aria-label. Also corrects two stale doc comments on AppState: the gauge is no longer derived where the corner is fixed, and the superellipse reference is gone. --- crates/squircle/squircle-demo.css | 2 - crates/squircle/src/lib.rs | 22 +++-------- crates/squircle/src/squircle.rs | 63 +++++++++++-------------------- crates/squircle/src/view.rs | 44 +++++++-------------- 4 files changed, 41 insertions(+), 90 deletions(-) diff --git a/crates/squircle/squircle-demo.css b/crates/squircle/squircle-demo.css index 4d8c2bf7..0ffc3945 100644 --- a/crates/squircle/squircle-demo.css +++ b/crates/squircle/squircle-demo.css @@ -148,8 +148,6 @@ font-size: 0.85rem; } -/* A derived control stays in place as a readout rather than vanishing and - reflowing the row. */ #squircle-demo-root .squircle-slider input[type="range"]:disabled { opacity: 0.4; } diff --git a/crates/squircle/src/lib.rs b/crates/squircle/src/lib.rs index b593d40a..225004ae 100644 --- a/crates/squircle/src/lib.rs +++ b/crates/squircle/src/lib.rs @@ -27,24 +27,14 @@ use crate::squircle::Squircles; /// The state driving the tester. #[derive(Debug)] pub(crate) struct AppState { - /// Which construction is drawn against the superellipse reference. + /// Which construction is drawn. choice: Squircles, - /// Overall gauge, between `squircle::GAUGE_MIN` and `squircle::GAUGE_MAX`. - /// - /// This is the "superellipse gauge": the distance from the centre to the - /// shape along the 45 degree diagonal, as a fraction of the half-width. A - /// circle is `FRAC_1_SQRT_2`, a square is 1. - /// - /// Ignored for constructions whose corner is fixed, which derive their - /// gauge from [`AppState::flat`] instead; see - /// [`Corner::resolve`](crate::squircle::Corner::resolve). + /// 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, - /// How much straight run to put along each edge, as a fraction of the most - /// the current gauge allows, in the range [0, 1]. - /// - /// It is a fraction rather than a length because the two are not - /// independent; [`Corner::resolve`](crate::squircle::Corner::resolve) - /// explains why. + /// 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, diff --git a/crates/squircle/src/squircle.rs b/crates/squircle/src/squircle.rs index 99839c87..3e683284 100644 --- a/crates/squircle/src/squircle.rs +++ b/crates/squircle/src/squircle.rs @@ -56,14 +56,11 @@ pub const GAUGE_MIN: f64 = 0.707; /// Largest overall gauge the tester offers. pub const GAUGE_MAX: f64 = 0.999; -/// The roundest corner the parametric constructions reach, where each becomes -/// a circular arc. +/// A circular corner, the roundest any of these reach. const CORNER_MIN: f64 = FRAC_1_SQRT_2; -/// The two numbers that actually determine a shape. -/// -/// A shape is a straight run along each half edge plus a corner profile scaled -/// into the square that is left over, which gives `gauge = c + h * (1 - c)`. +/// 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. @@ -78,17 +75,10 @@ pub struct Corner { impl Corner { /// Turns the two slider positions into a corner. /// - /// The gauge always holds, so switching construction never moves it. The - /// flat picks the corner within what the gauge allows, since - /// `gauge = c + h * (1 - c)` and `c` cannot go below a circular corner. A - /// flat of 1 is always exactly a circular corner. - /// - /// Where the construction's family has no corner that round or that square, - /// it makes the nearest one it can and the flat takes up the difference, so - /// the gauge still holds. Apple is that case at its extreme: one corner, so - /// the flat is entirely determined. - /// - /// This is the only place the parameterization is decided. + /// 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; @@ -104,9 +94,7 @@ impl Corner { /// The exponent `n` of the superellipse `|x|^n + |y|^n = 1` whose quadrant /// crosses its diagonal at `c`. /// -/// This inverts the `exp_adjust` that [`Superellipse`] derives from its gauge: -/// a circular corner is 2, and the familiar Lame curve behind most squircles -/// is 4, at a gauge of about 0.841. +/// 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() } @@ -143,9 +131,8 @@ pub fn quadrant(choice: Squircles, corner: Corner) -> BezPath { /// The curvature profile of the assembled quadrant. /// -/// Scaling a curve by `s` multiplies arc length by `s` and divides curvature by -/// `s`, so the corner's own profile transforms directly and does not have to be -/// resampled off the assembled path. +/// 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]); @@ -153,9 +140,8 @@ pub fn quadrant_profile(choice: Squircles, corner: Corner) -> Vec return inner; } let scale = 1.0 - h; - // `render_profile` drops any non-finite sample, but the trailing flat still - // has to be placed, so the corner's length comes from the last sample that - // carries one rather than from whatever the profile happens to end on. + // `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() @@ -198,19 +184,16 @@ impl Squircles { } } - /// Whether this construction is a superellipse, or an approximation close - /// enough to one that the superellipse exponent describes its corner. + /// 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 - /// that makes it. + /// The corner nearest `c` this construction can make, and the parameter for it. /// - /// The superellipse and the Chromium approximation take the gauge as is. - /// Apple has one corner. The clothoid and Figma take a smoothness, so those - /// are solved for; the clothoid's family stops around 0.79, well short of - /// the slider, and returns its nearest corner instead. + /// 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), @@ -241,8 +224,7 @@ impl Squircles { } } - /// The corner gauge this construction is pinned to, for constructions that - /// have no shape parameter of their own. + /// 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), @@ -314,12 +296,9 @@ impl Squircle for Superellipse { // constructions that use the default `curvature_profile`. for i in 0..=N { let th = i as f64 * (FRAC_PI_2 / N as f64); - // Pinned at the end exactly as `render` pins it. The last `th` - // lands a ulp past `FRAC_PI_2`, so `cos` returns about -1.2e-16, - // and `powf` of a negative base to a fractional power is NaN. That - // NaN reaches `s` through the chord below and stays there, so the - // sample carrying the end of the quadrant is dropped rather than - // plotted. + // 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); diff --git a/crates/squircle/src/view.rs b/crates/squircle/src/view.rs index e4170b68..bb2379f2 100644 --- a/crates/squircle/src/view.rs +++ b/crates/squircle/src/view.rs @@ -70,16 +70,10 @@ fn curvature_labels(transform: Affine, k_max: f64) -> impl DomView + u /// A tick up from the arc length axis at `s_end`, where the curve stops. /// -/// Axis furniture, and drawn in the axis' own style: it reports where the -/// quadrant ends, which is not something the curvature profile says about -/// itself. The curve's trailing flat runs along the axis, so without this the -/// end of one line over another is hard to place. -/// -/// Built in `viewBox` units rather than plot units, so it keeps one size as the -/// curvature axis rescales. It rises from the axis and does not cross below it. -/// Every construction here is convex, so curvature is never negative and the -/// plot has no use for the space under the axis; keeping the one mark that -/// could stray down out of it is a quiet reminder of that. +/// 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(); @@ -90,14 +84,10 @@ fn end_tick(transform: Affine, s_end: f64) -> BezPath { /// Maximum of the curvature plot's arc length axis. /// -/// A quadrant runs from (1, 0) to (0, 1) moving monotonically in both -/// coordinates inside the unit square, so it is at most 2 long, and only the -/// degenerate square corner reaches that. The axis is pinned to that bound -/// rather than fitted to the profile, because the actual length varies with -/// both sliders: an axis that tracked it would rescale under the cursor during -/// a drag, and two settings could not be compared by eye. Holding it absolute -/// is also what lets the flats be read off the plot at their true length, -/// rather than as a share of a total that is itself moving. +/// 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. @@ -246,9 +236,6 @@ fn slider( } /// One label-and-value row, for a number the tester reports but cannot set. -/// -/// A greyed row rather than a hidden one, so that switching construction does -/// not reflow the controls under the cursor. fn readout_row( caption: &'static str, value: String, @@ -262,21 +249,21 @@ fn readout_row( .class("squircle-slider") } -/// The radio group, zoom toggle, the two shape sliders and the exponent. +/// The control row above the panels. fn controls(state: &AppState, corner: Corner) -> impl DomView + use<> { - // Apple's corner is fixed, so its flat follows the gauge rather than - // setting it, and that slider becomes a readout. + // Apple's corner is fixed, so its flat follows from the gauge. let flat_fixed = state.choice.fixed_corner().is_some(); let choices = div(( - span("Construction").class("squircle-control-label"), 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"); + .class("squircle-choices") + .attr("role", "radiogroup") + .attr("aria-label", "Variant"); let zoom = label(( input(()) @@ -322,8 +309,6 @@ fn controls(state: &AppState, corner: Corner) -> impl DomView + use<> |state, t| state.flat = t, ); - // Only a superellipse, or something approximating one, has an exponent; - // for the rest there is no number to show rather than a number to grey. let has_exponent = state.choice.has_exponent(); let exponent = readout_row( "Exponent", @@ -372,8 +357,7 @@ fn shape_panel(state: &AppState, corner: Corner) -> impl DomView + use 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 to the data; arc length keeps the - // absolute [`PLOT_S_MAX`] whatever the sliders do. + // 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. From 598f8ec5574cfb4faa51747f62afa8ec72dcd189 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 19:03:30 +1000 Subject: [PATCH 6/9] Include actual article text This an edited version of the draft I had posted to Zulip. --- content/wiki/curves/squircle.md | 148 +++++++++++++++++++++++++++----- 1 file changed, 127 insertions(+), 21 deletions(-) diff --git a/content/wiki/curves/squircle.md b/content/wiki/curves/squircle.md index 0947cd0c..4dcebc65 100644 --- a/content/wiki/curves/squircle.md +++ b/content/wiki/curves/squircle.md @@ -2,20 +2,10 @@ title = "Squircles" +++ -A squircle is a rounded rectangle whose corners blend into the straight edges without a visible seam. -A conventional rounded rectangle joins a circular arc to a line, which leaves curvature jumping from $1/r$ to zero at the join. -The eye picks that discontinuity up as a crease, so a number of different constructions have been proposed to smooth it out. +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 are parametrized, and can vary between circle and square. -This page compares four of them. -They are close enough in shape to be hard to tell apart directly, so the tester below also plots curvature against arc length, where the differences are obvious. - -## The gauge parameter - -All four constructions are controlled here by a single shape parameter, the *superellipse gauge*. -Working in a unit square, with the shape inscribed so that it touches $(1, 0)$ and $(0, 1)$, the gauge $g$ is the coordinate at which the shape crosses the diagonal: the curve passes through $(g, g)$. -A circle gives $g = 1/\sqrt{2} \approx 0.707$ and a square gives $g = 1$. - -This is the same quantity as the "Superellipse" column in [Curve families](@/wiki/curve_families.md), which makes it a convenient common axis for comparing families that are otherwise parameterized quite differently.

Loading the interactive tester…

@@ -44,18 +34,134 @@ This is the same quantity as the "Superellipse" column in [Curve families](@/wik ## Superellipse -TODO: the defining equation, the relation between the exponent and the gauge, and why the curvature goes to zero at the axes. +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, including [Desperately Seeking Squircles](https://www.figma.com/blog/desperately-seeking-squircles/) from Figma. +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 + +Early analysis of the Apple squircle shape suggested a superellipse with exponent 5. +This analysis + +## 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 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 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. + +* Wikipedia has a “periodic squircle” which has very similar behavior to the Fernández-Guasti one (they are visually near indistinguishable). -## Chromium approximation +## References -TODO: the two-cubic fit from [Implementing corner-shape], and how closely it tracks the true superellipse. +* There’s a very detailed exploration of superellipses and Bézier approximations at [Goodbye Circles, Hello Squircles: Perfect Corners in CSS & Canvas](https://orgpad.info/blog/squircles). +(not sure I’ll include this; there’s lots of detail but some things are iffy.) -## Clothoid +* The Wikipedia page on [squircles](https://en.wikipedia.org/wiki/Squircle). -TODO: a clothoid in from the edge, a circular arc through the corner, a clothoid back out, with the split between them as the smoothness parameter. +## Discussion questions: -## Figma +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. -TODO: the corner-smoothing construction, and how it differs from both of the above. +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. -[Implementing corner-shape]: https://developer.chrome.com/blog/implementing-corner-shape +[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/ From b40533982d0253b3a7e246f8f96220ec23855dd5 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 10:11:24 +0000 Subject: [PATCH 7/9] Add the Apple-like corner-shape section A superellipse with exponent 3 and a flat side chosen to match the gauge is a very close match to the Apple shape. Figure was generated by Claude. --- .../curves/squircle/apple_vs_superellipse.svg | 18 ++++++++++++++++++ .../curves/{squircle.md => squircle/index.md} | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 content/wiki/curves/squircle/apple_vs_superellipse.svg rename content/wiki/curves/{squircle.md => squircle/index.md} (92%) 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 @@ + + + +Apple +gauge 0.809 + +superellipse, exponent 3 +flat sides, border-radius 45.6% + diff --git a/content/wiki/curves/squircle.md b/content/wiki/curves/squircle/index.md similarity index 92% rename from content/wiki/curves/squircle.md rename to content/wiki/curves/squircle/index.md index 4dcebc65..8c2d4e2b 100644 --- a/content/wiki/curves/squircle.md +++ b/content/wiki/curves/squircle/index.md @@ -120,7 +120,24 @@ It is exact for placing the midpoint (this is part of the formula), so is well c ### Apple-like behavior with the corner-shape spec Early analysis of the Apple squircle shape suggested a superellipse with exponent 5. -This analysis +This analysis was based on coarse shape only and didn't allow for an additional flat side, which it does contain. + +A very close 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. + +![The Apple squircle beside a superellipse of exponent 3 with flat sides](apple_vs_superellipse.svg) + +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 From 9feb5c903a89927b1f58b5b8e6278fc02c0c3152 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 20:30:28 +1000 Subject: [PATCH 8/9] More tweaks and edits --- content/wiki/curves/squircle/index.md | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/content/wiki/curves/squircle/index.md b/content/wiki/curves/squircle/index.md index 8c2d4e2b..6b28a5b8 100644 --- a/content/wiki/curves/squircle/index.md +++ b/content/wiki/curves/squircle/index.md @@ -4,7 +4,7 @@ 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 are parametrized, and can vary between circle and square. +All general squircles are parametrized, and can vary between circle and square.
@@ -59,7 +59,7 @@ A significant advantage of the latter approach is that it can adapt to rectangle ## 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, including [Desperately Seeking Squircles](https://www.figma.com/blog/desperately-seeking-squircles/) from Figma. +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. @@ -100,7 +100,7 @@ Gauges up to 1 are of course attainable by adding the flat side. 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 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. @@ -119,10 +119,8 @@ It is exact for placing the midpoint (this is part of the formula), so is well c ### Apple-like behavior with the corner-shape spec -Early analysis of the Apple squircle shape suggested a superellipse with exponent 5. -This analysis was based on coarse shape only and didn't allow for an additional flat side, which it does contain. - -A very close match to the Apple shape is attainable using a superellipse with exponent 3 and a corresponding flat side length to match the gauge. +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. @@ -143,10 +141,10 @@ Note that the parameter to the `superellipse()` function is the base-2 log of th 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 is of course the exception, as it has an infinitely high order of continuity. +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 have this property. +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 @@ -162,14 +160,7 @@ 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. -* Wikipedia has a “periodic squircle” which has very similar behavior to the Fernández-Guasti one (they are visually near indistinguishable). - -## References - -* There’s a very detailed exploration of superellipses and Bézier approximations at [Goodbye Circles, Hello Squircles: Perfect Corners in CSS & Canvas](https://orgpad.info/blog/squircles). -(not sure I’ll include this; there’s lots of detail but some things are iffy.) - -* The Wikipedia page on [squircles](https://en.wikipedia.org/wiki/Squircle). +* 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: From 589356ed05557656ef9075a7b99e08b2d929e6c0 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 18 Sep 2026 20:38:15 +1000 Subject: [PATCH 9/9] Fix typos in code --- crates/squircle/src/euler.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/squircle/src/euler.rs b/crates/squircle/src/euler.rs index 3ad93ea1..e164ebf0 100644 --- a/crates/squircle/src/euler.rs +++ b/crates/squircle/src/euler.rs @@ -153,7 +153,7 @@ pub fn integ_euler_12n(mut k0: f64, mut k1: f64, n: usize) -> (f64, f64) { (x * ds, y * ds) } -/// Evaulate the Euler spiral integral. +/// Evaluate the Euler spiral integral. /// /// Compute the following integral to the desired accuracy. /// @@ -243,12 +243,12 @@ impl EulerParams { /// /// 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 thm = self.th(t * 0.5); + 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 * thm.sin(); - let c = t / self.chord * thm.cos(); + 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)