/** * 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 = `\n` writeFileSync(join(OUTPUT, `${name}.svg`), svg) } console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`) }