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
<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
888 lines
32 KiB
JavaScript
888 lines
32 KiB
JavaScript
/**
|
||
* 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, 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}`)
|
||
}
|