/** * Shared, DOM-free geometry: androidx's graphics-shapes (RoundedPolygon — a shape built from * vertices with per-corner rounding, turned into cubic Béziers) and its Morph (matching two * shapes' cubics so they can be interpolated). Used by bin/shapes.mjs and bin/loading-indicator.mjs * to build the shape and loading-indicator SVGs at commit time, and by progress.js to morph the * circular wavy progress indicator's star and circle at runtime. * * --------------------------------------------------------------------------------------- * Ported from androidx (https://github.com/androidx/androidx), commit * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: * * 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/FeatureMapping.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FloatMapping.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Morph.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Point.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.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-2025 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. * --------------------------------------------------------------------------------------- */ const DISTANCE_EPSILON = 1e-4 const ANGLE_EPSILON = 1e-6 // 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 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)) const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius) const convex = (previous, current, next) => { const [a, b] = [minus(current, previous), minus(next, current)] return a.x * b.y - a.y * b.x > 0 } 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) } // 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] const straightLine = (x0, y0, x1, y1) => [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 clockwise = 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) * (clockwise ? 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 const clamp = (value, min, max) => Math.min(Math.max(value, min), max) /** 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]))] } /** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */ 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 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) }) } /** RoundedPolygon.circle(numVertices, radius): a regular polygon rounded all the way round. */ function circlePolygon(numVertices = 8, radius = 1) { const polygonRadius = radius / Math.cos(Math.PI / numVertices) return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) }) } /** RoundedPolygon.star(numVerticesPerRadius, radius, innerRadius, rounding, innerRounding). */ function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED, innerCornerRounding = cornerRounding } = {}) { const vertices = [] const roundings = [] for (let i = 0; i < numVerticesPerRadius; i++) { vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i), radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1))) roundings.push(cornerRounding, innerCornerRounding) } return polygonFromVertices(vertices, { perVertexRounding: roundings, center: point(0, 0) }) } // Morph: FloatMapping.kt, PolygonMeasure.kt, FeatureMapping.kt, Morph.kt ---------------------- const positiveModulo = (num, mod) => ((num % mod) + mod) % mod const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to) const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b)) function linearMap(xValues, yValues, x) { const n = xValues.length const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n])) const end = (start + 1) % n const sizeX = positiveModulo(xValues[end] - xValues[start], 1) const sizeY = positiveModulo(yValues[end] - yValues[start], 1) const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX return positiveModulo(yValues[start] + sizeY * position, 1) } const MEASURE_SEGMENTS = 3 /** LengthMeasurer.closestProgressTo: [the parameter at which `threshold` length is reached, the length]. */ function closestProgressTo(c, threshold) { let total = 0 let remainder = threshold let previous = point(c[0], c[1]) for (let i = 1; i <= MEASURE_SEGMENTS; i++) { const progress = i / MEASURE_SEGMENTS const p = pointOnCurve(c, progress) const segment = Math.hypot(p.x - previous.x, p.y - previous.y) if (segment >= remainder) { return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold] } remainder -= segment total += segment previous = p } return [1, total] } class MeasuredCubic { constructor(c, startOutlineProgress, endOutlineProgress) { if (endOutlineProgress < startOutlineProgress) { throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress') } this.cubic = c this.startOutlineProgress = startOutlineProgress this.endOutlineProgress = endOutlineProgress this.measuredSize = closestProgressTo(c, Infinity)[1] } cutAtProgress(cutOutlineProgress) { const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress) const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress) const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0] const [c1, c2] = split(this.cubic, t) return [new MeasuredCubic(c1, this.startOutlineProgress, bounded), new MeasuredCubic(c2, bounded, this.endOutlineProgress)] } } class MeasuredPolygon { constructor(features, cubics, outlineProgress) { this.features = features this.cubics = [] let startOutlineProgress = 0 for (let i = 0; i < cubics.length; i++) { if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) { this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1])) startOutlineProgress = outlineProgress[i + 1] } } this.cubics.at(-1).endOutlineProgress = 1 } static measure(polygon) { const cubics = [] const featureToCubic = [] for (const feature of polygon.features) { feature.cubics.forEach((c, i) => { if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) { featureToCubic.push([feature, cubics.length]) } cubics.push(c) }) } const measures = [0] for (const c of cubics) { measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1]) } const outlineProgress = measures.map((measure) => measure / measures.at(-1)) const features = featureToCubic.map(([feature, ix]) => ({ progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), feature, })) return new MeasuredPolygon(features, cubics, outlineProgress) } cutAndShift(cuttingPoint) { if (cuttingPoint < DISTANCE_EPSILON) { return this } const n = this.cubics.length const targetIndex = this.cubics.findIndex((c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress) const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint) const cubics = [b2.cubic] for (let i = 1; i < n; i++) { cubics.push(this.cubics[(i + targetIndex) % n].cubic) } cubics.push(b1.cubic) const outlineProgress = Array.from({ length: n + 2 }, (_, index) => { if (index === 0) { return 0 } if (index === n + 1) { return 1 } return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1) }) const features = this.features.map(({ progress, feature }) => ({ progress: positiveModulo(progress - cuttingPoint, 1), feature })) return new MeasuredPolygon(features, cubics, outlineProgress) } } function featureDistSquared(f1, f2) { if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) { return Infinity } const representative = (feature) => { const [first, last] = [feature.cubics[0], feature.cubics.at(-1)] return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2) } const [p1, p2] = [representative(f1), representative(f2)] return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2 } function doMapping(features1, features2) { const distanceVertexList = [] for (const f1 of features1) { for (const f2 of features2) { const distance = featureDistSquared(f1.feature, f2.feature) if (distance !== Infinity) { distanceVertexList.push({ distance, f1, f2 }) } } } distanceVertexList.sort((a, b) => a.distance - b.distance) if (distanceVertexList.length === 0) { return [ [0, 0], [0.5, 0.5], ] } if (distanceVertexList.length === 1) { const { f1, f2 } = distanceVertexList[0] return [ [f1.progress, f2.progress], [(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1], ] } const mapping = [] const usedF1 = new Set() const usedF2 = new Set() for (const { f1, f2 } of distanceVertexList) { if (usedF1.has(f1) || usedF2.has(f2)) { continue } const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress) const index = insertionIndex === -1 ? mapping.length : insertionIndex if (index < mapping.length && mapping[index][0] === f1.progress) { throw new Error("There can't be two features with the same progress") } const n = mapping.length if (n >= 1) { const [before1, before2] = mapping[(index + n - 1) % n] const [after1, after2] = mapping[index % n] if ( progressDistance(f1.progress, before1) < DISTANCE_EPSILON || progressDistance(f1.progress, after1) < DISTANCE_EPSILON || progressDistance(f2.progress, before2) < DISTANCE_EPSILON || progressDistance(f2.progress, after2) < DISTANCE_EPSILON ) { continue } if (n > 1 && !progressInRange(f2.progress, before2, after2)) { continue } } mapping.splice(index, 0, [f1.progress, f2.progress]) usedF1.add(f1) usedF2.add(f2) } return mapping } /** Morph.match: both shapes cut into pairs of matching cubics. */ function match(p1, p2) { const measuredPolygon1 = MeasuredPolygon.measure(p1) const measuredPolygon2 = MeasuredPolygon.measure(p2) const corners = (features) => features.filter(({ feature }) => feature.type === 'corner') const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features)) const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])] const map = (x) => linearMap(sources, targets, x) const mapBack = (x) => linearMap(targets, sources, x) const polygon2CutPoint = map(0) const bs1 = measuredPolygon1.cubics const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics const pairs = [] let i1 = 0 let i2 = 0 let b1 = bs1[i1++] let b2 = bs2[i2++] while (b1 !== undefined && b2 !== undefined) { const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress const b2a = i2 === bs2.length ? 1 : mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1)) const minb = Math.min(b1a, b2a) let seg1 let seg2 if (b1a > minb + ANGLE_EPSILON) { ;[seg1, b1] = b1.cutAtProgress(minb) } else { seg1 = b1 b1 = bs1[i1++] } if (b2a > minb + ANGLE_EPSILON) { ;[seg2, b2] = b2.cutAtProgress(positiveModulo(map(minb) - polygon2CutPoint, 1)) } else { seg2 = b2 b2 = bs2[i2++] } pairs.push([seg1.cubic, seg2.cubic]) } if (b1 !== undefined || b2 !== undefined) { throw new Error("Expected both Polygon's Cubic to be fully matched") } return pairs } /** Morph.asCubics: every matched pair interpolated at `progress`, closed on its first anchor. */ function asCubics(pairs, progress) { const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress)) cubics.at(-1)[6] = cubics[0][0] cubics.at(-1)[7] = cubics[0][1] return cubics } export { point, plus, minus, times, length, pointOnCurve, split, turningPoints, cubicBounds, rounding, UNROUNDED, polygonFromVertices, regularPolygon, circlePolygon, star, match, asCubics, }