diff --git a/packages/preview/dorodango/0.3.0/LICENSE b/packages/preview/dorodango/0.3.0/LICENSE new file mode 100644 index 0000000000..11f8504c9a --- /dev/null +++ b/packages/preview/dorodango/0.3.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Antonio Camargo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/README.md b/packages/preview/dorodango/0.3.0/README.md new file mode 100644 index 0000000000..ecdfa08bfa --- /dev/null +++ b/packages/preview/dorodango/0.3.0/README.md @@ -0,0 +1,193 @@ +# dorodango + +[![Typst package](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fapcamargo%2Fdorodango%2Fmain%2Ftypst.toml&query=%24.package.version&prefix=v&logo=typst&label=package&color=239DAD)](https://typst.app/universe/package/dorodango) +[![Manual](https://img.shields.io/badge/User_manual-blue)](./docs/manual.pdf) +[![GitHub repository](https://img.shields.io/badge/GitHub-Repository-black?logo=github)](https://github.com/apcamargo/dorodango) + +`dorodango` is a Typst package for drawing squircles with tunable corner smoothing. + +## Background + +Smooth-cornered rectangles blend their corners gradually into straight edges. While a standard rounded rectangle jumps abruptly from a straight edge into a circular arc, smooth corners ease into the curve with zero starting curvature. + +`dorodango` provides three corner families: + +- `squircle`: [Figma's](https://www.figma.com/blog/desperately-seeking-squircles/) squircle construction, combining a circular arc with cubic Bézier shoulders at either end. +- `superellipse`: cubic approximations of Lamé curves parameterized by an exponent, from a standard circular arc at 2 to squarer corners with continuous curvature. +- `clothoid`: cubic approximations of Euler-spiral blends where curvature ramps linearly along the curve. + +# Documentation + +Refer to the [manual](./docs/manual.pdf) for a full API reference and usage examples. + +## Quickstart + +In a Typst document, import the `dorodango` package: + +```typ +#import "@preview/dorodango:0.3.0": * +``` + +`dorodango` provides a `squircle` function that mirrors Typst's built-in `rect` element, adding parameters to control corner smoothing. The comparison below shows how the corner-to-edge transitions differ for a rectangle and a squircle of the same size and radius: + +```typ +#grid( + columns: (1fr, 1fr), + gutter: 14pt, + rect(width: 90pt, height: 60pt, radius: (top-left: 50%), fill: aqua), + squircle( + width: 90pt, + height: 60pt, + radius: (top-left: 50%), + smoothing: 100%, + fill: aqua, + ), +) +``` + + + + Side-by-side comparison of a rounded rectangle and a squircle with the same size and corner radius. The rectangle's top-left corner has an abrupt transition from straight edge to circular arc, while the squircle's corner curves smoothly into the edges. + + +## Customize squircle corners + +Three parameters control how squircle corners are drawn: + +- `smoothing` controls how gradually edges blend into corners. At `0%`, the shape is an ordinary rounded rectangle. At `100%`, the central circular arc disappears entirely and the two Bézier shoulders meet. +- `preserve-smoothing` determines how the corner adapts when the requested radius and smoothing exceed available edge space. +- `per-edge-smoothing` lets each edge of a corner use its own available space instead of constraining both to the shorter edge. + +In the example below, all three shapes share the same size and radius. The third preserves its requested smoothing even when edge space runs out: + +```typ +#grid( + columns: (1fr, 1fr, 1fr), + gutter: 14pt, + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 5%, + fill: aqua, + ), + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + fill: aqua, + ), + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + preserve-smoothing: true, + fill: aqua, + ), +) +``` + + + + Three squircles of the same size and corner radius. The first and second set the smoothing parameter to 5% and 100%, respectively. The third adds preserve-smoothing, retaining full smoothing and looking visibly compressed at the corners. + + +On the pill shape below, the corner radius consumes the entire vertical edge, leaving no room for smoothing. By default, corners stay symmetric, so horizontal smoothing is clamped to match the vertical limit. Setting `per-edge-smoothing: true` allows the horizontal edges to use their remaining length and smooth independently: + +```typ +#grid( + columns: (1fr, 1fr), + gutter: 14pt, + squircle( + width: 160pt, + height: 50pt, + radius: 25pt, + smoothing: 100%, + fill: aqua, + ), + squircle( + width: 160pt, + height: 50pt, + radius: 25pt, + smoothing: 100%, + per-edge-smoothing: true, + fill: aqua, + ), +) +``` + + + + Two squircles of the same size and corner radius, with radius half the height. The first, with per-edge-smoothing false, has smoothing clamped away on every edge. The second, with per-edge-smoothing true, keeps full smoothing on its long edges while its short, semicircular ends stay unchanged. + + +## Alternative corner curve families + +Besides `squircle`, `dorodango` also provides `superellipse` and `clothoid` to round corners using different curve families. + +Note that each family is governed by different parameters, so the comparison below is not like-for-like. While `squircle` and `clothoid` corner blends can extend along adjacent edges as space allows, `superellipse` corners always stay strictly within their radius. + +```typ +#grid( + columns: (1fr, 1fr, 1fr), + gutter: 14pt, + row-gutter: 12pt, + align: center + top, + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + fill: aqua, + ), + superellipse( + width: 85pt, + height: 55pt, + radius: 20pt, + exponent: 5, + fill: aqua, + ), + clothoid( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + fill: aqua, + ), + + [Squircle \ smoothing: 100%], + [Superellipse \ exponent: 5], + [Clothoid \ smoothing: 100%], +) +``` + + + + Three rectangles of the same size and corner radius, labeled with their curve family and the parameter used: Squircle at smoothing 100%, Superellipse at exponent 5, and Clothoid at smoothing 100%. The squircle blends through Figma-style cubic shoulders, the superellipse follows a Lamé curve profile, and the clothoid ramps curvature linearly along Euler-spiral transitions. The squircle and clothoid corners spread further along the edges than the superellipse corner, which stays within its radius. + diff --git a/packages/preview/dorodango/0.3.0/assets/corner-family-comparison-dark.svg b/packages/preview/dorodango/0.3.0/assets/corner-family-comparison-dark.svg new file mode 100644 index 0000000000..e48455cbb7 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/corner-family-comparison-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/corner-family-comparison-light.svg b/packages/preview/dorodango/0.3.0/assets/corner-family-comparison-light.svg new file mode 100644 index 0000000000..953bd9bba5 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/corner-family-comparison-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/corner-family-comparison.typ b/packages/preview/dorodango/0.3.0/assets/corner-family-comparison.typ new file mode 100644 index 0000000000..27c0ab1e4e --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/corner-family-comparison.typ @@ -0,0 +1,47 @@ +#import "../src/lib.typ": clothoid, squircle, superellipse + +#set page(width: 170mm, height: auto, margin: 8mm, fill: none) + +#let theme = sys.inputs.at("theme", default: "light") +#set text( + font: "Source Sans 3", + size: 12.5pt, + fill: if theme == "dark" { + rgb("#f0f6fc") + } else { rgb("#000000") }, +) + +#align(center + horizon)[ + #grid( + columns: (1fr, 1fr, 1fr), + rows: 2, + align: center + top, + row-gutter: 12pt, + column-gutter: 14pt, + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + fill: aqua, + ), + superellipse( + width: 85pt, + height: 55pt, + radius: 20pt, + exponent: 5, + fill: aqua, + ), + clothoid( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + fill: aqua, + ), + + [Squircle \ smoothing: 100%], + [Superellipse \ exponent: 5], + [Clothoid \ smoothing: 100%], + ) +] diff --git a/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison-dark.svg b/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison-dark.svg new file mode 100644 index 0000000000..11ebe81ea7 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison-light.svg b/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison-light.svg new file mode 100644 index 0000000000..a7669b7126 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison.typ b/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison.typ new file mode 100644 index 0000000000..d482607851 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/per-edge-smoothing-comparison.typ @@ -0,0 +1,39 @@ +#import "../src/lib.typ": squircle + +#set page(width: 170mm, height: auto, margin: 8mm, fill: none) + +#let theme = sys.inputs.at("theme", default: "light") +#set text( + font: "Source Sans 3", + size: 12.5pt, + fill: if theme == "dark" { + rgb("#f0f6fc") + } else { rgb("#000000") }, +) + +#align(center + horizon)[ + #grid( + columns: (1fr, 1fr), + rows: 2, + align: center + top, + row-gutter: 12pt, + column-gutter: 14pt, + squircle( + width: 160pt, + height: 50pt, + radius: 25pt, + smoothing: 100%, + fill: aqua, + ), + squircle( + width: 160pt, + height: 50pt, + radius: 25pt, + smoothing: 100%, + per-edge-smoothing: true, + fill: aqua, + ), + + [per-edge-smoothing: false], [per-edge-smoothing: true], + ) +] diff --git a/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle-dark.svg b/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle-dark.svg new file mode 100644 index 0000000000..1d0f12662e --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle-light.svg b/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle-light.svg new file mode 100644 index 0000000000..92191502ba --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle.typ b/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle.typ new file mode 100644 index 0000000000..85ace149ee --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/rectangle-vs-squircle.typ @@ -0,0 +1,39 @@ +#import "../src/lib.typ": squircle + +#set page(width: 170mm, height: auto, margin: 8mm, fill: none) + +#let theme = sys.inputs.at("theme", default: "light") +#set text( + font: "Source Sans 3", + size: 12.5pt, + fill: if theme == "dark" { + rgb("#f0f6fc") + } else { rgb("#000000") }, +) + +#align(center + horizon)[ + #block(width: 100% * 2 / 3)[ + #grid( + columns: (1fr, 1fr), + rows: 2, + align: center + top, + row-gutter: 12pt, + column-gutter: 14pt, + rect( + width: 90pt, + height: 60pt, + radius: (top-left: 50%), + fill: aqua, + ), + squircle( + width: 90pt, + height: 60pt, + radius: (top-left: 50%), + smoothing: 100%, + fill: aqua, + ), + + text[Rounded rectangle], text[Squircle], + ) + ] +] diff --git a/packages/preview/dorodango/0.3.0/assets/smoothing-comparison-dark.svg b/packages/preview/dorodango/0.3.0/assets/smoothing-comparison-dark.svg new file mode 100644 index 0000000000..107fe56053 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/smoothing-comparison-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/smoothing-comparison-light.svg b/packages/preview/dorodango/0.3.0/assets/smoothing-comparison-light.svg new file mode 100644 index 0000000000..0ff38cdd6b --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/smoothing-comparison-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/dorodango/0.3.0/assets/smoothing-comparison.typ b/packages/preview/dorodango/0.3.0/assets/smoothing-comparison.typ new file mode 100644 index 0000000000..ea38b7f338 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/assets/smoothing-comparison.typ @@ -0,0 +1,51 @@ +#import "../src/lib.typ": squircle + +#set page(width: 170mm, height: auto, margin: 8mm, fill: none) + +#let theme = sys.inputs.at("theme", default: "light") +#set text( + font: "Source Sans 3", + size: 12.5pt, + fill: if theme == "dark" { + rgb("#f0f6fc") + } else { rgb("#000000") }, +) + +#align(center + horizon)[ + #grid( + columns: (1fr, 1fr, 1fr), + rows: 2, + align: center + top, + row-gutter: 12pt, + column-gutter: 14pt, + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 5%, + fill: aqua, + ), + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + fill: aqua, + ), + squircle( + width: 85pt, + height: 55pt, + radius: 20pt, + smoothing: 100%, + preserve-smoothing: true, + fill: aqua, + ), + + [smoothing: 5%], + [smoothing: 100%], + [ + smoothing: 100% #linebreak() + preserve-smoothing: true + ], + ) +] diff --git a/packages/preview/dorodango/0.3.0/docs/manual.pdf b/packages/preview/dorodango/0.3.0/docs/manual.pdf new file mode 100644 index 0000000000..7f10303940 Binary files /dev/null and b/packages/preview/dorodango/0.3.0/docs/manual.pdf differ diff --git a/packages/preview/dorodango/0.3.0/docs/manual.typ b/packages/preview/dorodango/0.3.0/docs/manual.typ new file mode 100644 index 0000000000..b3d5abdc98 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/docs/manual.typ @@ -0,0 +1,430 @@ +#import "@preview/tidy:0.4.3" +#import "../src/lib.typ": clothoid, squircle, superellipse + +#let light-gray = oklch(93.5%, 0.005, 265deg) +#let medium-gray = oklch(60%, 0.005, 265deg) + +#set document(title: "dorodango manual") + +#set page( + paper: "a4", + numbering: "1", + number-align: center + bottom, +) + +#set text(font: "Source Sans 3", size: 10.8pt) + +#set raw(lang: "manual-inline") + +#show raw.where(block: false, lang: "manual-inline"): set text(size: 9.2pt) + +#show raw.where(block: false, lang: "manual-inline"): box.with( + outset: (y: 2.6pt, x: 0.3pt), + inset: (x: 2.5pt), + fill: light-gray, + radius: 2.2pt, +) + +#set par(justify: true, leading: 0.72em, justification-limits: ( + tracking: (min: -0.012em, max: 0.012em), + spacing: (min: 70%, max: 130%), +)) + +#set heading(numbering: none) + +#show heading.where(level: 1): it => { + set text(size: 16pt) + set block(below: 0.8em) + block(it) +} + +#show heading.where(level: 2): it => { + set block(below: 0.8em) + block(it) +} + +#show link: set text(fill: rgb("4B69BE")) + +#show footnote: set text(fill: rgb("4B69BE")) + +#let api-squircle = tidy.parse-module( + read("../src/squircle.typ"), + name: "squircle", + label-prefix: "api-squircle-", + require-all-parameters: true, + scope: (squircle: squircle), +) + +#let api-superellipse = tidy.parse-module( + read("../src/superellipse.typ"), + name: "superellipse", + label-prefix: "api-superellipse-", + require-all-parameters: true, + scope: (superellipse: superellipse), +) + +#let api-clothoid = tidy.parse-module( + read("../src/clothoid.typ"), + name: "clothoid", + label-prefix: "api-clothoid-", + require-all-parameters: true, + scope: (clothoid: clothoid), +) + +#show: tidy.render-examples.with( + scope: ( + squircle: squircle, + superellipse: superellipse, + clothoid: clothoid, + ), + layout: (code, preview) => grid( + columns: (1fr, 0.5fr), + gutter: 10pt, + block(breakable: false, inset: 10pt, fill: rgb("ddd3"), width: 100%)[#code], + block(breakable: false, inset: 10pt, width: 100%)[ + #set text(font: "Source Sans 3", size: 10.8pt) + #preview + ], + ), +) + +#grid( + columns: (1fr,), + rows: 2, + align: center, + gutter: 0.525cm, + text(size: 27pt, weight: "bold")[dorodango], + text( + fill: medium-gray, + )[Draw squircles in Typst with tunable corner smoothing], +) + +#v(1cm) + +#outline(title: [Contents], depth: 2) + +#pagebreak() + += Introduction + +`dorodango` provides three functions (`squircle`, `superellipse`, and `clothoid`) that mirror the built-in `rect` element, replacing abrupt circular corners with smooth curve transitions. + +#align(center)[ + #grid( + columns: 4, + rows: 2, + align: center, + row-gutter: 6.5pt, + column-gutter: 22pt, + rect( + width: 80pt, + height: 55pt, + radius: (top-left: 50%), + fill: aqua, + ), + squircle( + width: 80pt, + height: 55pt, + radius: (top-left: 50%), + smoothing: 100%, + fill: aqua, + ), + superellipse( + width: 80pt, + height: 55pt, + radius: (top-left: 50%), + exponent: 5, + fill: aqua, + ), + clothoid( + width: 80pt, + height: 55pt, + radius: (top-left: 50%), + smoothing: 100%, + fill: aqua, + ), + + text(fill: medium-gray)[Rectangle], + text(fill: medium-gray)[Squircle], + text(fill: medium-gray)[Superellipse], + text(fill: medium-gray)[Clothoid], + ) +] + +Note that each family is driven by different parameters (see the #link()[API reference]), so the comparison above is not like-for-like. While `squircle` and `clothoid` corner blends can extend along adjacent edges as space allows, `superellipse` corners always stay strictly within their radius. + += API reference + +// squircle + +#tidy.show-module( + api-squircle, + style: tidy.styles.default, + first-heading-level: 1, + show-module-name: false, + show-outline: false, +) + +// superellipse + +#tidy.show-module( + api-superellipse, + style: tidy.styles.default, + first-heading-level: 1, + show-module-name: false, + show-outline: false, +) + +// clothoid + +#tidy.show-module( + api-clothoid, + style: tidy.styles.default, + first-heading-level: 1, + show-module-name: false, + show-outline: false, +) + += Concepts and examples + +== Squircle + +`squircle` draws #link("https://www.figma.com/blog/desperately-seeking-squircles/")[Figma's] squircle corner, a circular arc with a cubic Bézier shoulder at either end. `smoothing` splits the corner's 90° turn between the shoulders and the arc, so at `0%` the corner is a plain quarter circle and at `100%` the arc vanishes and the two shoulders meet. `preserve-smoothing` and `per-edge-smoothing`, both specific to `squircle`, control what happens when the requested corner does not fit. + +=== Smoothing + +`smoothing` controls the gradual transition between edges and corners. In the example below, the shapes use `0%`, `60%`, and `100%` smoothing. + +```example +#grid( + rows: 3, + gutter: 12pt, + ..(0%, 60%, 100%).map(s => align(center + horizon)[ + #squircle( + width: 75pt, + height: 48pt, + radius: 35%, + smoothing: s, + fill: aqua, + )[smoothing: #repr(s)] + ]), +) +``` + +=== Preserve smoothing + +Large radii and high smoothing both consume space along a corner's edges. For a corner with radius $r$ and smoothing $s$, the required space along each edge is $p = (1 + s) r$. When $p$ exceeds the available edge budget $b$, `preserve-smoothing` determines how the shape adjusts: + +- `preserve-smoothing: false` (default) preserves the radius and reduces smoothing to $b / r - 1$. +- `preserve-smoothing: true` keeps both the requested radius and smoothing, compressing the Bézier transitions to fit the edge. + +```example +#grid( + rows: 2, + gutter: 12pt, + ..(false, true).map(keep => align(center + horizon)[ + #squircle( + width: 75pt, + height: 48pt, + radius: 18pt, + smoothing: 100%, + preserve-smoothing: keep, + fill: aqua, + )[preserve-smoothing: #repr(keep)] + ]), +) +``` + +=== Per-edge smoothing + +Each half of a corner uses space along one adjacent edge, and those edges may have different lengths. For example, on an elongated rectangle, a short edge might run out of space for smoothing while the long edge still has plenty of room. + +- `per-edge-smoothing: false` (default) uses the tighter edge budget for both halves, keeping the corner symmetric. +- `per-edge-smoothing: true` allows each half to adapt to its own edge budget independently. + +When `preserve-smoothing` is `false`, smoothing is reduced only on the tighter edge, giving the two halves different transition angles. When `preserve-smoothing` is `true`, both halves retain their transition angles, but the transition on the tighter edge is compressed to fit. + +```example +#grid( + rows: 2, + gutter: 12pt, + ..(false, true).map(u => align(center + horizon)[ + #squircle( + width: 150pt, + height: 48pt, + radius: 25pt, + smoothing: 100%, + per-edge-smoothing: u, + fill: aqua, + )[per-edge-smoothing: #repr(u)] + ]), +) +``` + +== Superellipse + +`superellipse` draws corners based on Lamé curves ($|x/p|^n + |y/p|^n = 1$), fitted with three cubic Bézier segments inside a square corner footprint of side $p$. For $n > 2$, the curve meets straight edges with zero curvature. + +=== Exponent + +The `exponent` parameter sets $n$, clamped to $[2, 12]$. At $n = 2$, the corner is an exact circular arc matching `rect`. Higher values make the corner squarer. Near $n = 12$, the cubic fit trades exact curve fidelity to stay strictly within the corner footprint. + +```example +#grid( + rows: 3, + gutter: 12pt, + ..(2, 3, 6).map(n => align(center + horizon)[ + #superellipse( + width: 75pt, + height: 48pt, + radius: 35%, + exponent: n, + fill: aqua, + )[exponent: #n] + ]), +) +``` + +== Clothoid + +`clothoid` draws Euler-spiral corner blends, ramping curvature linearly from zero at the straight edge up to the curvature of a central circular arc. Each spiral transition is approximated by a single cubic Bézier segment. + +=== Smoothing + +`smoothing` splits the 90° corner turn between the spiral transitions and the central circular arc. At `0%`, the corner is a standard circular arc matching `rect`. At `100%`, the central arc vanishes and the two spirals meet directly. + +When a blend requires more space than the adjacent edge allows, `clothoid` uniformly scales down both the spiral lengths and circular radius, keeping the angular smoothing proportions intact. + +```example +#grid( + rows: 3, + gutter: 12pt, + ..(0%, 60%, 100%).map(s => align(center + horizon)[ + #clothoid( + width: 75pt, + height: 48pt, + radius: 35%, + smoothing: s, + fill: aqua, + )[smoothing: #repr(s)] + ]), +) +``` + +== Common parameters + +Although `squircle`, `superellipse`, and `clothoid` take different parameters to shape their corners, they share the common ones described below: + +=== Size and body + +`width` and `height` set the shape's layout dimensions. When both are `auto`, the shape sizes itself to fit the content passed in the body. + +```example +#grid( + rows: 2, + gutter: 12pt, + squircle(radius: 10pt, fill: aqua)[Auto-sized body], + squircle( + width: 150pt, + height: 55pt, + radius: 10pt, + fill: aqua, + )[Fixed width and height], +) +``` + +=== Fill + +`fill` sets the interior color, gradient, or tiling pattern. + +```example +#grid( + rows: 2, + gutter: 12pt, + squircle(width: 75pt, height: 48pt, radius: 10pt, fill: aqua), + squircle(width: 75pt, height: 48pt, radius: 10pt, fill: gradient.linear(..color.map.flare)), +) +``` + +=== Stroke + +`stroke` sets the border outline, either uniformly or per side. + +```example +#grid( + rows: 2, + gutter: 12pt, + squircle( + width: 75pt, + height: 48pt, + radius: 10pt, + stroke: 3pt + fuchsia, + ), + squircle( + width: 75pt, + height: 48pt, + radius: 10pt, + stroke: (top: 3pt + red, bottom: none, right: blue, left: 5pt + orange), + ), +) +``` + +=== Radius + +`radius` controls corner rounding, either as a single value for all corners or per corner. + +```example +#grid( + rows: 3, + gutter: 12pt, + squircle( + width: 75pt, + height: 48pt, + radius: 0pt, + fill: aqua, + ), + squircle( + width: 75pt, + height: 48pt, + radius: 35%, + fill: aqua, + ), + squircle( + width: 75pt, + height: 48pt, + radius: (top-left: 4pt, rest: 20pt), + fill: aqua, + ), +) +``` + +=== Inset and outset + +`inset` adds internal padding around the content, while `outset` extends the background drawing outward without affecting layout dimensions. + +```example +#grid( + rows: 3, + gutter: 12pt, + squircle( + width: 90pt, + height: 48pt, + radius: 10pt, + fill: aqua, + )[No inset or outset], + squircle( + width: 90pt, + height: 48pt, + inset: (x: 20pt, y: 10pt), + radius: 10pt, + fill: aqua, + )[Inset adds padding], + squircle( + width: 90pt, + height: 48pt, + radius: 10pt, + outset: 7pt, + fill: aqua, + )[Outset expands the drawing], +) +``` diff --git a/packages/preview/dorodango/0.3.0/src/clothoid.typ b/packages/preview/dorodango/0.3.0/src/clothoid.typ new file mode 100644 index 0000000000..a44471a7c9 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/clothoid.typ @@ -0,0 +1,150 @@ +#import "validation.typ": * +#import "dictionaries.typ": * +#import "geometry.typ": * +#import "corners.typ": * +#import "shape.typ": _draw-shape + +/// Draws a rectangle with clothoid (Euler-spiral) blend corners. +/// +/// Use `clothoid` like `rect` when you want cubic approximations of Euler-spiral +/// corner transitions, whose ideal curvature ramps linearly along arc length. +/// At `smoothing: 0%`, it has the same geometry as a rounded `rect`. +/// +/// -> content +#let clothoid( + /// The clothoid's width, relative to its parent container. + /// -> auto | length | ratio | relative + width: auto, + /// The clothoid's height, relative to its parent container. + /// -> auto | length | ratio | relative | fraction + height: auto, + /// How to fill the clothoid. + /// + /// When setting a fill, the default stroke disappears. To create a + /// clothoid with both fill and stroke, you have to configure both. + /// -> none | color | gradient | tiling + fill: none, + /// How to stroke the clothoid. This can be: + /// + /// - `none` to disable stroking. + /// - `auto` for a stroke of `1pt + black` if and only if no fill is given. + /// - Any kind of stroke. + /// - A dictionary describing the stroke for each side individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top`: The top stroke. + /// - `right`: The right stroke. + /// - `bottom`: The bottom stroke. + /// - `left`: The left stroke. + /// - `x`: The left and right stroke. + /// - `y`: The top and bottom stroke. + /// - `rest`: The stroke on all sides except those for which the dictionary + /// explicitly sets a stroke. + /// + /// All keys are optional. Omitted sides are not stroked. + /// -> auto | none | length | color | gradient | tiling | stroke | dictionary + stroke: auto, + /// How much to round the clothoid's corners, relative to the minimum of + /// the width and height divided by two. This can be: + /// + /// - A relative length for a uniform corner radius. + /// - A dictionary describing the radius for each corner individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top-left`: The top-left corner radius. + /// - `top-right`: The top-right corner radius. + /// - `bottom-right`: The bottom-right corner radius. + /// - `bottom-left`: The bottom-left corner radius. + /// - `left`: The top-left and bottom-left corner radii. + /// - `top`: The top-left and top-right corner radii. + /// - `right`: The top-right and bottom-right corner radii. + /// - `bottom`: The bottom-left and bottom-right corner radii. + /// - `rest`: The radii for all corners except those for which the + /// dictionary explicitly sets a radius. + /// -> length | ratio | relative | dictionary + radius: 0pt, + /// How much to pad the clothoid's content. See `box`'s `inset` + /// parameter for more details. + /// -> length | ratio | relative | dictionary + inset: 5pt, + /// How much to expand the clothoid's size without affecting the layout. + /// See `box`'s `outset` parameter for more details. + /// -> length | ratio | relative | dictionary + outset: 0pt, + /// How strongly to smooth the clothoid's corners. Smoothing splits the + /// 90-degree corner rotation into clothoid spiral transitions (where curvature + /// ramps linearly) and a central circular arc. This can be: + /// + /// - A relative length for uniform corner smoothing. + /// - A dictionary describing the smoothing for each corner individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top-left`: The top-left corner smoothing. + /// - `top-right`: The top-right corner smoothing. + /// - `bottom-right`: The bottom-right corner smoothing. + /// - `bottom-left`: The bottom-left corner smoothing. + /// - `left`: The top-left and bottom-left corner smoothing. + /// - `top`: The top-left and top-right corner smoothing. + /// - `right`: The top-right and bottom-right corner smoothing. + /// - `bottom`: The bottom-left and bottom-right corner smoothing. + /// - `rest`: The smoothing for all corners except those for which the + /// dictionary explicitly sets smoothing. + /// + /// At `0%`, the corner is a quarter circle matching a rounded `rect`. At + /// `100%`, it is two clothoid transitions meeting with no circular arc. + /// + /// When the natural blend does not fit the tighter adjacent-edge budget, its + /// clothoid lengths and effective circular radius scale down together. This + /// keeps the angular smoothing proportions unchanged. + /// -> length | ratio | relative | dictionary + smoothing: 60%, + /// The content to place into the clothoid. Strings and symbols are converted + /// to content. + /// + /// When this is omitted, the clothoid takes on a default size of at most + /// `45pt` by `30pt`. + /// -> none | content | str | symbol + ..body, +) = { + _validate-size("width", width) + _validate-size("height", height, fraction-ok: true) + _validate-fill(fill) + stroke = _validate-stroke(stroke) + _validate-relative-or-dict("radius", radius, _corner-keys) + _validate-relative-or-dict("inset", inset, _side-keys) + _validate-relative-or-dict("outset", outset, _side-keys) + _validate-relative-or-dict("smoothing", smoothing, _corner-keys) + body = _validate-body(body) + + let smoothing-corners = _resolve-corners(smoothing, default: 60%) + let smoothings = _corner-order + .map(c => ( + c, + calc.max( + 0.0, + calc.min(1.0, _resolve-scalar(smoothing-corners.at(c), 1pt) / 1pt), + ), + )) + .to-dict() + + let _piece-for(corner, pt, r, r-fit, budget, split) = { + _clothoid-piece( + corner, + pt, + r, + r-fit, + budget, + smoothings.at(corner), + split: split, + ) + } + + _draw-shape( + width: width, + height: height, + fill: fill, + stroke: stroke, + radius: radius, + inset: inset, + outset: outset, + _piece-for, + body: body, + ) +} diff --git a/packages/preview/dorodango/0.3.0/src/corners.typ b/packages/preview/dorodango/0.3.0/src/corners.typ new file mode 100644 index 0000000000..24b37483b1 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/corners.typ @@ -0,0 +1,738 @@ +#import "geometry.typ": _hypot, _vadd, _vscale, _vsub + +// Clockwise corner order and adjacency mappings. +#let _corner-order = ("top-left", "top-right", "bottom-right", "bottom-left") +#let _next-cw = ( + top-left: "top-right", + top-right: "bottom-right", + bottom-right: "bottom-left", + bottom-left: "top-left", +) +#let _next-ccw = ( + top-left: "bottom-left", + top-right: "top-left", + bottom-right: "top-right", + bottom-left: "bottom-right", +) +#let _side-cw = ( + top-left: "top", + top-right: "right", + bottom-right: "bottom", + bottom-left: "left", +) +#let _side-ccw = ( + top-left: "left", + top-right: "top", + bottom-right: "right", + bottom-left: "bottom", +) + +// Corner edge unit vectors (edge-in, edge-out) and arc angle bounds. +#let _corner-geom = ( + top-left: (edge-in: (0, 1), edge-out: (1, 0), base0: 180deg, base1: 270deg), + top-right: (edge-in: (-1, 0), edge-out: (0, 1), base0: -90deg, base1: 0deg), + bottom-right: ( + edge-in: (0, -1), + edge-out: (-1, 0), + base0: 0deg, + base1: 90deg, + ), + bottom-left: ( + edge-in: (1, 0), + edge-out: (0, -1), + base0: 90deg, + base1: 180deg, + ), +) + +// Straight-edge budgets for each corner's edges. Adjacent corners split +// shared sides by radius. +#let _budgets(radii, side-lens, per-edge-smoothing) = { + let budget = ( + top-left: (cw: -1pt, ccw: -1pt), + top-right: (cw: -1pt, ccw: -1pt), + bottom-right: (cw: -1pt, ccw: -1pt), + bottom-left: (cw: -1pt, ccw: -1pt), + ) + for corner in _corner-order.sorted(key: c => -radii.at(c)) { + let r = radii.at(corner) + // Shared edge budget lookup from adjacent corner. + let term(side, adj-corner, adj-field) = { + let ar = radii.at(adj-corner) + if r <= 0pt and ar <= 0pt { 0pt } else { + let side-len = side-lens.at(side) + let adj-budget = budget.at(adj-corner).at(adj-field) + if adj-budget >= 0pt { side-len - adj-budget } else { + (r / (r + ar)) * side-len + } + } + } + let cw = term(_side-cw.at(corner), _next-cw.at(corner), "ccw") + let ccw = term(_side-ccw.at(corner), _next-ccw.at(corner), "cw") + budget.at(corner) = if per-edge-smoothing { (cw: cw, ccw: ccw) } else { + let combined = calc.min(cw, ccw) + (cw: combined, ccw: combined) + } + } + budget +} + +// Figma corner smoothing parameters. +// https://www.figma.com/blog/desperately-seeking-squircles/ +#let _corner-params(r, s, budget, preserve-smoothing) = { + if r <= 0pt { + ( + a: 0pt, + b: 0pt, + c: 0pt, + d: 0pt, + p: 0pt, + angle-alpha: 45deg, + ) + } else { + let p = (1 + s) * r + // Clamp smoothing to budget, or compress handles when preserving smoothing. + if not preserve-smoothing { + s = calc.min(s, budget / r - 1) + p = calc.min(p, budget) + } + let arc-measure = 90deg * (1 - s) + let arc-len = calc.sin(arc-measure / 2) * r * calc.sqrt(2) + let angle-alpha = (90deg - arc-measure) / 2 + let p3-p4 = r * calc.tan(angle-alpha / 2) + let angle-beta = 45deg * s + let c = p3-p4 * calc.cos(angle-beta) + let d = c * calc.tan(angle-beta) + let b = (p - arc-len - c - d) / 3 + let a = 2 * b + if preserve-smoothing and p > budget { + let p1-p3-max = budget - d - arc-len - c + let min-a = p1-p3-max / 6 + let max-b = p1-p3-max - min-a + b = calc.min(b, max-b) + a = p1-p3-max - b + p = budget + } + ( + a: a, + b: b, + c: c, + d: d, + p: p, + angle-alpha: angle-alpha, + ) + } +} + +// Segment records with endpoints and control points. +#let _cubic(from, c1, c2, to) = (from: from, c1: c1, c2: c2, to: to) +#let _emit(segs) = segs.map(s => curve.cubic(s.c1, s.c2, s.to)) +#let _emit-rev(segs) = segs.rev().map(s => curve.cubic(s.c2, s.c1, s.from)) + +// Cubic curve subdivision at parameter t. +#let _lerp(a, b, t) = _vadd(_vscale(a, 1.0 - t), _vscale(b, t)) +#let _cubic-at(seg, t) = { + let u = 1.0 - t + _vadd( + _vadd( + _vscale(seg.from, u * u * u), + _vscale(seg.c1, 3.0 * u * u * t), + ), + _vadd( + _vscale(seg.c2, 3.0 * u * t * t), + _vscale(seg.to, t * t * t), + ), + ) +} +#let _split-cubic(seg, t) = { + let a = _lerp(seg.from, seg.c1, t) + let b = _lerp(seg.c1, seg.c2, t) + let c = _lerp(seg.c2, seg.to, t) + let d = _lerp(a, b, t) + let e = _lerp(b, c, t) + let mid = _lerp(d, e, t) + ( + first: _cubic(seg.from, a, d, mid), + second: _cubic(mid, e, c, seg.to), + mid: mid, + ) +} + +// Signed cross and dot products against a ray. +#let _ray-cross(center, direction, point) = { + let d = _vsub(point, center) + ( + (d.at(0) / 1pt) * (direction.at(1) / 1pt) + - (d.at(1) / 1pt) * (direction.at(0) / 1pt) + ) +} +#let _ray-dot(center, direction, point) = { + let d = _vsub(point, center) + ( + (d.at(0) / 1pt) * (direction.at(0) / 1pt) + + (d.at(1) / 1pt) * (direction.at(1) / 1pt) + ) +} + +// Split cubic segments where they cross the ray from center through split. +#let _split-segs-on-ray(segs, center, split) = { + let direction = _vsub(split, center) + let dir-len = _hypot(direction) + if dir-len == 0pt { + return (mid: segs.first().from, first: (), second: segs) + } + let dir-len-num = dir-len / 1pt + + // Check segment endpoints close to the ray. + let near-ray(point) = { + let scale = calc.max( + 1.0, + (_hypot(_vsub(point, center)) / 1pt) * dir-len-num, + ) + calc.abs(_ray-cross(center, direction, point)) <= 1e-10 * scale + } + + let before = () + for (i, seg) in segs.enumerate() { + if ( + i > 0 + and near-ray(seg.from) + and _ray-dot(center, direction, seg.from) >= 0.0 + ) { + return (mid: seg.from, first: before, second: segs.slice(i)) + } + let lo-t = 0.0 + let lo-v = _ray-cross(center, direction, seg.from) + for step in range(1, 33) { + let hi-t = step / 32.0 + let hi-v = _ray-cross(center, direction, _cubic-at(seg, hi-t)) + let crosses = ( + lo-v == 0.0 + or hi-v == 0.0 + or ( + (lo-v < 0.0 and hi-v > 0.0) or (lo-v > 0.0 and hi-v < 0.0) + ) + ) + if crosses { + let cut-t = if lo-v == 0.0 { lo-t } else if hi-v == 0.0 { hi-t } else { + let left-t = lo-t + let right-t = hi-t + let left-v = lo-v + let found-t = none + for _ in range(48) { + let mid-t = (left-t + right-t) / 2.0 + let mid-v = _ray-cross( + center, + direction, + _cubic-at(seg, mid-t), + ) + if mid-v == 0.0 { + // Stop early on an exact root. + found-t = mid-t + break + } else if ( + (left-v < 0.0 and mid-v > 0.0) + or ( + left-v > 0.0 and mid-v < 0.0 + ) + ) { + right-t = mid-t + } else { + left-t = mid-t + left-v = mid-v + } + } + if found-t == none { + (left-t + right-t) / 2.0 + } else { + found-t + } + } + let cut = _split-cubic(seg, cut-t) + if _ray-dot(center, direction, cut.mid) >= 0.0 { + return ( + mid: cut.mid, + first: before + (cut.first,), + second: (cut.second,) + segs.slice(i + 1), + ) + } + } + lo-t = hi-t + lo-v = hi-v + } + before.push(seg) + } + + // Fall back to nearest endpoint when the ray falls outside the curve sweep. + let start = segs.first().from + let end = segs.last().to + let start-reach = _hypot(_vsub(start, center)) + let end-reach = _hypot(_vsub(end, center)) + let start-offset = if start-reach == 0pt { calc.inf } else { + calc.abs(_ray-cross(center, direction, start)) / (start-reach / 1pt) + } + let end-offset = if end-reach == 0pt { calc.inf } else { + calc.abs(_ray-cross(center, direction, end)) / (end-reach / 1pt) + } + if start-offset <= end-offset { + let cut = _split-cubic(segs.first(), 0.0) + (mid: cut.mid, first: (cut.first,), second: (cut.second,) + segs.slice(1)) + } else { + let before = () + for i in range(segs.len() - 1) { before.push(segs.at(i)) } + let cut = _split-cubic(segs.last(), 1.0) + (mid: cut.mid, first: before + (cut.first,), second: (cut.second,)) + } +} + +#let _angle-of(center, p) = { + let d = _vsub(p, center) + calc.atan2(d.at(0) / 1pt, d.at(1) / 1pt) +} + +// Sharp corner with inward pull for negative radii. +#let _sharp-piece(corner, pt, pull) = { + let (edge-in, edge-out, ..) = _corner-geom.at(corner) + ( + pt: pt, + arc: false, + start: _vadd(pt, _vscale(edge-in, pull)), + mid: pt, + end: _vadd(pt, _vscale(edge-out, pull)), + full: (), + first: (), + second: (), + ) +} + +// Rounded squircle corner with optional smoothing and split points. +#let _piece(corner, pt, r, params-in, params-out, split: none) = { + let (edge-in, edge-out, base0, base1) = _corner-geom.at(corner) + if r <= 0pt { + // Negative radius pulls sharp corner inward, matching rect. + return _sharp-piece(corner, pt, r) + } + + let center = _vadd(pt, _vscale(_vadd(edge-in, edge-out), r)) + let neg-edge-in = _vscale(edge-in, -1) + + // Half-angles stay within 45 degrees. + let angle0 = base0 + params-in.angle-alpha + let angle1 = base1 - params-out.angle-alpha + + // Radius circle endpoints and tangents. + let arc-end(angle) = { + let c = calc.cos(angle) + let s = calc.sin(angle) + ( + pt: (center.at(0) + r * c, center.at(1) + r * s), + tangent: (-s, c), + angle: angle, + ) + } + + // Approximate circle arc using standard cubic construction. + let arc-seg(from, to) = { + let kappa = 4.0 / 3.0 * calc.tan((to.angle - from.angle) / 4) + _cubic( + from.pt, + _vadd(from.pt, _vscale(from.tangent, kappa * r)), + _vadd(to.pt, _vscale(to.tangent, -kappa * r)), + to.pt, + ) + } + + let e0 = arc-end(angle0) + let e1 = arc-end(angle1) + + let start = _vadd(pt, _vscale(edge-in, params-in.p)) + let end = _vadd(pt, _vscale(edge-out, params-out.p)) + + let lead-in = _cubic( + start, + _vadd(start, _vscale(neg-edge-in, params-in.a)), + _vadd(start, _vscale(neg-edge-in, params-in.a + params-in.b)), + e0.pt, + ) + // Lead-out cubic from arc end to corner end. + let lead-out = _cubic( + e1.pt, + _vsub(end, _vscale(edge-out, params-out.a + params-out.b)), + _vsub(end, _vscale(edge-out, params-out.a)), + end, + ) + + let base = ( + pt: pt, + arc: true, + start: start, + end: end, + full: (lead-in, arc-seg(e0, e1), lead-out), + ) + if split == none { + return (..base, mid: pt, first: (), second: ()) + } + + // Clamp split angle to remaining arc span. + let angle-mid = { + let s = _angle-of(center, split) + while s < base0 { s += 360deg } + while s > base1 { s -= 360deg } + calc.max(angle0, calc.min(angle1, s)) + } + + let em = arc-end(angle-mid) + + ( + ..base, + mid: em.pt, + first: (lead-in, arc-seg(e0, em)), + second: (arc-seg(em, e1), lead-out), + ) +} + +// Single cubic arc around center. +#let _arc-through(start, center, end) = { + let a = _vsub(start, center) + let b = _vsub(end, center) + let ax = a.at(0) / 1pt + let ay = a.at(1) / 1pt + let bx = b.at(0) / 1pt + let by = b.at(1) / 1pt + let q1 = ax * ax + ay * ay + let q2 = q1 + ax * bx + ay * by + let denom = ax * by - ay * bx + if denom == 0 { return curve.line(end) } + let k2 = 4.0 / 3.0 * (calc.sqrt(calc.max(0.0, 2 * q1 * q2)) - q2) / denom + curve.cubic( + ( + center.at(0) + a.at(0) - k2 * a.at(1), + center.at(1) + a.at(1) + k2 * a.at(0), + ), + ( + center.at(0) + b.at(0) + k2 * b.at(1), + center.at(1) + b.at(1) - k2 * b.at(0), + ), + end, + ) +} + +// Unit normal perpendicular to vector from -> to. +#let _line-normal(from, to) = { + let d = _vsub(to, from) + let h = _hypot(d) / 1pt + if h == 0 { (0, 0) } else { (d.at(1) / 1pt / h, -(d.at(0) / 1pt) / h) } +} + +// Small chord offset for round caps. +#let _cap-nudge = 1pt / 127 + +// Sample angles and midpoints for superellipse cubic fitting. +#let _se-thetas = (0deg, 30deg, 60deg, 90deg) +#let _se-theta-mids = (15deg, 45deg, 75deg) + +// Superellipse corner via Lamé curve |X/p|^n + |Y/p|^n = 1. +#let _superellipse-piece( + corner, + pt, + r, + r-fit, + budget, + exponent, + split: none, +) = { + let (edge-in, edge-out, ..) = _corner-geom.at(corner) + let p = calc.min(r-fit, budget.cw, budget.ccw) + if p <= 0pt { + return _sharp-piece(corner, pt, calc.min(0pt, r)) + } + + // Clamp exponent to supported range [2, 12]. + let n = float(calc.min(calc.max(exponent, 2), 12)) + + // Exponent 2 is an exact circular arc. + if n == 2.0 { + return _piece( + corner, + pt, + r, + _corner-params(r-fit, 0.0, budget.ccw, false), + _corner-params(r-fit, 0.0, budget.cw, false), + split: split, + ) + } + + let e = 2.0 / n + let e1 = e - 1.0 + + let pow-e(x) = { + if n == 2.0 { x } else if n == 4.0 { calc.sqrt(x) } else if n == 8.0 { + calc.sqrt(calc.sqrt(x)) + } else { calc.pow(x, e) } + } + let pow-e1(x) = { + if n == 2.0 { 1.0 } else if n == 4.0 { 1.0 / calc.sqrt(x) } else { + calc.pow(x, e1) + } + } + + let points = () + for (i, th) in _se-thetas.enumerate() { + if i == 0 { + points.push((0pt, 0pt)) + } else if i == _se-thetas.len() - 1 { + points.push((p, p)) + } else { + let sth = calc.sin(th) + let cth = calc.cos(th) + points.push((p * pow-e(sth), p * (1.0 - pow-e(cth)))) + } + } + + let tangents = () + for (i, th) in _se-thetas.enumerate() { + if i == 0 { + tangents.push((1.0, 0.0)) + } else if i == _se-thetas.len() - 1 { + tangents.push((0.0, 1.0)) + } else { + let sth = calc.sin(th) + let cth = calc.cos(th) + let dx = e * pow-e1(sth) * cth * (p / 1pt) + let dy = e * pow-e1(cth) * sth * (p / 1pt) + let m = calc.sqrt(dx * dx + dy * dy) + if m == 0 { m = 1.0 } + tangents.push((dx / m, dy / m)) + } + } + + let canonical-to-display(px, py) = { + _vadd(_vadd(pt, _vscale(edge-in, p - px)), _vscale(edge-out, py)) + } + + let segs = () + for i in range(3) { + let (x0, y0) = points.at(i) + let (x1, y1) = points.at(i + 1) + let (t0x, t0y) = tangents.at(i) + let (t1x, t1y) = tangents.at(i + 1) + + let th-m = _se-theta-mids.at(i) + let mx = p * pow-e(calc.sin(th-m)) + let my = p * (1.0 - pow-e(calc.cos(th-m))) + + let rhs-x = (8.0 / 3.0) * (mx - (x0 + x1) / 2.0) / 1pt + let rhs-y = (8.0 / 3.0) * (my - (y0 + y1) / 2.0) / 1pt + let det = t1x * t0y - t1y * t0x + let h0 = ( + (if det != 0 { (-t1y * rhs-x + t1x * rhs-y) / det } else { 0.0 }) * 1pt + ) + let h1 = ( + (if det != 0 { (t0x * rhs-y - t0y * rhs-x) / det } else { 0.0 }) * 1pt + ) + + let b0 = canonical-to-display(x0, y0) + let b1 = canonical-to-display(x0 + h0 * t0x, y0 + h0 * t0y) + let b2 = canonical-to-display(x1 - h1 * t1x, y1 - h1 * t1y) + let b3 = canonical-to-display(x1, y1) + + segs.push(_cubic(b0, b1, b2, b3)) + } + + let start = canonical-to-display(0pt, 0pt) + let end = canonical-to-display(p, p) + let base = ( + pt: pt, + arc: true, + start: start, + end: end, + full: segs, + ) + if split == none { + return (..base, mid: pt, first: (), second: ()) + } + + // Ray origins use the fitted corner footprint. + let diag = _vadd(edge-in, edge-out) + let center = _vadd(pt, _vscale(diag, p)) + let cut = _split-segs-on-ray(segs, center, _vadd(split, _vscale(diag, p - r))) + (:..base, ..cut) +} + +// Simpson's rule integration for clothoid coordinates. +#let _integrate-clothoid(A, L) = { + if L <= 0.0 { return (x: 0.0, y: 0.0) } + let steps = 32 + let step = L / steps + let half-a = A / 2.0 + let sixth-step = step / 6.0 + let x-acc = 0.0 + let y-acc = 0.0 + for i in range(1, steps + 1) { + let s-a = (i - 1) * step + let s-b = s-a + step + let s-m = (s-a + s-b) / 2.0 + let th-a = half-a * s-a * s-a + let th-b = half-a * s-b * s-b + let th-m = half-a * s-m * s-m + x-acc += ( + sixth-step * (calc.cos(th-a) + 4.0 * calc.cos(th-m) + calc.cos(th-b)) + ) + y-acc += ( + sixth-step * (calc.sin(th-a) + 4.0 * calc.sin(th-m) + calc.sin(th-b)) + ) + } + (x: x-acc, y: y-acc) +} + +// Clothoid blend corner with linear curvature ramp. +#let _clothoid-piece(corner, pt, r, r-fit, budget, s, split: none) = { + let (edge-in, edge-out, ..) = _corner-geom.at(corner) + let available-budget = calc.min(budget.cw, budget.ccw) + if r-fit <= 0pt or available-budget <= 0pt { + return _sharp-piece(corner, pt, calc.min(0pt, r)) + } + + let s-clamped = calc.max(0.0, calc.min(1.0, s)) + let R = r-fit / 1pt + let d-theta = (calc.pi / 4.0) * s-clamped + let L = (calc.pi / 2.0) * R * s-clamped + + // Zero smoothing uses exact circular arc. + if L <= 0.0 { + return _piece( + corner, + pt, + r, + _corner-params(r-fit, 0.0, budget.ccw, false), + _corner-params(r-fit, 0.0, budget.cw, false), + split: split, + ) + } + + let A = 1.0 / (R * L) + + let end-pt = _integrate-clothoid(A, L) + let mid-pt = _integrate-clothoid(A, L / 2.0) + let x-c = end-pt.x + let y-c = end-pt.y + let x-mid = mid-pt.x + let y-mid = mid-pt.y + + let arc-cx = x-c - R * calc.sin(d-theta * 1rad) + let arc-cy = y-c + R * calc.cos(d-theta * 1rad) + let natural-p = (arc-cx + arc-cy) * 1pt + + let p = natural-p + let eff-R = R * 1pt + let eff-x = x-c * 1pt + let eff-y = y-c * 1pt + let eff-mx = x-mid * 1pt + let eff-my = y-mid * 1pt + + if natural-p > available-budget { + // Scale clothoid lengths and radius when footprint exceeds budget. + let scale-fac = available-budget / natural-p + p = available-budget + eff-R = eff-R * scale-fac + eff-x = eff-x * scale-fac + eff-y = eff-y * scale-fac + eff-mx = eff-mx * scale-fac + eff-my = eff-my * scale-fac + } + + let cos-dt = calc.cos(d-theta * 1rad) + let sin-dt = calc.sin(d-theta * 1rad) + let h1 = if sin-dt > 1e-12 { + ((8.0 / 3.0) * (eff-y / 2.0 - eff-my)) / sin-dt + } else { 0pt } + let h0 = (8.0 / 3.0) * (eff-mx - eff-x / 2.0) + h1 * cos-dt + + let canonical-to-display(px, py) = { + _vadd(_vadd(pt, _vscale(edge-in, p - px)), _vscale(edge-out, py)) + } + + let head-cubic = { + let b0 = canonical-to-display(0pt, 0pt) + let b1 = canonical-to-display(h0, 0pt) + let b2 = canonical-to-display(eff-x - h1 * cos-dt, eff-y - h1 * sin-dt) + let b3 = canonical-to-display(eff-x, eff-y) + _cubic(b0, b1, b2, b3) + } + + let tail-cubic = { + let b0 = canonical-to-display(p - eff-y, p - eff-x) + let b1 = canonical-to-display( + p - eff-y + h1 * sin-dt, + p - eff-x + h1 * cos-dt, + ) + // Tail cubic segment. + let b2 = canonical-to-display(p, p - h0) + let b3 = canonical-to-display(p, p) + _cubic(b0, b1, b2, b3) + } + + let arc-sweep = 90deg - 2.0 * (d-theta * 1rad) + let has-arc = calc.abs(arc-sweep) > 1e-6deg + + // Canonical arc endpoints and tangents. + let arc-from = (eff-x, eff-y) + let arc-to = (p - eff-y, p - eff-x) + let tangent-from = (cos-dt, sin-dt) + let tangent-to = (sin-dt, cos-dt) + + // Cubic segment along central arc. + let arc-cubic-between(from, t-from, to, t-to, sweep) = { + let a0 = canonical-to-display(from.at(0), from.at(1)) + let a1 = canonical-to-display(to.at(0), to.at(1)) + let kappa = (4.0 / 3.0) * calc.tan(sweep / 4.0) + // Transform tangent to display orientation. + let heading(at, t) = _vsub( + canonical-to-display(at.at(0) + t.at(0) * 1pt, at.at(1) + t.at(1) * 1pt), + canonical-to-display(at.at(0), at.at(1)), + ) + _cubic( + a0, + _vadd(a0, _vscale(heading(from, t-from), kappa * (eff-R / 1pt))), + _vsub(a1, _vscale(heading(to, t-to), kappa * (eff-R / 1pt))), + a1, + ) + } + + let full-segs = if has-arc { + ( + head-cubic, + arc-cubic-between( + arc-from, + tangent-from, + arc-to, + tangent-to, + arc-sweep, + ), + tail-cubic, + ) + } else { (head-cubic, tail-cubic) } + + let start = canonical-to-display(0pt, 0pt) + let end = canonical-to-display(p, p) + + let base = ( + pt: pt, + arc: true, + start: start, + end: end, + full: full-segs, + ) + if split == none { + return (..base, mid: pt, first: (), second: ()) + } + + // Ray origins use fitted footprint. + let diag = _vadd(edge-in, edge-out) + let center = _vadd(pt, _vscale(diag, p)) + let cut = _split-segs-on-ray(full-segs, center, _vadd(split, _vscale( + diag, + p - r, + ))) + (:..base, ..cut) +} diff --git a/packages/preview/dorodango/0.3.0/src/dictionaries.typ b/packages/preview/dorodango/0.3.0/src/dictionaries.typ new file mode 100644 index 0000000000..f90e1ac5f1 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/dictionaries.typ @@ -0,0 +1,129 @@ +#import "validation.typ": * + +// Side precedence: specific side, axis, rest, then parameter default. +#let _sides-from-dict(val, default: none) = { + let pick(side, axis) = val.at( + side, + default: val.at(axis, default: val.at("rest", default: default)), + ) + ( + top: pick("top", "y"), + right: pick("right", "x"), + bottom: pick("bottom", "y"), + left: pick("left", "x"), + ) +} + +#let _resolve-edges(val, default: 0pt) = { + if type(val) == dictionary { _sides-from-dict(val, default: default) } else { + (top: val, right: val, bottom: val, left: val) + } +} + +// Corner precedence: specific corner, vertical side, horizontal side, rest, then default. +#let _resolve-corner-dict(val, default: 0pt) = { + let rest = val.at("rest", default: none) + let side(name) = val.at(name, default: rest) + let top = side("top") + let bottom = side("bottom") + let left = side("left") + let right = side("right") + let corner(name, vertical, horizontal) = { + let own = val.at(name, default: none) + let resolved = if own != none { own } else if vertical != none { + vertical + } else { horizontal } + if resolved == none { default } else { resolved } + } + ( + top-left: corner("top-left", top, left), + top-right: corner("top-right", top, right), + bottom-right: corner("bottom-right", bottom, right), + bottom-left: corner("bottom-left", bottom, left), + ) +} + +#let _resolve-corners(val, default: 0pt) = { + if type(val) == dictionary { + _resolve-corner-dict(val, default: default) + } else { + ( + top-left: val, + top-right: val, + bottom-right: val, + bottom-left: val, + ) + } +} + +// Resolve per-side strokes. Omitted sides default to none. +#let _stroke-sides(val, has-fill) = { + if val == auto { + let s = if has-fill { none } else { 1pt + black } + (top: s, right: s, bottom: s, left: s) + } else if _is-side-dict(val) { + _sides-from-dict(val, default: none) + } else { + (top: val, right: val, bottom: val, left: val) + } +} + +// Normalize stroke fields, resolving auto values. +#let _fixed(val) = { + if val == none { return none } + let s = stroke(val) + ( + paint: if s.paint == auto { black } else { s.paint }, + thickness: if s.thickness == auto { 1pt } else { s.thickness }, + cap: if s.cap == auto { "butt" } else { s.cap }, + join: if s.join == auto { "miter" } else { s.join }, + dash: if s.dash == auto { none } else { s.dash }, + miter-limit: if s.miter-limit == auto { 4.0 } else { s.miter-limit }, + ) +} + +#let _is-solid(fixed) = ( + fixed == none + or fixed.dash == none + or ( + type(fixed.dash) == dictionary + and fixed.dash.at("array", default: ()).len() == 0 + ) +) + +// Compare paints via representation to handle gradients and patterns. +#let _paint-eq(a, b) = { + if a == b { true } + else if type(a) == color or type(b) == color { false } + else if type(a) != type(b) { false } + else { repr(a) == repr(b) } +} + +// Compare stroke fields with paint representation equality. +#let _fixed-eq(a, b) = { + if a == none or b == none { + a == none and b == none + } else { + ( + a.thickness == b.thickness + and a.cap == b.cap + and a.join == b.join + and a.dash == b.dash + and a.miter-limit == b.miter-limit + and _paint-eq(a.paint, b.paint) + ) + } +} + +// Check whether meeting strokes can be drawn continuously. +#let _same-stroke(a, b) = { + if a == none and b == none { + true + } else if a == none or b == none { + false + } else { + let filled-same = _paint-eq(a.paint, b.paint) and a.dash == b.dash + let stroked-same = a.cap == b.cap and a.thickness == b.thickness + filled-same and (_is-solid(a) or stroked-same) + } +} diff --git a/packages/preview/dorodango/0.3.0/src/geometry.typ b/packages/preview/dorodango/0.3.0/src/geometry.typ new file mode 100644 index 0000000000..0f3be1343d --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/geometry.typ @@ -0,0 +1,45 @@ +#import "validation.typ": * + +#let _vadd(u, v) = (u.at(0) + v.at(0), u.at(1) + v.at(1)) +#let _vsub(u, v) = (u.at(0) - v.at(0), u.at(1) - v.at(1)) +#let _vscale(v, k) = (v.at(0) * k, v.at(1) * k) +#let _hypot(v) = { + let x = v.at(0) / 1pt + let y = v.at(1) / 1pt + calc.sqrt(x * x + y * y) * 1pt +} + +#let _resolve-scalar(val, basis) = { + if type(val) == length { val } else if type(val) == ratio { + basis * (val / 100%) + } else if type(val) == relative { + basis * (val.ratio / 100%) + val.length + } else { val } +} + +#let _rel-is-zero(val) = { + if type(val) == length { val == 0pt } else if type(val) == ratio { + val == 0% + } else if type(val) == relative { + val.length == 0pt and val.ratio == 0% + } else { false } +} + +// Split inset into absolute length and ratio. +#let _split-inset(val) = { + if type(val) == length { (a: val, r: 0%) } else if ( + type(val) == ratio + ) { (a: 0pt, r: val) } else { (a: val.length, r: val.ratio) } +} + +// Solve padded dimension from body size and insets. +#let _resolve-auto-dim(body-dim, side-a, side-b) = { + let sa = _split-inset(side-a) + let sb = _split-inset(side-b) + let abs = sa.a + sb.a + let rat = sa.r + sb.r + if rat >= 100% { + _fail("inset exceeds the box size") + } + (body-dim + abs) / ((100% - rat) / 100%) +} diff --git a/packages/preview/dorodango/0.3.0/src/lib.typ b/packages/preview/dorodango/0.3.0/src/lib.typ new file mode 100644 index 0000000000..a335ae77b9 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/lib.typ @@ -0,0 +1,5 @@ +#import "squircle.typ": squircle +#import "superellipse.typ": superellipse +#import "clothoid.typ": clothoid + + diff --git a/packages/preview/dorodango/0.3.0/src/shape.typ b/packages/preview/dorodango/0.3.0/src/shape.typ new file mode 100644 index 0000000000..7032c78960 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/shape.typ @@ -0,0 +1,549 @@ +#import "validation.typ": * +#import "dictionaries.typ": * +#import "geometry.typ": * +#import "corners.typ": * + +// Shared drawing engine for `squircle`, `superellipse`, and `clothoid`. +// Mirrors `rect` layout and stroke handling, delegating corner construction to `_piece-for`. + +#let _draw-shape( + width: auto, + height: auto, + fill: none, + stroke: auto, + radius: 0pt, + inset: 5pt, + outset: 0pt, + per-edge-smoothing: false, + _piece-for, + body: none, +) = { + // Missing side keys fall back to defaults, matching rect. + let ins = _resolve-edges(inset, default: 5pt) + let base-outs = _resolve-edges(outset, default: 0pt) + let radius-corners = _resolve-corners(radius, default: 0pt) + + // Resolved stroke for each side. + let sides = _stroke-sides(stroke, fill != none) + .pairs() + .map(((k, v)) => (k, _fixed(v))) + .to-dict() + let plain = ( + _corner-order.all(c => _rel-is-zero(radius-corners.at(c))) + and _fixed-eq(sides.left, sides.top) + and _fixed-eq(sides.top, sides.right) + and _fixed-eq(sides.right, sides.bottom) + ) + + // Half thicknesses. Radius measures to the outer stroke edge. + let half = sides + .pairs() + .map(((k, v)) => (k, if v == none { none } else { v.thickness / 2 })) + .to-dict() + + // Split corners where meeting strokes differ. + let same = _corner-order + .map(c => ( + c, + _same-stroke(sides.at(_side-ccw.at(c)), sides.at(_side-cw.at(c))), + )) + .to-dict() + + // Clockwise runs of sides sharing a pen. + let runs = () + let cut = _corner-order.find(c => not same.at(c)) + if cut != none { + let current = cut + let last = cut + for _ in range(4) { + current = _next-cw.at(current) + if same.at(current) { continue } + runs.push((last, current)) + last = current + } + } else if sides.top != none { + runs.push(("top-left", "top-left")) + } + + // Run properties without tight-corner checks. + let segments = runs + .map(((start, end)) => { + let side-stroke = sides.at(_side-cw.at(start)) + if side-stroke == none { return none } + let start-cap = side-stroke.cap + ( + start: start, + end: end, + stroke: side-stroke, + start-cap: start-cap, + end-cap: { + let s = sides.at(_side-ccw.at(end)) + if s == none { start-cap } else { s.cap } + }, + solid: _is-solid(side-stroke), + ) + }) + .filter(s => s != none) + + // Corners where an outline splits. + let split-corners = () + for s in segments { + if s.start != s.end { + for c in (s.start, s.end) { + if c not in split-corners { split-corners.push(c) } + } + } + } + + // layout is already contextual, so measure needs no context. + layout(container-size => { + let cw = container-size.width + let ch = container-size.height + let unbounded(l) = calc.abs(l / 1pt) == calc.inf + + // In unbounded regions, rect drops ratio components. + let resolve-size(val, basis) = { + if not unbounded(basis) { _resolve-scalar(val, basis) } else if ( + type(val) == ratio + ) { 0pt } else if type(val) == relative { val.length } else { val } + } + + // Measure body width, keeping results for auto-height. + let (w, measured) = if width != auto { + (resolve-size(width, cw), none) + } else if body == none { + // Default size when body is omitted. + (calc.min(45pt, cw), none) + } else if unbounded(cw) { + // Unbounded widths do not line-break. + let m = measure(body) + (_resolve-auto-dim(m.width, ins.left, ins.right), m) + } else if type(ins.left) == length and type(ins.right) == length { + // Absolute padding does not depend on width. + let m = measure(body, width: calc.max(0pt, cw - ins.left - ins.right)) + (calc.min(m.width + ins.left + ins.right, cw), m) + } else { + // Ratio insets require iterative fixed-point layout. + let w-fp = cw + // Break early once width converges. + for _ in range(40) { + let pl = _resolve-scalar(ins.left, w-fp) + let pr = _resolve-scalar(ins.right, w-fp) + let inner = calc.max(0pt, w-fp - pl - pr) + let next = measure(body, width: inner).width + pl + pr + let settled = calc.abs(next - w-fp) < 0.0001pt + w-fp = next + if settled { break } + } + (calc.min(w-fp, cw), none) + } + + // Draw once final box dimensions are known. + let draw(w, h) = { + // Outsets resolve against box size. + let outs = ( + top: _resolve-scalar(base-outs.top, h), + right: _resolve-scalar(base-outs.right, w), + bottom: _resolve-scalar(base-outs.bottom, h), + left: _resolve-scalar(base-outs.left, w), + ) + let out-w = w + outs.left + outs.right + let out-h = h + outs.top + outs.bottom + + // Unrounded boxes use closed rectangles. + let shapes = if plain { + ( + ( + fill: fill, + stroke: sides.top, + elems: ( + curve.move((0pt, 0pt)), + curve.line((out-w, 0pt)), + curve.line((out-w, out-h)), + curve.line((0pt, out-h)), + curve.close(mode: "straight"), + ), + ), + ) + } else { + let box-corner = ( + top-left: (0pt, 0pt), + top-right: (out-w, 0pt), + bottom-right: (out-w, out-h), + bottom-left: (0pt, out-h), + ) + + // Control points for outer, middle, and inner outlines. + let base-radius = calc.min(calc.abs(out-w), calc.abs(out-h)) / 2 + let control = (:) + for corner in _corner-order { + let (edge-in, edge-out, ..) = _corner-geom.at(corner) + let sb-opt = half.at(_side-ccw.at(corner)) + let sa-opt = half.at(_side-cw.at(corner)) + // Allow thinner stroke thickness on top of half the short side. + let both = if sb-opt != none and sa-opt != none { + calc.min(sb-opt, sa-opt) + } else { 0pt } + let corner-max = base-radius + both + let r-outer = calc.min( + _resolve-scalar(radius-corners.at(corner), corner-max * 2), + corner-max, + ) + // Unstroked sides borrow neighboring stroke width if radius permits. + let sb = if sb-opt != none { sb-opt } else if ( + sa-opt != none and 2 * sa-opt < r-outer + ) { sa-opt } else { 0pt } + let sa = if sa-opt != none { sa-opt } else if ( + sb-opt != none and 2 * sb-opt < r-outer + ) { sb-opt } else { 0pt } + let sc = box-corner.at(corner) + control.insert(corner, ( + sb-set: sb-opt != none, + sa-set: sa-opt != none, + sb: sb, + sa: sa, + r-outer: r-outer, + r-mid: calc.max(0pt, r-outer - calc.min(sb, sa)), + r-inner: calc.max(0pt, r-outer - 2 * calc.max(sb, sa)), + pt-outer: _vsub( + sc, + _vadd(_vscale(edge-out, sb), _vscale(edge-in, sa)), + ), + pt-mid: sc, + pt-inner: _vadd( + sc, + _vadd(_vscale(edge-out, sb), _vscale(edge-in, sa)), + ), + )) + } + + // Build one rounded outline. + let contour(pt-key, r-key, splits) = { + let pts = (:) + let radii = (:) + for corner in _corner-order { + pts.insert(corner, control.at(corner).at(pt-key)) + radii.insert(corner, calc.max(0pt, control.at(corner).at(r-key))) + } + let side-lens = ( + top: calc.abs(pts.top-right.at(0) - pts.top-left.at(0)), + right: calc.abs(pts.bottom-right.at(1) - pts.top-right.at(1)), + bottom: calc.abs(pts.bottom-right.at(0) - pts.bottom-left.at(0)), + left: calc.abs(pts.bottom-left.at(1) - pts.top-left.at(1)), + ) + let budget = _budgets(radii, side-lens, per-edge-smoothing) + let out = (:) + for corner in _corner-order { + out.insert(corner, _piece-for( + corner, + pts.at(corner), + // Raw radius is passed through for sharp-corner inward pull. + control.at(corner).at(r-key), + radii.at(corner), + budget.at(corner), + splits.at(corner, default: none), + )) + } + out + } + + // Fill corner when pens differ or stroke is wider than remaining radius. + let fill-corner(corner) = { + let c = control.at(corner) + let sb-val = if c.sb-set { c.sb } else { none } + let sa-val = if c.sa-set { c.sa } else { none } + sb-val != sa-val or c.r-mid < c.sb + } + let fill-corners(start, end) = { + let any = fill-corner(start) or fill-corner(end) + let cur = _next-cw.at(start) + while cur != end { + any = any or fill-corner(cur) + cur = _next-cw.at(cur) + } + any + } + + let segs = segments.map(s => ( + ..s, + ring: s.solid and fill-corners(s.start, s.end), + )) + + // Build outlines needed for rendering. + let need-ring = segs.any(s => s.ring) + let need-mid = fill != none or segs.any(s => not s.ring) + + // Radial split points from outer corner. + let mid-splits = (:) + let inner-splits = (:) + let outer-splits = (:) + for corner in split-corners { + let c = control.at(corner) + let (edge-in, edge-out, ..) = _corner-geom.at(corner) + let diag = _vadd(edge-in, edge-out) + let o = c.pt-outer + let along(center, from, r) = { + let d = _vsub(from, center) + let h = _hypot(d) + if h == 0pt { center } else { + _vadd(center, _vscale(d, r / h)) + } + } + if need-mid { + let center-mid = _vadd(c.pt-mid, _vscale(diag, c.r-mid)) + mid-splits.insert(corner, along(center-mid, o, c.r-mid)) + } + if need-ring { + let center-inner = _vadd(c.pt-inner, _vscale(diag, c.r-inner)) + let center-outer = _vadd(c.pt-outer, _vscale(diag, c.r-outer)) + inner-splits.insert(corner, along(center-inner, o, c.r-inner)) + + // https://math.stackexchange.com/a/311956 + let d = _vsub(o, center-inner) + let g = _vsub(center-inner, center-outer) + let dx = d.at(0) / 1pt + let dy = d.at(1) / 1pt + let gx = g.at(0) / 1pt + let gy = g.at(1) / 1pt + let qa = dx * dx + dy * dy + let qb = 2 * (dx * gx + dy * gy) + let qc = gx * gx + gy * gy - (c.r-outer / 1pt) * (c.r-outer / 1pt) + let t = if qa == 0 { 1.0 } else { + (-qb + calc.sqrt(calc.max(0.0, qb * qb - 4 * qa * qc))) / (2 * qa) + } + outer-splits.insert(corner, _vadd(center-inner, _vscale(d, t))) + } + } + + let outer = if need-ring { + contour("pt-outer", "r-outer", outer-splits) + } + let mid = if need-mid { contour("pt-mid", "r-mid", mid-splits) } + let inner = if need-ring { + contour("pt-inner", "r-inner", inner-splits) + } + + // Open middle stroke run. + let stroke-segment(start, end) = { + let out = () + let c = mid.at(start) + if start == end or not c.arc { + out.push(curve.move(c.end)) + } else { + out.push(curve.move(c.mid)) + out += _emit(c.second) + } + let cur = _next-cw.at(start) + while cur != end { + let c = mid.at(cur) + if c.arc { + out.push(curve.line(c.start)) + out += _emit(c.full) + } else { out.push(curve.line(c.end)) } + cur = _next-cw.at(cur) + } + let c = mid.at(end) + if not c.arc { + out.push(curve.line(c.start)) + } else if start == end { + out.push(curve.line(c.start)) + out += _emit(c.full) + } else { + out.push(curve.line(c.start)) + out += _emit(c.first) + } + out + } + + // Caps beside unstroked sides. + let cap-at(corner, cap-type, at-start) = { + let c = control.at(corner) + let co = outer.at(corner) + let ci = inner.at(corner) + let (butt-start, butt-end) = if at-start { + (ci.mid, co.mid) + } else { (co.mid, ci.mid) } + let neighbor-set = if at-start { c.sb-set } else { c.sa-set } + let borrow = if at-start { c.sa-set and 2 * c.sa < c.r-outer } else { + c.sb-set and 2 * c.sb < c.r-outer + } + let keep-butt = c.r-outer != 0pt and not borrow + if cap-type == "butt" or neighbor-set or keep-butt { + (curve.line(butt-end),) + } else if cap-type == "square" { + // Square cap extension. + let reach = if at-start { c.sa } else { c.sb } + let off = _vscale(_line-normal(butt-start, butt-end), reach) + ( + curve.line(_vadd(butt-start, off)), + curve.line(_vadd(butt-end, off)), + curve.line(butt-end), + ) + } else { + let center = _vadd( + _vscale(_vadd(butt-start, butt-end), 0.5), + _vscale(_line-normal(butt-start, butt-end), -_cap-nudge), + ) + (_arc-through(butt-start, center, butt-end),) + } + } + + // Filled ring run. + let fill-segment(start, end, start-cap, end-cap) = { + let out = () + if start == end { + out.push(curve.move(inner.at(start).end)) + out.push(curve.line(outer.at(start).end)) + } else { + let ci = inner.at(start) + out.push(curve.move(ci.end)) + if ci.arc { out += _emit-rev(ci.second) } + out += cap-at(start, start-cap, true) + let co = outer.at(start) + if co.arc { + out.push(curve.line(co.mid)) + out += _emit(co.second) + } + } + + let cur = _next-cw.at(start) + while cur != end { + let co = outer.at(cur) + if co.arc { + out.push(curve.line(co.start)) + out += _emit(co.full) + } else { out.push(curve.line(co.pt)) } + cur = _next-cw.at(cur) + } + + if start == end { + let co = outer.at(end) + if co.arc { + out.push(curve.line(co.start)) + out += _emit(co.full) + } else { + out.push(curve.line(co.pt)) + out.push(curve.line(co.end)) + } + let ci = inner.at(end) + if ci.arc { + out.push(curve.line(ci.end)) + out += _emit-rev(ci.full) + } else { out.push(curve.line(ci.pt)) } + } else { + let co = outer.at(end) + if co.arc { + out.push(curve.line(co.start)) + out += _emit(co.first) + } else { out.push(curve.line(co.pt)) } + out += cap-at(end, end-cap, false) + let ci = inner.at(end) + if ci.arc { + out.push(curve.line(ci.mid)) + out += _emit-rev(ci.first) + } + } + + let cur = _next-ccw.at(end) + while cur != start { + let ci = inner.at(cur) + if ci.arc { + out.push(curve.line(ci.end)) + out += _emit-rev(ci.full) + } else { out.push(curve.line(ci.pt)) } + cur = _next-ccw.at(cur) + } + + out + (curve.close(mode: "straight"),) + } + + let below = () + let above = () + if fill != none { + // Fill follows the middle outline. + let fill-path = { + let c = mid.top-left + let out = if c.arc { + (curve.move(c.start),) + _emit(c.full) + } else { (curve.move(c.pt),) } + for corner in ("top-right", "bottom-right", "bottom-left") { + let c = mid.at(corner) + if c.arc { + out += (curve.line(c.start),) + _emit(c.full) + } else { out += (curve.line(c.pt),) } + } + out + (curve.close(mode: "straight"),) + } + below.push((fill: fill, stroke: none, elems: fill-path)) + } + // Render stroked runs under filled runs. + for s in segs { + if s.ring { + above.push(( + fill: s.stroke.paint, + stroke: none, + elems: fill-segment(s.start, s.end, s.start-cap, s.end-cap), + )) + } else { + below.push(( + fill: none, + stroke: s.stroke, + elems: stroke-segment(s.start, s.end), + )) + } + } + below + above + } + + let drawn = shapes.map(s => place( + top + left, + dx: -outs.left, + dy: -outs.top, + curve(fill: s.fill, stroke: s.stroke, ..s.elems), + )) + + box( + width: w, + height: h, + drawn.join() + box(width: 100%, height: 100%, inset: ins, body), + ) + } + + if type(height) == fraction { + // Fractional heights resolve within container layout. + block( + width: w, + height: height, + spacing: 0pt, + block( + width: 100%, + height: 100%, + spacing: 0pt, + layout(inner => draw(w, inner.height)), + ), + ) + } else { + // Height resolved after width layout. + let h = if height != auto { + resolve-size(height, ch) + } else if body == none { + calc.min(30pt, ch) + } else if measured != none { + // Reuse cached measurement. + _resolve-auto-dim(measured.height, ins.top, ins.bottom) + } else { + let inner-w = calc.max( + 0pt, + w - _resolve-scalar(ins.left, w) - _resolve-scalar(ins.right, w), + ) + _resolve-auto-dim( + measure(body, width: inner-w).height, + ins.top, + ins.bottom, + ) + } + draw(w, h) + } + }) +} + diff --git a/packages/preview/dorodango/0.3.0/src/squircle.typ b/packages/preview/dorodango/0.3.0/src/squircle.typ new file mode 100644 index 0000000000..3af127eef1 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/squircle.typ @@ -0,0 +1,175 @@ +#import "validation.typ": * +#import "dictionaries.typ": * +#import "geometry.typ": * +#import "corners.typ": * +#import "shape.typ": _draw-shape + +/// Draws a rectangle with smoothly rounded corners. +/// +/// Use `squircle` like `rect` when you want softer, more continuous corner +/// transitions. With `smoothing: 0%`, it has the same geometry as a rounded +/// `rect`. +/// +/// -> content +#let squircle( + /// The squircle's width, relative to its parent container. + /// -> auto | length | ratio | relative + width: auto, + /// The squircle's height, relative to its parent container. + /// -> auto | length | ratio | relative | fraction + height: auto, + /// How to fill the squircle. + /// + /// When setting a fill, the default stroke disappears. To create a squircle + /// with both fill and stroke, you have to configure both. + /// -> none | color | gradient | tiling + fill: none, + /// How to stroke the squircle. This can be: + /// + /// - `none` to disable stroking. + /// - `auto` for a stroke of `1pt + black` if and only if no fill is given. + /// - Any kind of stroke. + /// - A dictionary describing the stroke for each side individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top`: The top stroke. + /// - `right`: The right stroke. + /// - `bottom`: The bottom stroke. + /// - `left`: The left stroke. + /// - `x`: The left and right stroke. + /// - `y`: The top and bottom stroke. + /// - `rest`: The stroke on all sides except those for which the dictionary + /// explicitly sets a stroke. + /// + /// All keys are optional. Omitted sides are not stroked. + /// -> auto | none | length | color | gradient | tiling | stroke | dictionary + stroke: auto, + /// How much to round the squircle's corners, relative to the minimum of the + /// width and height divided by two. This can be: + /// + /// - A relative length for a uniform corner radius. + /// - A dictionary describing the radius for each corner individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top-left`: The top-left corner radius. + /// - `top-right`: The top-right corner radius. + /// - `bottom-right`: The bottom-right corner radius. + /// - `bottom-left`: The bottom-left corner radius. + /// - `left`: The top-left and bottom-left corner radii. + /// - `top`: The top-left and top-right corner radii. + /// - `right`: The top-right and bottom-right corner radii. + /// - `bottom`: The bottom-left and bottom-right corner radii. + /// - `rest`: The radii for all corners except those for which the + /// dictionary explicitly sets a radius. + /// -> length | ratio | relative | dictionary + radius: 0pt, + /// How much to pad the squircle's content. See `box`'s `inset` parameter for + /// more details. + /// -> length | ratio | relative | dictionary + inset: 5pt, + /// How much to expand the squircle's size without affecting the layout. See + /// `box`'s `outset` parameter for more details. + /// -> length | ratio | relative | dictionary + outset: 0pt, + /// How strongly to smooth the squircle's corners. Smoothing replaces the + /// ends of each circular corner arc with Bézier transitions whose curvature + /// gradually changes between the straight edges and the arc. This can be: + /// + /// - A relative length for uniform corner smoothing. + /// - A dictionary describing the smoothing for each corner individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top-left`: The top-left corner smoothing. + /// - `top-right`: The top-right corner smoothing. + /// - `bottom-right`: The bottom-right corner smoothing. + /// - `bottom-left`: The bottom-left corner smoothing. + /// - `left`: The top-left and bottom-left corner smoothing. + /// - `top`: The top-left and top-right corner smoothing. + /// - `right`: The top-right and bottom-right corner smoothing. + /// - `bottom`: The bottom-left and bottom-right corner smoothing. + /// - `rest`: The smoothing for all corners except those for which the + /// dictionary explicitly sets smoothing. + /// + /// At `0%`, the corner is a quarter circle matching a rounded `rect`. At + /// `100%`, it is two Bézier transitions with no circular arc. + /// + /// A corner's two edges normally share one available space, the smaller of + /// the two. See `per-edge-smoothing` to change that. + /// -> length | ratio | relative | dictionary + smoothing: 60%, + /// Whether to preserve the requested smoothing when it exceeds available + /// edge space. This has no effect when smoothing already fits. + /// + /// If `false`, smoothing scales down to fit. If `true`, both the requested + /// radius and smoothing are retained by compressing the Bézier transitions. + /// -> bool + preserve-smoothing: false, + /// Whether each half of a corner can use all the space available on its edge + /// when requested smoothing exceeds available room along one or both edges. + /// + /// If `false`, each corner's two transition angles stay symmetric and share + /// the tighter edge limit. If `true`, both halves smooth independently. + /// -> bool + per-edge-smoothing: false, + /// The content to place into the squircle. Strings and symbols are converted + /// to content. + /// + /// When this is omitted, the squircle takes on a default size of at most + /// `45pt` by `30pt`. + /// -> none | content | str | symbol + ..body, +) = { + _validate-size("width", width) + _validate-size("height", height, fraction-ok: true) + _validate-fill(fill) + stroke = _validate-stroke(stroke) + _validate-relative-or-dict("radius", radius, _corner-keys) + _validate-relative-or-dict("inset", inset, _side-keys) + _validate-relative-or-dict("outset", outset, _side-keys) + _validate-relative-or-dict("smoothing", smoothing, _corner-keys) + _expect("preserve-smoothing", preserve-smoothing, (bool,), "boolean") + _expect("per-edge-smoothing", per-edge-smoothing, (bool,), "boolean") + body = _validate-body(body) + + let smoothing-corners = _resolve-corners(smoothing, default: 60%) + let smoothings = _corner-order + .map(c => ( + c, + calc.max( + 0.0, + calc.min(1.0, _resolve-scalar(smoothing-corners.at(c), 1pt) / 1pt), + ), + )) + .to-dict() + + let _piece-for(corner, pt, r, r-fit, budget, split) = { + _piece( + corner, + pt, + r, + _corner-params( + r-fit, + smoothings.at(corner), + budget.ccw, + preserve-smoothing, + ), + _corner-params( + r-fit, + smoothings.at(corner), + budget.cw, + preserve-smoothing, + ), + split: split, + ) + } + + _draw-shape( + width: width, + height: height, + fill: fill, + stroke: stroke, + radius: radius, + inset: inset, + outset: outset, + per-edge-smoothing: per-edge-smoothing, + _piece-for, + body: body, + ) +} diff --git a/packages/preview/dorodango/0.3.0/src/superellipse.typ b/packages/preview/dorodango/0.3.0/src/superellipse.typ new file mode 100644 index 0000000000..2cc6619952 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/superellipse.typ @@ -0,0 +1,125 @@ +#import "validation.typ": * +#import "dictionaries.typ": * +#import "geometry.typ": * +#import "corners.typ": * +#import "shape.typ": _draw-shape + +/// Draws a rectangle with superellipse (Lamé curve) corners. +/// +/// Use `superellipse` like `rect` when you want cubic approximations of Lamé +/// curve corners parameterized by a power exponent. For exponents above 2, the +/// ideal Lamé curve has zero curvature where it meets the straight edges. At +/// `exponent: 2`, the corners are circular arcs matching a rounded `rect`. +/// Higher exponents make the corners squarer. +/// +/// -> content +#let superellipse( + /// The superellipse's width, relative to its parent container. + /// -> auto | length | ratio | relative + width: auto, + /// The superellipse's height, relative to its parent container. + /// -> auto | length | ratio | relative | fraction + height: auto, + /// How to fill the superellipse. + /// + /// When setting a fill, the default stroke disappears. To create a + /// superellipse with both fill and stroke, you have to configure both. + /// -> none | color | gradient | tiling + fill: none, + /// How to stroke the superellipse. This can be: + /// + /// - `none` to disable stroking. + /// - `auto` for a stroke of `1pt + black` if and only if no fill is given. + /// - Any kind of stroke. + /// - A dictionary describing the stroke for each side individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top`: The top stroke. + /// - `right`: The right stroke. + /// - `bottom`: The bottom stroke. + /// - `left`: The left stroke. + /// - `x`: The left and right stroke. + /// - `y`: The top and bottom stroke. + /// - `rest`: The stroke on all sides except those for which the dictionary + /// explicitly sets a stroke. + /// + /// All keys are optional. Omitted sides are not stroked. + /// -> auto | none | length | color | gradient | tiling | stroke | dictionary + stroke: auto, + /// How much to round the superellipse's corners, relative to the minimum of + /// the width and height divided by two. This can be: + /// + /// - A relative length for a uniform corner radius. + /// - A dictionary describing the radius for each corner individually. The + /// dictionary can contain the following keys in order of precedence: + /// - `top-left`: The top-left corner radius. + /// - `top-right`: The top-right corner radius. + /// - `bottom-right`: The bottom-right corner radius. + /// - `bottom-left`: The bottom-left corner radius. + /// - `left`: The top-left and bottom-left corner radii. + /// - `top`: The top-left and top-right corner radii. + /// - `right`: The top-right and bottom-right corner radii. + /// - `bottom`: The bottom-left and bottom-right corner radii. + /// - `rest`: The radii for all corners except those for which the + /// dictionary explicitly sets a radius. + /// -> length | ratio | relative | dictionary + radius: 0pt, + /// How much to pad the superellipse's content. See `box`'s `inset` + /// parameter for more details. + /// -> length | ratio | relative | dictionary + inset: 5pt, + /// How much to expand the superellipse's size without affecting the layout. + /// See `box`'s `outset` parameter for more details. + /// -> length | ratio | relative | dictionary + outset: 0pt, + /// The Lamé curve exponent $n$ in $|x/p|^n + |y/p|^n = 1$. + /// + /// Controls the corner shape profile. Values must be finite and are clamped + /// into $[2, 12]$. Below 2 the curve would bulge inward, while above 12 + /// the three-cubic fit degrades and control points can leave the corner's + /// footprint. Near the upper bound the fit trades fidelity for containment. + /// At 2 the curve is a circle, and the corner drawn is the circular arc `rect` + /// draws rather than an approximation. + /// -> int | float + exponent: 5, + /// The content to place into the superellipse. Strings and symbols are + /// converted to content. + /// + /// When this is omitted, the superellipse takes on a default size of at most + /// `45pt` by `30pt`. + /// -> none | content | str | symbol + ..body, +) = { + _validate-size("width", width) + _validate-size("height", height, fraction-ok: true) + _validate-fill(fill) + stroke = _validate-stroke(stroke) + _validate-relative-or-dict("radius", radius, _corner-keys) + _validate-relative-or-dict("inset", inset, _side-keys) + _validate-relative-or-dict("outset", outset, _side-keys) + _validate-number("exponent", exponent) + body = _validate-body(body) + + let _piece-for(corner, pt, r, r-fit, budget, split) = { + _superellipse-piece( + corner, + pt, + r, + r-fit, + budget, + exponent, + split: split, + ) + } + + _draw-shape( + width: width, + height: height, + fill: fill, + stroke: stroke, + radius: radius, + inset: inset, + outset: outset, + _piece-for, + body: body, + ) +} diff --git a/packages/preview/dorodango/0.3.0/src/validation.typ b/packages/preview/dorodango/0.3.0/src/validation.typ new file mode 100644 index 0000000000..ce9a1ed834 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/src/validation.typ @@ -0,0 +1,187 @@ +#let _fail(msg) = panic("squircle: " + msg) + +// Joins list items with conjunctions, matching Typst formatting. +#let _oxford(items, conj) = { + if items.len() == 2 { + items.at(0) + " " + conj + " " + items.at(1) + } else { items.join(", ", last: ", " + conj + " ") } +} + +#let _list-keys(keys) = _oxford(keys.map(k => "\"" + k + "\""), "and") + +#let _list-types(types) = _oxford(types.map(str), "or") + +#let _bad-keys(val, allowed) = val.keys().filter(k => k not in allowed) + +#let _key-error(name, bad, allowed) = _fail( + name + + ": " + + (if bad.len() == 1 { "unexpected key " } else { "unexpected keys " }) + + _list-keys(bad) + + ", valid keys are " + + _list-keys(allowed), +) + +#let _found(value) = str(type(value)) + +#let _expect(name, value, accepted, expected) = { + if type(value) not in accepted { + _fail(name + ": expected " + expected + ", found " + _found(value)) + } +} + +#let _validate-number(name, value) = { + _expect(name, value, (int, float), "number") + if float.is-nan(value) { + _fail(name + ": expected finite number, found NaN") + } + if float.is-infinite(value) { + let sign = if value > 0 { "positive" } else { "negative" } + _fail(name + ": expected finite number, found " + sign + " infinity") + } +} + +#let _relative-types = (length, ratio, relative) + +// Format expected size types. +#let _validate-size(name, value, fraction-ok: false) = { + let accepted = (type(auto),) + _relative-types + if fraction-ok { accepted.push(fraction) } + _expect( + name, + value, + accepted, + "auto or relative length" + if fraction-ok { " or fraction" } else { "" }, + ) +} + +#let _validate-fill(value) = _expect( + "fill", + value, + (color, gradient, tiling, type(none)), + "paint or none", +) + +// Supported corner keys. +#let _corner-keys = ( + "top-left", + "top-right", + "bottom-right", + "bottom-left", + "left", + "top", + "right", + "bottom", + "rest", +) + +#let _side-keys = ("left", "top", "right", "bottom", "x", "y", "rest") + +// Distinguish per-side dictionary from stroke properties. +#let _is-side-dict(val) = ( + type(val) == dictionary and _bad-keys(val, _side-keys).len() == 0 +) + +#let _validate-relative(name, value) = _expect( + name, + value, + _relative-types, + "relative length", +) + +#let _validate-relative-or-dict(name, value, keys) = { + if type(value) != dictionary { + return _validate-relative(name, value) + } + + let bad = _bad-keys(value, keys) + if bad.len() > 0 { _key-error(name, bad, keys) } + for (key, item) in value { + _validate-relative(name + "." + key, item) + } +} + +#let _stroke-keys = ("paint", "thickness", "cap", "join", "dash", "miter-limit") + +// Supported stroke input types. +#let _stroke-input-types = (length, color, gradient, tiling, dictionary, stroke) +#let _stroke-side-types = _stroke-input-types + (type(none),) + +#let _as-stroke(name, value) = { + _expect(name, value, _stroke-input-types, _list-types(_stroke-input-types)) + if type(value) == dictionary { + let bad = _bad-keys(value, _stroke-keys) + if bad.len() > 0 { _key-error(name, bad, _stroke-keys) } + } + stroke(value) +} + +#let _validate-stroke(value) = { + if value in (auto, none) { return value } + if type(value) != dictionary { return _as-stroke("stroke", value) } + + if _is-side-dict(value) { + let out = (:) + for (key, item) in value { + // Reject auto for individual side strokes. + if item == auto { + _fail( + "stroke." + + key + + ": expected " + + _list-types(_stroke-side-types) + + ", found auto", + ) + } else if item == none { + out.insert(key, none) + } else { + out.insert(key, _as-stroke("stroke." + key, item)) + } + } + return out + } + + if _bad-keys(value, _stroke-keys).len() == 0 { + return _as-stroke("stroke", value) + } + + // Report mixed or invalid dictionary keys. + let all-keys = _side-keys + _stroke-keys + let bad = _bad-keys(value, all-keys) + if bad.len() > 0 { + _key-error("stroke", bad, all-keys) + } else { + _fail("stroke: cannot mix side keys with stroke-property keys") + } +} + +#let _validate-body(args) = { + // Validate positional body argument and reject unexpected named arguments. + let named = args.named() + if "body" in named { + _fail( + "unexpected named argument \"body\"; " + + "the body must be specified positionally", + ) + } + if named.len() > 0 { + _fail("unexpected argument: " + named.keys().first()) + } + + let positional = args.pos() + if positional.len() > 1 { + _fail( + "expected at most one positional body, found " + str(positional.len()), + ) + } + if positional.len() == 0 or positional.first() == none { return none } + + let body = positional.first() + if type(body) == content { body } else if type(body) in (str, symbol) { + [#body] + } else { + _fail( + "body: expected content, string, symbol, or none, found " + _found(body), + ) + } +} diff --git a/packages/preview/dorodango/0.3.0/typst.toml b/packages/preview/dorodango/0.3.0/typst.toml new file mode 100644 index 0000000000..e683c09014 --- /dev/null +++ b/packages/preview/dorodango/0.3.0/typst.toml @@ -0,0 +1,10 @@ +[package] +name = "dorodango" +version = "0.3.0" +entrypoint = "src/lib.typ" +authors = ["Antonio Camargo "] +license = "MIT" +description = "A package for drawing squircles with tunable corner smoothing" +categories = ["components"] +keywords = ["squircle", "superellipse", "clothoid", "shape"] +exclude = ["assets", "docs", "tests"]