Add the Material 3 Expressive foundation
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
Colour, shape, type, elevation and motion as tokens and Tailwind utilities; `php artisan material:scheme`, which generates an app's colour roles with Google's material-color-utilities (spec 2025); the theme head script with light, dark and system and its Alpine store; Google Sans Flex; every Material Symbol (4,135, outlined and filled) drawn by <x-icon> without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as <x-shape>; the x-figure directive; the Toasts concern; DesignGuard for applications' tests; and a showcase with every token, both themes side by side and an icon search. Colour utilities are `@theme inline`, so a section with its own data-theme repaints; without it they resolve once on :root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b53891a8e
commit
b48e879254
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Refresh resources/svg/symbols from google/material-design-icons (Apache-2.0).
|
||||
#
|
||||
# Material Symbols Rounded at weight 400, grade 0 and optical size 24, outlined and
|
||||
# filled, for every symbol Google publishes. Maintenance only: applications never run
|
||||
# this, and bin/ is not part of the archive Composer installs.
|
||||
#
|
||||
# The repository is ~5 GB, so it is cloned without blobs and checked out sparsely:
|
||||
# git downloads only the two files per symbol that are kept. The @material-symbols
|
||||
# npm packages were not used because their SVGs are drawn at optical size 48, whose
|
||||
# strokes are thinner than the 24px design.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
git clone --quiet --depth=1 --filter=blob:none --no-checkout \
|
||||
https://github.com/google/material-design-icons.git "$work/repo"
|
||||
|
||||
cd "$work/repo"
|
||||
|
||||
git sparse-checkout set --no-cone \
|
||||
'/symbols/web/*/materialsymbolsrounded/*_24px.svg' \
|
||||
'!/symbols/web/*/materialsymbolsrounded/*_wght[0-9]*' \
|
||||
'!/symbols/web/*/materialsymbolsrounded/*_grad200*' \
|
||||
'!/symbols/web/*/materialsymbolsrounded/*_gradN25*'
|
||||
|
||||
git checkout --quiet "$(git symbolic-ref --short HEAD)"
|
||||
|
||||
out="$root/resources/svg/symbols"
|
||||
rm -rf "$out/outlined" "$out/filled"
|
||||
mkdir -p "$out/outlined" "$out/filled"
|
||||
|
||||
# Google's files carry width="24" height="24" and no fill. Drop the size so CSS decides
|
||||
# it, and paint in currentColor so an icon takes the colour of the text around it.
|
||||
normalise() {
|
||||
sed -E \
|
||||
-e 's/ (width|height)="[0-9]+"//g' \
|
||||
-e 's/<svg /<svg fill="currentColor" /' \
|
||||
"$1"
|
||||
}
|
||||
|
||||
missing=0
|
||||
|
||||
for dir in symbols/web/*/materialsymbolsrounded; do
|
||||
name="$(basename "$(dirname "$dir")")"
|
||||
outlined="$dir/${name}_24px.svg"
|
||||
filled="$dir/${name}_fill1_24px.svg"
|
||||
|
||||
if [[ ! -f "$outlined" || ! -f "$filled" ]]; then
|
||||
echo "skipped $name: no outlined and filled 24px pair" >&2
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
normalise "$outlined" > "$out/outlined/$name.svg"
|
||||
normalise "$filled" > "$out/filled/$name.svg"
|
||||
done
|
||||
|
||||
git rev-parse HEAD > "$out/SOURCE"
|
||||
|
||||
echo "$(ls "$out/outlined" | wc -l | tr -d ' ') symbols from google/material-design-icons@$(cut -c1-12 "$out/SOURCE"), $missing skipped"
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* The source of resources/node/scheme.mjs, which `php artisan material:scheme` runs.
|
||||
*
|
||||
* Rebuild with `npm run build:scheme` after changing this file or upgrading
|
||||
* @material/material-color-utilities; the bundle is committed so applications need
|
||||
* nothing but `node`. (The published library imports without file extensions, which
|
||||
* plain Node refuses, so it cannot be run unbundled anyway.)
|
||||
*
|
||||
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
|
||||
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
||||
*/
|
||||
import {
|
||||
argbFromHex,
|
||||
customColor,
|
||||
hexFromArgb,
|
||||
Hct,
|
||||
MaterialDynamicColors,
|
||||
SchemeContent,
|
||||
SchemeExpressive,
|
||||
SchemeFidelity,
|
||||
SchemeFruitSalad,
|
||||
SchemeMonochrome,
|
||||
SchemeNeutral,
|
||||
SchemeRainbow,
|
||||
SchemeTonalSpot,
|
||||
SchemeVibrant,
|
||||
} from '@material/material-color-utilities'
|
||||
|
||||
const VARIANTS = {
|
||||
'tonal-spot': SchemeTonalSpot,
|
||||
vibrant: SchemeVibrant,
|
||||
expressive: SchemeExpressive,
|
||||
neutral: SchemeNeutral,
|
||||
fidelity: SchemeFidelity,
|
||||
content: SchemeContent,
|
||||
monochrome: SchemeMonochrome,
|
||||
rainbow: SchemeRainbow,
|
||||
'fruit-salad': SchemeFruitSalad,
|
||||
}
|
||||
|
||||
// Key colours seed the palettes; they are not roles a stylesheet paints with.
|
||||
const NOT_ROLES = /_palette_key_color$/
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let input
|
||||
|
||||
try {
|
||||
input = JSON.parse(process.argv[2] ?? '')
|
||||
} catch {
|
||||
fail('Expected one JSON argument: {"seed": "#rrggbb", "variant": "tonal-spot", ...}')
|
||||
}
|
||||
|
||||
const hex = /^#[0-9a-f]{6}$/i
|
||||
const Scheme = VARIANTS[input.variant]
|
||||
|
||||
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
|
||||
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
||||
|
||||
for (const state of ['success', 'warning', 'info']) {
|
||||
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
||||
}
|
||||
|
||||
const contrast = Number(input.contrast ?? 0)
|
||||
|
||||
if (!(contrast >= -1 && contrast <= 1)) fail(`The contrast level must be between -1 and 1, "${input.contrast}" given.`)
|
||||
|
||||
const source = Hct.fromInt(argbFromHex(input.seed))
|
||||
const colors = new MaterialDynamicColors()
|
||||
|
||||
function roles(isDark) {
|
||||
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
||||
// variants the new spec does not define (fidelity, content, monochrome, …).
|
||||
const scheme = new Scheme(source, isDark, contrast, '2025')
|
||||
const out = {}
|
||||
|
||||
for (const color of colors.allColors) {
|
||||
if (color && !NOT_ROLES.test(color.name)) {
|
||||
out[color.name.replaceAll('_', '-')] = hexFromArgb(color.getArgb(scheme))
|
||||
}
|
||||
}
|
||||
|
||||
return { scheme, out }
|
||||
}
|
||||
|
||||
const light = roles(false)
|
||||
const dark = roles(true)
|
||||
|
||||
// success, warning and info are M3 custom colours, harmonisation off: harmonising pulls
|
||||
// each hue towards the seed, and a state has to stay recognisable whatever the brand is.
|
||||
for (const state of ['success', 'warning', 'info']) {
|
||||
const group = customColor(argbFromHex(input.seed), { name: state, value: argbFromHex(input[state]), blend: false })
|
||||
|
||||
for (const [theme, colours] of [['light', light.out], ['dark', dark.out]]) {
|
||||
colours[state] = hexFromArgb(group[theme].color)
|
||||
colours[`on-${state}`] = hexFromArgb(group[theme].onColor)
|
||||
colours[`${state}-container`] = hexFromArgb(group[theme].colorContainer)
|
||||
colours[`on-${state}-container`] = hexFromArgb(group[theme].onColorContainer)
|
||||
}
|
||||
}
|
||||
|
||||
// A state colour drawn on the inverse surface (a snackbar's icon) is the other theme's.
|
||||
for (const state of ['error', 'success', 'warning', 'info']) {
|
||||
light.out[`inverse-${state}`] = dark.out[state]
|
||||
dark.out[`inverse-${state}`] = light.out[state]
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
seed: input.seed.toLowerCase(),
|
||||
variant: input.variant,
|
||||
spec: light.scheme.specVersion,
|
||||
contrast,
|
||||
light: light.out,
|
||||
dark: dark.out,
|
||||
}))
|
||||
+881
@@ -0,0 +1,881 @@
|
||||
/**
|
||||
* Regenerates resources/svg/shapes: one SVG per Material 3 Expressive shape.
|
||||
*
|
||||
* Run from the repository root with `npm run build:shapes` (or `node bin/shapes.mjs`).
|
||||
* Maintenance only: plain Node 22+, no dependencies, and the output is deterministic,
|
||||
* so running it twice changes nothing.
|
||||
*
|
||||
* The geometry is not traced but ported from androidx, which defines every shape as a
|
||||
* RoundedPolygon — vertices plus per-vertex CornerRounding — and turns it into cubic
|
||||
* Béziers. That construction is reproduced here line for line (with doubles in place of
|
||||
* Kotlin's Floats), then each shape is normalised as MaterialShapes does, scaled so its
|
||||
* exact bounds span 96 units on the larger axis, and centred in a 100 × 100 viewBox.
|
||||
* Where a control point would still land outside the viewBox, that cubic is split into
|
||||
* pieces of the same curve (see `contained`), so every coordinate in a file lies in 0–100.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Ported from androidx (https://github.com/androidx/androidx), commit
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
||||
*
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MaterialShapes.kt
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
|
||||
* compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Matrix.kt (rotateZ, scale)
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/CornerRounding.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Cubic.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Point.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Shapes.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Utils.kt
|
||||
*
|
||||
* Copyright 2022-2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* ---------------------------------------------------------------------------------------
|
||||
*/
|
||||
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const OUTPUT = 'resources/svg/shapes'
|
||||
const VIEWBOX = 100
|
||||
const FILL = 96
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
|
||||
// Point.kt / Utils.kt ------------------------------------------------------------------
|
||||
|
||||
const point = (x, y) => ({ x, y })
|
||||
const plus = (a, b) => point(a.x + b.x, a.y + b.y)
|
||||
const minus = (a, b) => point(a.x - b.x, a.y - b.y)
|
||||
const times = (a, k) => point(a.x * k, a.y * k)
|
||||
const div = (a, k) => point(a.x / k, a.y / k)
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y
|
||||
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y)
|
||||
const rotate90 = (a) => point(-a.y, a.x)
|
||||
const clockwise = (a, b) => a.x * b.y - a.y * b.x > 0
|
||||
const lerp = (a, b, f) => (1 - f) * a + f * b
|
||||
const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f))
|
||||
|
||||
function direction(a) {
|
||||
const d = length(a)
|
||||
|
||||
if (!(d > 0)) {
|
||||
throw new Error("Can't get the direction of a 0-length vector")
|
||||
}
|
||||
|
||||
return div(a, d)
|
||||
}
|
||||
|
||||
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
const convex = (previous, current, next) => clockwise(minus(current, previous), minus(next, current))
|
||||
|
||||
// Cubic.kt ------------------------------------------------------------------------------
|
||||
|
||||
/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */
|
||||
const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y]
|
||||
|
||||
function straightLine(x0, y0, x1, y1) {
|
||||
return [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1]
|
||||
}
|
||||
|
||||
function circularArc(centerX, centerY, x0, y0, x1, y1) {
|
||||
const p0d = direction(point(x0 - centerX, y0 - centerY))
|
||||
const p1d = direction(point(x1 - centerX, y1 - centerY))
|
||||
const rotatedP0 = rotate90(p0d)
|
||||
const rotatedP1 = rotate90(p1d)
|
||||
const isClockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0
|
||||
const cosa = dot(p0d, p1d)
|
||||
|
||||
if (cosa > 0.999) {
|
||||
return straightLine(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
const k =
|
||||
(((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) *
|
||||
(Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) /
|
||||
(1 - cosa) *
|
||||
(isClockwise ? 1 : -1)
|
||||
|
||||
return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1]
|
||||
}
|
||||
|
||||
function pointOnCurve(c, t) {
|
||||
const u = 1 - t
|
||||
|
||||
return point(
|
||||
c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t),
|
||||
c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t),
|
||||
)
|
||||
}
|
||||
|
||||
function split(c, t) {
|
||||
const u = 1 - t
|
||||
const p = pointOnCurve(c, t)
|
||||
|
||||
return [
|
||||
[
|
||||
c[0],
|
||||
c[1],
|
||||
c[0] * u + c[2] * t,
|
||||
c[1] * u + c[3] * t,
|
||||
c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t),
|
||||
c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t),
|
||||
p.x,
|
||||
p.y,
|
||||
],
|
||||
[
|
||||
p.x,
|
||||
p.y,
|
||||
c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t),
|
||||
c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t),
|
||||
c[4] * u + c[6] * t,
|
||||
c[5] * u + c[7] * t,
|
||||
c[6],
|
||||
c[7],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]]
|
||||
const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON
|
||||
|
||||
/** The parameters in (0, 1) where one axis of a cubic turns: the roots of its derivative. */
|
||||
function turningPoints(c, axis) {
|
||||
const [p0, p1, p2, p3] = [c[axis], c[axis + 2], c[axis + 4], c[axis + 6]]
|
||||
const a = -p0 + 3 * p1 - 3 * p2 + p3
|
||||
const b = 2 * (p0 - 2 * p1 + p2)
|
||||
const k = p1 - p0
|
||||
const roots = []
|
||||
|
||||
if (Math.abs(a) < 1e-9) {
|
||||
if (Math.abs(b) > 1e-9) {
|
||||
roots.push(-k / b)
|
||||
}
|
||||
} else if (b * b - 4 * a * k >= 0) {
|
||||
const root = Math.sqrt(b * b - 4 * a * k)
|
||||
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a))
|
||||
}
|
||||
|
||||
return roots.filter((t) => t > 1e-6 && t < 1 - 1e-6)
|
||||
}
|
||||
|
||||
/** Axis-aligned bounds of one cubic: of all four points when approximate, else of the curve itself. */
|
||||
function cubicBounds(c, approximate) {
|
||||
const xs = [c[0], c[6]]
|
||||
const ys = [c[1], c[7]]
|
||||
|
||||
if (approximate) {
|
||||
xs.push(c[2], c[4])
|
||||
ys.push(c[3], c[5])
|
||||
} else {
|
||||
turningPoints(c, 0).forEach((t) => xs.push(pointOnCurve(c, t).x))
|
||||
turningPoints(c, 1).forEach((t) => ys.push(pointOnCurve(c, t).y))
|
||||
}
|
||||
|
||||
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
|
||||
}
|
||||
|
||||
// CornerRounding.kt ---------------------------------------------------------------------
|
||||
|
||||
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
|
||||
const UNROUNDED = rounding()
|
||||
|
||||
// RoundedPolygon.kt ---------------------------------------------------------------------
|
||||
|
||||
class RoundedCorner {
|
||||
constructor(p0, p1, p2, cornerRounding) {
|
||||
this.p0 = p0
|
||||
this.p1 = p1
|
||||
this.p2 = p2
|
||||
|
||||
const v01 = minus(p0, p1)
|
||||
const v21 = minus(p2, p1)
|
||||
const d01 = length(v01)
|
||||
const d21 = length(v21)
|
||||
|
||||
if (d01 > 0 && d21 > 0) {
|
||||
this.d1 = div(v01, d01)
|
||||
this.d2 = div(v21, d21)
|
||||
this.cornerRadius = cornerRounding?.radius ?? 0
|
||||
this.smoothing = cornerRounding?.smoothing ?? 0
|
||||
this.cosAngle = dot(this.d1, this.d2)
|
||||
this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle)
|
||||
this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0
|
||||
} else {
|
||||
this.d1 = point(0, 0)
|
||||
this.d2 = point(0, 0)
|
||||
this.cornerRadius = 0
|
||||
this.smoothing = 0
|
||||
this.cosAngle = 0
|
||||
this.sinAngle = 0
|
||||
this.expectedRoundCut = 0
|
||||
}
|
||||
}
|
||||
|
||||
get expectedCut() {
|
||||
return (1 + this.smoothing) * this.expectedRoundCut
|
||||
}
|
||||
|
||||
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
|
||||
const allowedCut = Math.min(allowedCut0, allowedCut1)
|
||||
|
||||
if (
|
||||
this.expectedRoundCut < DISTANCE_EPSILON ||
|
||||
allowedCut < DISTANCE_EPSILON ||
|
||||
this.cornerRadius < DISTANCE_EPSILON
|
||||
) {
|
||||
return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]
|
||||
}
|
||||
|
||||
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut)
|
||||
const actualSmoothing0 = this.actualSmoothing(allowedCut0)
|
||||
const actualSmoothing1 = this.actualSmoothing(allowedCut1)
|
||||
const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut
|
||||
const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut)
|
||||
const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance))
|
||||
const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut))
|
||||
const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut))
|
||||
const flanking0 = this.flankingCurve(
|
||||
actualRoundCut,
|
||||
actualSmoothing0,
|
||||
this.p1,
|
||||
this.p0,
|
||||
circleIntersection0,
|
||||
circleIntersection2,
|
||||
center,
|
||||
actualR,
|
||||
)
|
||||
const flanking2 = reverse(
|
||||
this.flankingCurve(
|
||||
actualRoundCut,
|
||||
actualSmoothing1,
|
||||
this.p1,
|
||||
this.p2,
|
||||
circleIntersection2,
|
||||
circleIntersection0,
|
||||
center,
|
||||
actualR,
|
||||
),
|
||||
)
|
||||
|
||||
return [
|
||||
flanking0,
|
||||
circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]),
|
||||
flanking2,
|
||||
]
|
||||
}
|
||||
|
||||
actualSmoothing(allowedCut) {
|
||||
if (allowedCut > this.expectedCut) {
|
||||
return this.smoothing
|
||||
}
|
||||
|
||||
if (allowedCut > this.expectedRoundCut) {
|
||||
return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) {
|
||||
const sideDirection = direction(minus(sideStart, corner))
|
||||
const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing)))
|
||||
const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing)
|
||||
const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR))
|
||||
const circleTangent = rotate90(minus(curveEnd, circleCenter))
|
||||
const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection
|
||||
const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3)
|
||||
|
||||
return cubic(curveStart, anchorStart, anchorEnd, curveEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function lineIntersection(p0, d0, p1, d1) {
|
||||
const rotatedD1 = rotate90(d1)
|
||||
const den = dot(d0, rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON) {
|
||||
return null
|
||||
}
|
||||
|
||||
const num = dot(minus(p1, p0), rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return plus(p0, times(d0, num / den))
|
||||
}
|
||||
|
||||
/**
|
||||
* A polygon as androidx keeps it: its features (corners and the edges between them, each a
|
||||
* list of cubics) and a centre. `cubics` flattens the features exactly as RoundedPolygon does.
|
||||
*/
|
||||
class RoundedPolygon {
|
||||
constructor(features, center) {
|
||||
this.features = features
|
||||
this.center = center
|
||||
this.cubics = flatten(features, center)
|
||||
}
|
||||
|
||||
transformed(f) {
|
||||
const move = (c) => {
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < 8; i += 2) {
|
||||
const p = f(c[i], c[i + 1])
|
||||
out.push(p.x, p.y)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
return new RoundedPolygon(
|
||||
this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })),
|
||||
f(this.center.x, this.center.y),
|
||||
)
|
||||
}
|
||||
|
||||
bounds(approximate) {
|
||||
const all = this.cubics.map((c) => (zeroLength(c) ? [c[0], c[1], c[0], c[1]] : cubicBounds(c, approximate)))
|
||||
|
||||
return [
|
||||
Math.min(...all.map((b) => b[0])),
|
||||
Math.min(...all.map((b) => b[1])),
|
||||
Math.max(...all.map((b) => b[2])),
|
||||
Math.max(...all.map((b) => b[3])),
|
||||
]
|
||||
}
|
||||
|
||||
normalized() {
|
||||
const [left, top, right, bottom] = this.bounds(true)
|
||||
const width = right - left
|
||||
const height = bottom - top
|
||||
const side = Math.max(width, height)
|
||||
const offsetX = (side - width) / 2 - left
|
||||
const offsetY = (side - height) / 2 - top
|
||||
|
||||
return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side))
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(features, center) {
|
||||
const out = []
|
||||
let firstCubic = null
|
||||
let lastCubic = null
|
||||
let firstFeatureSplitStart = null
|
||||
let firstFeatureSplitEnd = null
|
||||
|
||||
if (features.length > 0 && features[0].cubics.length === 3) {
|
||||
const [start, end] = split(features[0].cubics[1], 0.5)
|
||||
firstFeatureSplitStart = [features[0].cubics[0], start]
|
||||
firstFeatureSplitEnd = [end, features[0].cubics[2]]
|
||||
}
|
||||
|
||||
for (let i = 0; i <= features.length; i++) {
|
||||
let featureCubics
|
||||
|
||||
if (i === 0 && firstFeatureSplitEnd !== null) {
|
||||
featureCubics = firstFeatureSplitEnd
|
||||
} else if (i === features.length) {
|
||||
if (firstFeatureSplitStart === null) {
|
||||
break
|
||||
}
|
||||
|
||||
featureCubics = firstFeatureSplitStart
|
||||
} else {
|
||||
featureCubics = features[i].cubics
|
||||
}
|
||||
|
||||
for (const c of featureCubics) {
|
||||
if (!zeroLength(c)) {
|
||||
if (lastCubic !== null) {
|
||||
out.push(lastCubic)
|
||||
}
|
||||
|
||||
lastCubic = c
|
||||
|
||||
if (firstCubic === null) {
|
||||
firstCubic = c
|
||||
}
|
||||
} else if (lastCubic !== null) {
|
||||
lastCubic = [...lastCubic]
|
||||
lastCubic[6] = c[0]
|
||||
lastCubic[7] = c[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCubic !== null && firstCubic !== null) {
|
||||
out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]])
|
||||
} else {
|
||||
out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y])
|
||||
}
|
||||
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const previous = out[(i + out.length - 1) % out.length]
|
||||
|
||||
if (
|
||||
Math.abs(out[i][0] - previous[6]) > DISTANCE_EPSILON ||
|
||||
Math.abs(out[i][1] - previous[7]) > DISTANCE_EPSILON
|
||||
) {
|
||||
throw new Error('RoundedPolygon must be contiguous')
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY) */
|
||||
function polygonFromVertices(vertices, { cornerRounding = UNROUNDED, perVertexRounding = null, center = null } = {}) {
|
||||
const n = vertices.length
|
||||
|
||||
if (n < 3) {
|
||||
throw new Error('Polygons must have at least 3 vertices')
|
||||
}
|
||||
|
||||
if (perVertexRounding !== null && perVertexRounding.length !== n) {
|
||||
throw new Error('perVertexRounding list should be either null or the same size as the number of vertices')
|
||||
}
|
||||
|
||||
const roundedCorners = vertices.map(
|
||||
(vertex, i) =>
|
||||
new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding?.[i] ?? cornerRounding),
|
||||
)
|
||||
|
||||
const cutAdjusts = vertices.map((vertex, i) => {
|
||||
const next = (i + 1) % n
|
||||
const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut
|
||||
const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut
|
||||
const sideSize = length(minus(vertex, vertices[next]))
|
||||
|
||||
if (expectedRoundCut > sideSize) {
|
||||
return [sideSize / expectedRoundCut, 0]
|
||||
}
|
||||
|
||||
if (expectedCut > sideSize) {
|
||||
return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)]
|
||||
}
|
||||
|
||||
return [1, 1]
|
||||
})
|
||||
|
||||
const corners = roundedCorners.map((corner, i) => {
|
||||
const allowedCuts = [0, 1].map((delta) => {
|
||||
const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n]
|
||||
|
||||
return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio
|
||||
})
|
||||
|
||||
return corner.getCubics(allowedCuts[0], allowedCuts[1])
|
||||
})
|
||||
|
||||
const features = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const previous = vertices[(i + n - 1) % n]
|
||||
const next = vertices[(i + 1) % n]
|
||||
const end = corners[i].at(-1)
|
||||
const start = corners[(i + 1) % n][0]
|
||||
|
||||
features.push({ type: 'corner', convex: convex(previous, vertices[i], next), cubics: corners[i] })
|
||||
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
|
||||
}
|
||||
|
||||
if (center === null) {
|
||||
center = point(
|
||||
vertices.reduce((sum, v) => sum + v.x, 0) / n,
|
||||
vertices.reduce((sum, v) => sum + v.y, 0) / n,
|
||||
)
|
||||
}
|
||||
|
||||
return new RoundedPolygon(features, center)
|
||||
}
|
||||
|
||||
// Shapes.kt -----------------------------------------------------------------------------
|
||||
|
||||
/** RoundedPolygon(numVertices, radius, centerX, centerY, rounding, perVertexRounding) */
|
||||
function regularPolygon(numVertices, { radius = 1, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
|
||||
const vertices = Array.from({ length: numVertices }, (_, i) =>
|
||||
radialToCartesian(radius, (Math.PI / numVertices) * 2 * i),
|
||||
)
|
||||
|
||||
return polygonFromVertices(vertices, { cornerRounding, perVertexRounding, center: point(0, 0) })
|
||||
}
|
||||
|
||||
function circlePolygon(numVertices = 8, radius = 1) {
|
||||
const polygonRadius = radius / Math.cos(Math.PI / numVertices)
|
||||
|
||||
return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) })
|
||||
}
|
||||
|
||||
function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
|
||||
const [left, top, right, bottom] = [-width / 2, -height / 2, width / 2, height / 2]
|
||||
|
||||
return polygonFromVertices([point(right, bottom), point(left, bottom), point(left, top), point(right, top)], {
|
||||
cornerRounding,
|
||||
perVertexRounding,
|
||||
center: point(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED } = {}) {
|
||||
const vertices = []
|
||||
|
||||
for (let i = 0; i < numVerticesPerRadius; i++) {
|
||||
vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i))
|
||||
vertices.push(radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1)))
|
||||
}
|
||||
|
||||
return polygonFromVertices(vertices, { cornerRounding, center: point(0, 0) })
|
||||
}
|
||||
|
||||
// Matrix.kt (rotateZ, scale) and ShapeUtil.kt (RoundedPolygon.transformed(Matrix)) --------
|
||||
|
||||
function rotateZ(degrees) {
|
||||
const r = degrees * (Math.PI / 180)
|
||||
const s = Math.sin(r)
|
||||
const c = Math.cos(r)
|
||||
|
||||
return (x, y) => point(c * x - s * y, s * x + c * y)
|
||||
}
|
||||
|
||||
const scale = (sx, sy) => (x, y) => point(x * sx, y * sy)
|
||||
|
||||
// MaterialShapes.kt ---------------------------------------------------------------------
|
||||
|
||||
const cornerRound15 = rounding(0.15)
|
||||
const cornerRound20 = rounding(0.2)
|
||||
const cornerRound30 = rounding(0.3)
|
||||
const cornerRound50 = rounding(0.5)
|
||||
const cornerRound100 = rounding(1)
|
||||
|
||||
const rotateNeg45 = rotateZ(-45)
|
||||
const rotateNeg90 = rotateZ(-90)
|
||||
const rotateNeg135 = rotateZ(-135)
|
||||
|
||||
/** PointNRound: a vertex of a custom polygon and its rounding. */
|
||||
const pnr = (x, y, cornerRounding = UNROUNDED) => ({ o: point(x, y), r: cornerRounding })
|
||||
|
||||
const toRadians = (degrees) => (degrees / 360) * 2 * Math.PI
|
||||
const angleDegrees = (p) => (Math.atan2(p.y, p.x) * 180) / Math.PI
|
||||
|
||||
function rotateDegrees(p, angle, center) {
|
||||
const a = toRadians(angle)
|
||||
const off = minus(p, center)
|
||||
|
||||
return plus(point(off.x * Math.cos(a) - off.y * Math.sin(a), off.x * Math.sin(a) + off.y * Math.cos(a)), center)
|
||||
}
|
||||
|
||||
function doRepeat(points, reps, center, mirroring) {
|
||||
if (!mirroring) {
|
||||
const np = points.length
|
||||
|
||||
return Array.from({ length: np * reps }, (_, it) => ({
|
||||
o: rotateDegrees(points[it % np].o, (Math.floor(it / np) * 360) / reps, center),
|
||||
r: points[it % np].r,
|
||||
}))
|
||||
}
|
||||
|
||||
const angles = points.map((p) => angleDegrees(minus(p.o, center)))
|
||||
const distances = points.map((p) => length(minus(p.o, center)))
|
||||
const actualReps = reps * 2
|
||||
const sectionAngle = 360 / actualReps
|
||||
const out = []
|
||||
|
||||
for (let it = 0; it < actualReps; it++) {
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const i = it % 2 === 0 ? index : points.length - 1 - index
|
||||
|
||||
if (i > 0 || it % 2 === 0) {
|
||||
const a = toRadians(
|
||||
sectionAngle * it + (it % 2 === 0 ? angles[i] : sectionAngle - angles[i] + 2 * angles[0]),
|
||||
)
|
||||
|
||||
out.push({ o: plus(times(point(Math.cos(a), Math.sin(a)), distances[i]), center), r: points[i].r })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function customPolygon(points, reps, { center = point(0.5, 0.5), mirroring = false } = {}) {
|
||||
const actualPoints = doRepeat(points, reps, center, mirroring)
|
||||
|
||||
return polygonFromVertices(
|
||||
actualPoints.map((p) => p.o),
|
||||
{ perVertexRounding: actualPoints.map((p) => p.r), center },
|
||||
)
|
||||
}
|
||||
|
||||
/** File name → the unnormalised shape, in MaterialShapes' declaration order. */
|
||||
const SHAPES = {
|
||||
circle: () => circlePolygon(10),
|
||||
square: () => rectangle({ width: 1, height: 1, cornerRounding: cornerRound30 }),
|
||||
slanted: () => customPolygon([pnr(0.926, 0.97, rounding(0.189, 0.811)), pnr(-0.021, 0.967, rounding(0.187, 0.057))], 2),
|
||||
arch: () =>
|
||||
regularPolygon(4, {
|
||||
perVertexRounding: [cornerRound100, cornerRound100, cornerRound20, cornerRound20],
|
||||
}).transformed(rotateNeg135),
|
||||
fan: () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(1.004, 1.0, rounding(0.148, 0.417)),
|
||||
pnr(0.0, 1.0, rounding(0.151)),
|
||||
pnr(0.0, -0.003, rounding(0.148)),
|
||||
pnr(0.978, 0.02, rounding(0.803)),
|
||||
],
|
||||
1,
|
||||
),
|
||||
arrow: () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.5, 0.892, rounding(0.313)),
|
||||
pnr(-0.216, 1.05, rounding(0.207)),
|
||||
pnr(0.499, -0.16, rounding(0.215, 1.0)),
|
||||
pnr(1.225, 1.06, rounding(0.211)),
|
||||
],
|
||||
1,
|
||||
),
|
||||
'semi-circle': () =>
|
||||
rectangle({
|
||||
width: 1.6,
|
||||
height: 1,
|
||||
perVertexRounding: [cornerRound20, cornerRound20, cornerRound100, cornerRound100],
|
||||
}),
|
||||
oval: () => circlePolygon().transformed(scale(1, 0.64)).transformed(rotateNeg45),
|
||||
pill: () =>
|
||||
customPolygon([pnr(0.961, 0.039, rounding(0.426)), pnr(1.001, 0.428), pnr(1.0, 0.609, rounding(1.0))], 2, {
|
||||
mirroring: true,
|
||||
}),
|
||||
triangle: () => regularPolygon(3, { cornerRounding: cornerRound20 }).transformed(rotateNeg90),
|
||||
diamond: () => customPolygon([pnr(0.5, 1.096, rounding(0.151, 0.524)), pnr(0.04, 0.5, rounding(0.159))], 2),
|
||||
'clam-shell': () =>
|
||||
customPolygon(
|
||||
[pnr(0.171, 0.841, rounding(0.159)), pnr(-0.02, 0.5, rounding(0.14)), pnr(0.17, 0.159, rounding(0.159))],
|
||||
2,
|
||||
),
|
||||
pentagon: () =>
|
||||
customPolygon(
|
||||
[pnr(0.5, -0.009, rounding(0.172)), pnr(1.03, 0.365, rounding(0.164)), pnr(0.828, 0.97, rounding(0.169))],
|
||||
1,
|
||||
{ mirroring: true },
|
||||
),
|
||||
gem: () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.499, 1.023, rounding(0.241, 0.778)),
|
||||
pnr(-0.005, 0.792, rounding(0.208)),
|
||||
pnr(0.073, 0.258, rounding(0.228)),
|
||||
pnr(0.433, -0.0, rounding(0.491)),
|
||||
],
|
||||
1,
|
||||
{ mirroring: true },
|
||||
),
|
||||
'very-sunny': () => customPolygon([pnr(0.5, 1.08, rounding(0.085)), pnr(0.358, 0.843, rounding(0.085))], 8),
|
||||
sunny: () => star(8, { innerRadius: 0.8, cornerRounding: cornerRound15 }),
|
||||
'cookie-4': () => customPolygon([pnr(1.237, 1.236, rounding(0.258)), pnr(0.5, 0.918, rounding(0.233))], 4),
|
||||
'cookie-6': () => customPolygon([pnr(0.723, 0.884, rounding(0.394)), pnr(0.5, 1.099, rounding(0.398))], 6),
|
||||
'cookie-7': () => star(7, { innerRadius: 0.75, cornerRounding: cornerRound50 }).transformed(rotateNeg90),
|
||||
'cookie-9': () => star(9, { innerRadius: 0.8, cornerRounding: cornerRound50 }).transformed(rotateNeg90),
|
||||
'cookie-12': () => star(12, { innerRadius: 0.8, cornerRounding: cornerRound50 }).transformed(rotateNeg90),
|
||||
ghostish: () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.5, 0, rounding(1.0)),
|
||||
pnr(1, 0, rounding(1.0)),
|
||||
pnr(1, 1.14, rounding(0.254, 0.106)),
|
||||
pnr(0.575, 0.906, rounding(0.253)),
|
||||
],
|
||||
1,
|
||||
{ mirroring: true },
|
||||
),
|
||||
'clover-4': () => customPolygon([pnr(0.5, 0.074), pnr(0.725, -0.099, rounding(0.476))], 4, { mirroring: true }),
|
||||
'clover-8': () => customPolygon([pnr(0.5, 0.036), pnr(0.758, -0.101, rounding(0.209))], 8),
|
||||
burst: () => customPolygon([pnr(0.5, -0.006, rounding(0.006)), pnr(0.592, 0.158, rounding(0.006))], 12),
|
||||
'soft-burst': () => customPolygon([pnr(0.193, 0.277, rounding(0.053)), pnr(0.176, 0.055, rounding(0.053))], 10),
|
||||
boom: () => customPolygon([pnr(0.457, 0.296, rounding(0.007)), pnr(0.5, -0.051, rounding(0.007))], 15),
|
||||
'soft-boom': () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.733, 0.454),
|
||||
pnr(0.839, 0.437, rounding(0.532)),
|
||||
pnr(0.949, 0.449, rounding(0.439, 1.0)),
|
||||
pnr(0.998, 0.478, rounding(0.174)),
|
||||
],
|
||||
16,
|
||||
{ mirroring: true },
|
||||
),
|
||||
flower: () =>
|
||||
customPolygon([pnr(0.37, 0.187), pnr(0.416, 0.049, rounding(0.381)), pnr(0.479, 0.001, rounding(0.095))], 8, {
|
||||
mirroring: true,
|
||||
}),
|
||||
puffy: () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.5, 0.053),
|
||||
pnr(0.545, -0.04, rounding(0.405)),
|
||||
pnr(0.67, -0.035, rounding(0.426)),
|
||||
pnr(0.717, 0.066, rounding(0.574)),
|
||||
pnr(0.722, 0.128),
|
||||
pnr(0.777, 0.002, rounding(0.36)),
|
||||
pnr(0.914, 0.149, rounding(0.66)),
|
||||
pnr(0.926, 0.289, rounding(0.66)),
|
||||
pnr(0.881, 0.346),
|
||||
pnr(0.94, 0.344, rounding(0.126)),
|
||||
pnr(1.003, 0.437, rounding(0.255)),
|
||||
],
|
||||
2,
|
||||
{ mirroring: true },
|
||||
).transformed(scale(1, 0.742)),
|
||||
'puffy-diamond': () =>
|
||||
customPolygon([pnr(0.87, 0.13, rounding(0.146)), pnr(0.818, 0.357), pnr(1.0, 0.332, rounding(0.853))], 4, {
|
||||
mirroring: true,
|
||||
}),
|
||||
'pixel-circle': () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.5, 0.0),
|
||||
pnr(0.704, 0.0),
|
||||
pnr(0.704, 0.065),
|
||||
pnr(0.843, 0.065),
|
||||
pnr(0.843, 0.148),
|
||||
pnr(0.926, 0.148),
|
||||
pnr(0.926, 0.296),
|
||||
pnr(1.0, 0.296),
|
||||
],
|
||||
2,
|
||||
{ mirroring: true },
|
||||
),
|
||||
'pixel-triangle': () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.11, 0.5),
|
||||
pnr(0.113, 0.0),
|
||||
pnr(0.287, 0.0),
|
||||
pnr(0.287, 0.087),
|
||||
pnr(0.421, 0.087),
|
||||
pnr(0.421, 0.17),
|
||||
pnr(0.56, 0.17),
|
||||
pnr(0.56, 0.265),
|
||||
pnr(0.674, 0.265),
|
||||
pnr(0.675, 0.344),
|
||||
pnr(0.789, 0.344),
|
||||
pnr(0.789, 0.439),
|
||||
pnr(0.888, 0.439),
|
||||
],
|
||||
1,
|
||||
{ mirroring: true },
|
||||
),
|
||||
bun: () =>
|
||||
customPolygon(
|
||||
[pnr(0.796, 0.5), pnr(0.853, 0.518, rounding(1)), pnr(0.992, 0.631, rounding(1)), pnr(0.968, 1.0, rounding(1))],
|
||||
2,
|
||||
{ mirroring: true },
|
||||
),
|
||||
heart: () =>
|
||||
customPolygon(
|
||||
[
|
||||
pnr(0.5, 0.268, rounding(0.016)),
|
||||
pnr(0.792, -0.066, rounding(0.958)),
|
||||
pnr(1.064, 0.276, rounding(1.0)),
|
||||
pnr(0.501, 0.946, rounding(0.129)),
|
||||
],
|
||||
1,
|
||||
{ mirroring: true },
|
||||
),
|
||||
}
|
||||
|
||||
// SVG -----------------------------------------------------------------------------------
|
||||
|
||||
/** Scales the normalised shape so its exact bounds span FILL on the larger axis, centred in the viewBox. */
|
||||
function fitted(polygon) {
|
||||
const [left, top, right, bottom] = polygon.bounds(false)
|
||||
const k = FILL / Math.max(right - left, bottom - top)
|
||||
const dx = VIEWBOX / 2 - ((left + right) / 2) * k
|
||||
const dy = VIEWBOX / 2 - ((top + bottom) / 2) * k
|
||||
|
||||
return polygon.transformed((x, y) => point(x * k + dx, y * k + dy))
|
||||
}
|
||||
|
||||
const round = (value) => Math.round(value * 10) / 10
|
||||
|
||||
function number(value) {
|
||||
const rounded = round(value)
|
||||
|
||||
return String(Object.is(rounded, -0) ? 0 : rounded)
|
||||
}
|
||||
|
||||
/**
|
||||
* A cubic's curve stays inside the viewBox, but its control points can reach past it where a
|
||||
* tight corner bulges towards the edge. Split such a cubic at its turning points (halving when it
|
||||
* has none) until every control point is inside too: the same curve, drawn in more pieces.
|
||||
*/
|
||||
function contained(c, depth = 0) {
|
||||
const outside = c.some((value) => round(value) < 0 || round(value) > VIEWBOX)
|
||||
|
||||
if (!outside || depth > 8) {
|
||||
return [c]
|
||||
}
|
||||
|
||||
const ts = [...new Set([...turningPoints(c, 0), ...turningPoints(c, 1)])]
|
||||
.filter((t) => t > 1e-3 && t < 1 - 1e-3)
|
||||
.sort((a, b) => a - b)
|
||||
const cuts = ts.length > 0 ? ts : [0.5]
|
||||
const pieces = []
|
||||
let rest = c
|
||||
let consumed = 0
|
||||
|
||||
for (const t of cuts) {
|
||||
const [head, tail] = split(rest, (t - consumed) / (1 - consumed))
|
||||
pieces.push(head)
|
||||
rest = tail
|
||||
consumed = t
|
||||
}
|
||||
|
||||
pieces.push(rest)
|
||||
|
||||
return pieces.flatMap((piece) => contained(piece, depth + 1))
|
||||
}
|
||||
|
||||
function pathData(polygon) {
|
||||
const cubics = polygon.cubics.flatMap((c) => contained(c)).map((c) => c.map(number))
|
||||
let d = `M${cubics[0][0]} ${cubics[0][1]}`
|
||||
|
||||
for (const c of cubics) {
|
||||
// A corner rounded by a few thousandths collapses to a point at one decimal: skip it.
|
||||
if (c.every((value, i) => value === c[i % 2])) {
|
||||
continue
|
||||
}
|
||||
|
||||
d += `C${c.slice(2).join(' ')}`
|
||||
}
|
||||
|
||||
return `${d}Z`
|
||||
}
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true })
|
||||
|
||||
for (const file of readdirSync(OUTPUT)) {
|
||||
if (file.endsWith('.svg')) {
|
||||
rmSync(join(OUTPUT, file))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, build] of Object.entries(SHAPES)) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" fill="currentColor"><path d="${pathData(fitted(build().normalized()))}"/></svg>\n`
|
||||
|
||||
writeFileSync(join(OUTPUT, `${name}.svg`), svg)
|
||||
}
|
||||
|
||||
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)
|
||||
Reference in New Issue
Block a user