Files
dotfiles/.config/quickshell/nucleus-shell/modules/components/morphedPolygons/shapes/rounded-polygon.js

343 lines
15 KiB
JavaScript

.pragma library
.import "feature.js" as Feature
.import "point.js" as Point
.import "cubic.js" as Cubic
.import "utils.js" as Utils
.import "corner-rounding.js" as CornerRounding
.import "rounded-corner.js" as RoundedCorner
class RoundedPolygon {
constructor(features, center) {
this.features = features
this.center = center
this.cubics = this.buildCubicList()
}
get centerX() {
return this.center.x
}
get centerY() {
return this.center.y
}
transformed(f) {
const center = this.center.transformed(f)
return new RoundedPolygon(this.features.map(x => x.transformed(f)), center)
}
normalized() {
const bounds = this.calculateBounds()
const width = bounds[2] - bounds[0]
const height = bounds[3] - bounds[1]
const side = Math.max(width, height)
// Center the shape if bounds are not a square
const offsetX = (side - width) / 2 - bounds[0] /* left */
const offsetY = (side - height) / 2 - bounds[1] /* top */
return this.transformed((x, y) => {
return new Point.Point((x + offsetX) / side, (y + offsetY) / side)
})
}
calculateMaxBounds(bounds = []) {
let maxDistSquared = 0
for (let i = 0; i < this.cubics.length; i++) {
const cubic = this.cubics[i]
const anchorDistance = Utils.distanceSquared(cubic.anchor0X - this.centerX, cubic.anchor0Y - this.centerY)
const middlePoint = cubic.pointOnCurve(.5)
const middleDistance = Utils.distanceSquared(middlePoint.x - this.centerX, middlePoint.y - this.centerY)
maxDistSquared = Math.max(maxDistSquared, Math.max(anchorDistance, middleDistance))
}
const distance = Math.sqrt(maxDistSquared)
bounds[0] = this.centerX - distance
bounds[1] = this.centerY - distance
bounds[2] = this.centerX + distance
bounds[3] = this.centerY + distance
return bounds
}
calculateBounds(bounds = [], approximate = true) {
let minX = Number.MAX_SAFE_INTEGER
let minY = Number.MAX_SAFE_INTEGER
let maxX = Number.MIN_SAFE_INTEGER
let maxY = Number.MIN_SAFE_INTEGER
for (let i = 0; i < this.cubics.length; i++) {
const cubic = this.cubics[i]
cubic.calculateBounds(bounds, approximate)
minX = Math.min(minX, bounds[0])
minY = Math.min(minY, bounds[1])
maxX = Math.max(maxX, bounds[2])
maxY = Math.max(maxY, bounds[3])
}
bounds[0] = minX
bounds[1] = minY
bounds[2] = maxX
bounds[3] = maxY
return bounds
}
buildCubicList() {
const result = []
// The first/last mechanism here ensures that the final anchor point in the shape
// exactly matches the first anchor point. There can be rendering artifacts introduced
// by those points being slightly off, even by much less than a pixel
let firstCubic = null
let lastCubic = null
let firstFeatureSplitStart = null
let firstFeatureSplitEnd = null
if (this.features.length > 0 && this.features[0].cubics.length == 3) {
const centerCubic = this.features[0].cubics[1]
const { a: start, b: end } = centerCubic.split(.5)
firstFeatureSplitStart = [this.features[0].cubics[0], start]
firstFeatureSplitEnd = [end, this.features[0].cubics[2]]
}
// iterating one past the features list size allows us to insert the initial split
// cubic if it exists
for (let i = 0; i <= this.features.length; i++) {
let featureCubics
if (i == 0 && firstFeatureSplitEnd != null) {
featureCubics = firstFeatureSplitEnd
} else if (i == this.features.length) {
if (firstFeatureSplitStart != null) {
featureCubics = firstFeatureSplitStart
} else {
break
}
} else {
featureCubics = this.features[i].cubics
}
for (let j = 0; j < featureCubics.length; j++) {
// Skip zero-length curves; they add nothing and can trigger rendering artifacts
const cubic = featureCubics[j]
if (!cubic.zeroLength()) {
if (lastCubic != null)
result.push(lastCubic)
lastCubic = cubic
if (firstCubic == null)
firstCubic = cubic
} else {
if (lastCubic != null) {
// Dropping several zero-ish length curves in a row can lead to
// enough discontinuity to throw an exception later, even though the
// distances are quite small. Account for that by making the last
// cubic use the latest anchor point, always.
lastCubic = new Cubic.Cubic([...lastCubic.points]) // Make a copy before mutating
lastCubic.points[6] = cubic.anchor1X
lastCubic.points[7] = cubic.anchor1Y
}
}
}
}
if (lastCubic != null && firstCubic != null) {
result.push(
new Cubic.Cubic([
lastCubic.anchor0X,
lastCubic.anchor0Y,
lastCubic.control0X,
lastCubic.control0Y,
lastCubic.control1X,
lastCubic.control1Y,
firstCubic.anchor0X,
firstCubic.anchor0Y,
])
)
} else {
// Empty / 0-sized polygon.
result.push(new Cubic.Cubic([this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY, this.centerX, this.centerY]))
}
return result
}
static calculateCenter(vertices) {
let cumulativeX = 0
let cumulativeY = 0
let index = 0
while (index < vertices.length) {
cumulativeX += vertices[index++]
cumulativeY += vertices[index++]
}
return new Point.Point(cumulativeX / (vertices.length / 2), cumulativeY / (vertices.length / 2))
}
static verticesFromNumVerts(numVertices, radius, centerX, centerY) {
const result = []
let arrayIndex = 0
for (let i = 0; i < numVertices; i++) {
const vertex = Utils.radialToCartesian(radius, (Math.PI / numVertices * 2 * i)).plus(new Point.Point(centerX, centerY))
result[arrayIndex++] = vertex.x
result[arrayIndex++] = vertex.y
}
return result
}
static fromNumVertices(numVertices, radius = 1, centerX = 0, centerY = 0, rounding = CornerRounding.Unrounded, perVertexRounding = null) {
return RoundedPolygon.fromVertices(this.verticesFromNumVerts(numVertices, radius, centerX, centerY), rounding, perVertexRounding, centerX, centerY)
}
static fromVertices(vertices, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = Number.MIN_SAFE_INTEGER, centerY = Number.MAX_SAFE_INTEGER) {
const corners = []
const n = vertices.length / 2
const roundedCorners = []
for (let i = 0; i < n; i++) {
const vtxRounding = perVertexRounding?.[i] ?? rounding
const prevIndex = ((i + n - 1) % n) * 2
const nextIndex = ((i + 1) % n) * 2
roundedCorners.push(
new RoundedCorner.RoundedCorner(
new Point.Point(vertices[prevIndex], vertices[prevIndex + 1]),
new Point.Point(vertices[i * 2], vertices[i * 2 + 1]),
new Point.Point(vertices[nextIndex], vertices[nextIndex + 1]),
vtxRounding
)
)
}
// For each side, check if we have enough space to do the cuts needed, and if not split
// the available space, first for round cuts, then for smoothing if there is space left.
// Each element in this list is a pair, that represent how much we can do of the cut for
// the given side (side i goes from corner i to corner i+1), the elements of the pair are:
// first is how much we can use of expectedRoundCut, second how much of expectedCut
const cutAdjusts = Array.from({ length: n }).map((_, ix) => {
const expectedRoundCut = roundedCorners[ix].expectedRoundCut + roundedCorners[(ix + 1) % n].expectedRoundCut
const expectedCut = roundedCorners[ix].expectedCut + roundedCorners[(ix + 1) % n].expectedCut
const vtxX = vertices[ix * 2]
const vtxY = vertices[ix * 2 + 1]
const nextVtxX = vertices[((ix + 1) % n) * 2]
const nextVtxY = vertices[((ix + 1) % n) * 2 + 1]
const sideSize = Utils.distance(vtxX - nextVtxX, vtxY - nextVtxY)
// Check expectedRoundCut first, and ensure we fulfill rounding needs first for
// both corners before using space for smoothing
if (expectedRoundCut > sideSize) {
// Not enough room for fully rounding, see how much we can actually do.
return { a: sideSize / expectedRoundCut, b: 0 }
} else if (expectedCut > sideSize) {
// We can do full rounding, but not full smoothing.
return { a: 1, b: (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut) }
} else {
// There is enough room for rounding & smoothing.
return { a: 1, b: 1 }
}
})
// Create and store list of beziers for each [potentially] rounded corner
for (let i = 0; i < n; i++) {
// allowedCuts[0] is for the side from the previous corner to this one,
// allowedCuts[1] is for the side from this corner to the next one.
const allowedCuts = []
for(const delta of [0, 1]) {
const { a: roundCutRatio, b: cutRatio } = cutAdjusts[(i + n - 1 + delta) % n]
allowedCuts.push(
roundedCorners[i].expectedRoundCut * roundCutRatio +
(roundedCorners[i].expectedCut - roundedCorners[i].expectedRoundCut) * cutRatio
)
}
corners.push(
roundedCorners[i].getCubics(allowedCuts[0], allowedCuts[1])
)
}
const tempFeatures = []
for (let i = 0; i < n; i++) {
// Note that these indices are for pairs of values (points), they need to be
// doubled to access the xy values in the vertices float array
const prevVtxIndex = (i + n - 1) % n
const nextVtxIndex = (i + 1) % n
const currVertex = new Point.Point(vertices[i * 2], vertices[i * 2 + 1])
const prevVertex = new Point.Point(vertices[prevVtxIndex * 2], vertices[prevVtxIndex * 2 + 1])
const nextVertex = new Point.Point(vertices[nextVtxIndex * 2], vertices[nextVtxIndex * 2 + 1])
const cnvx = Utils.convex(prevVertex, currVertex, nextVertex)
tempFeatures.push(new Feature.Corner(corners[i], cnvx))
tempFeatures.push(
new Feature.Edge([Cubic.Cubic.straightLine(
corners[i][corners[i].length - 1].anchor1X,
corners[i][corners[i].length - 1].anchor1Y,
corners[(i + 1) % n][0].anchor0X,
corners[(i + 1) % n][0].anchor0Y,
)])
)
}
let center
if (centerX == Number.MIN_SAFE_INTEGER || centerY == Number.MIN_SAFE_INTEGER) {
center = RoundedPolygon.calculateCenter(vertices)
} else {
center = new Point.Point(centerX, centerY)
}
return RoundedPolygon.fromFeatures(tempFeatures, center.x, center.y)
}
static fromFeatures(features, centerX, centerY) {
const vertices = []
for (const feature of features) {
for (const cubic of feature.cubics) {
vertices.push(cubic.anchor0X)
vertices.push(cubic.anchor0Y)
}
}
if (Number.isNaN(centerX)) {
centerX = this.calculateCenter(vertices).x
}
if (Number.isNaN(centerY)) {
centerY = this.calculateCenter(vertices).y
}
return new RoundedPolygon(features, new Point.Point(centerX, centerY))
}
static circle(numVertices = 8, radius = 1, centerX = 0, centerY = 0) {
// Half of the angle between two adjacent vertices on the polygon
const theta = Math.PI / numVertices
// Radius of the underlying RoundedPolygon object given the desired radius of the circle
const polygonRadius = radius / Math.cos(theta)
return RoundedPolygon.fromNumVertices(
numVertices,
polygonRadius,
centerX,
centerY,
new CornerRounding.CornerRounding(radius)
)
}
static rectangle(width, height, rounding = CornerRounding.Unrounded, perVertexRounding = null, centerX = 0, centerY = 0) {
const left = centerX - width / 2
const top = centerY - height / 2
const right = centerX + width / 2
const bottom = centerY + height / 2
return RoundedPolygon.fromVertices([right, bottom, left, bottom, left, top, right, top], rounding, perVertexRounding, centerX, centerY)
}
static star(numVerticesPerRadius, radius = 1, innerRadius = .5, rounding = CornerRounding.Unrounded, innerRounding = null, perVertexRounding = null, centerX = 0, centerY = 0) {
let pvRounding = perVertexRounding
// If no per-vertex rounding supplied and caller asked for inner rounding,
// create per-vertex rounding list based on supplied outer/inner rounding parameters
if (pvRounding == null && innerRounding != null) {
pvRounding = Array.from({ length: numVerticesPerRadius * 2 }).flatMap(() => [rounding, innerRounding])
}
return RoundedPolygon.fromVertices(RoundedPolygon.starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY), rounding, perVertexRounding, centerX, centerY)
}
static starVerticesFromNumVerts(numVerticesPerRadius, radius, innerRadius, centerX, centerY) {
const result = []
let arrayIndex = 0
for (let i = 0; i < numVerticesPerRadius; i++) {
let vertex = Utils.radialToCartesian(radius, (Math.PI / numVerticesPerRadius * 2 * i))
result[arrayIndex++] = vertex.x + centerX
result[arrayIndex++] = vertex.y + centerY
vertex = Utils.radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius * (2 * i + 1)))
result[arrayIndex++] = vertex.x + centerX
result[arrayIndex++] = vertex.y + centerY
}
return result
}
}