Files
livewire-material/bin/shapes.mjs
T
Andreas Reinhold / reiniandClaude Opus 5 b48e879254
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
Add the Material 3 Expressive foundation
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
2026-09-13 05:24:11 +02:00

882 lines
31 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, 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}`)