Files
livewire-material/bin/shapes.mjs
T
Andreas Reinhold / reiniandClaude Opus 5 cd64f4f371
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s
Add buttons, menus and the rest of M3 Expressive's actions
<x-button> (label buttons, icon buttons and toggles in five sizes, with
filled, tonal, outlined, elevated and text variants in any colour role),
<x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>,
<x-group> as a connected button group, <x-split-button>, <x-fab>,
<x-fab-menu> and <x-loading>. Sizes, colours and shapes come from
androidx Compose Material 3's tokens; the loading indicator ports its
Morph into SVG + SMIL.

Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS
anchor positioning. Browser tests run in Chromium, Firefox and WebKit.
The showcase fetches the icon names on demand: inlined, they tripped
Pest's test server into HTTP 431s under Firefox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 06:09:28 +02:00

888 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 0100.
*
* ---------------------------------------------------------------------------------------
* 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, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
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`
}
/** The geometry, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
export { point, pointOnCurve, split, cubicBounds, RoundedPolygon, SHAPES }
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
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}`)
}