/** * 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) * * The RoundedPolygon construction itself (CornerRounding, Cubic, Point, RoundedPolygon, Shapes, * Utils) is shared with bin/loading-indicator.mjs and progress.js: see resources/js/shapes.js. * * 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' import { circlePolygon, length, minus, plus, point, polygonFromVertices, regularPolygon, rounding, split, star, times, turningPoints, UNROUNDED } from '../resources/js/shapes.js' const OUTPUT = 'resources/svg/shapes' const VIEWBOX = 100 const FILL = 96 // Shapes.kt ----------------------------------------------------------------------------- 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), }) } // 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 shape catalog, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */ export { 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}`) }