diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 44e16249..55b7aa28 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,28 @@ jobs: env: BUILD_ONLY: true + # Demos embedded in pages are Rust compiled to Wasm; see ./main.yml for the + # packaging step that runs on deploy. + check-wasm: + name: check (wasm) + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + - name: install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: ./crates + - name: cargo check + working-directory: ./crates + # --list names the crates that declare a cdylib; --all-targets also + # covers each one's trunk-only dev bin. + run: cargo check $(./build.sh --list) --all-targets --target wasm32-unknown-unknown --locked + # If this fails, consider changing your text or adding something to .typos.toml. typos: runs-on: ubuntu-latest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6224c4cd..eab15d7d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,25 @@ jobs: steps: - name: checkout uses: actions/checkout@v4 + - name: install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: install wasm-bindgen + uses: taiki-e/install-action@v2 + with: + # Must match the wasm-bindgen version resolved in crates/Cargo.lock. + tool: wasm-bindgen@0.2.128 + - name: restore cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: ./crates + - name: build and package wasm + working-directory: ./crates + # Same script contributors run locally, so the two cannot drift. It + # builds only the crates that declare a cdylib, leaving native tools in + # this workspace alone, and writes into static/ for zola to pick up. + run: ./build.sh --release --locked - name: build_and_deploy # Also update in README.md and ./check.yml uses: shalzz/zola-deploy-action@v0.20.0 diff --git a/.gitignore b/.gitignore index 2217580d..06fda9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ _site .sass-cache .jekyll-metadata /public +/static/rust diff --git a/content/wiki/curves/squircle/apple_vs_superellipse.svg b/content/wiki/curves/squircle/apple_vs_superellipse.svg new file mode 100644 index 00000000..019af68d --- /dev/null +++ b/content/wiki/curves/squircle/apple_vs_superellipse.svg @@ -0,0 +1,18 @@ + + + +Apple +gauge 0.809 + +superellipse, exponent 3 +flat sides, border-radius 45.6% + diff --git a/content/wiki/curves/squircle/index.md b/content/wiki/curves/squircle/index.md new file mode 100644 index 00000000..6b28a5b8 --- /dev/null +++ b/content/wiki/curves/squircle/index.md @@ -0,0 +1,175 @@ ++++ +title = "Squircles" ++++ + +A squircle is a shape intermediate between a square and a circle. +There is no single mathematical definition, rather there are a number of curves that fit this shape. +All general squircles are parametrized, and can vary between circle and square. + + +
+

Loading the interactive tester…

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