mirror of
https://github.com/belsabbagh/dotfiles.git
synced 2026-04-11 09:36:46 +00:00
343 lines
15 KiB
JavaScript
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
|
|
}
|
|
} |