/** * @file Draw Convex Path Around PathItems.js * * Draws a convex bezier path (the convex hull) that wraps around a set of page items. * * @author m1b (designed by m1b, much maths work done by claude!) * @version 2026-08-23 */ (function () { if ( 0 === app.documents.length || 0 === app.activeDocument.selection.length ) return alert('Please select one or more path items.'); var doc = app.activeDocument; var appearance = { filled: false, stroked: true, strokeColor: makeStrokeColor(doc), strokeWidth: 1, }; drawConvexPathAroundPathItems(doc, doc.selection, undefined, appearance); })(); /** * Draws a convex bezier path wrapping around the shapes * found within `pathItems`. Circular parts are wrapped as arcs * and corners as straight tangents. * @author m1b * @version 2026-08-23 * @param {Document|GroupItem|Layer} container - where the drawn path will be added. * @param {PageItem|Array} pathItems - the items whose shapes will be wrapped. * @param {Number} [tolerance] - tolerance used to detect circular segments (default: 0.1). * @param {Object} [appearance] - properties to apply to the drawn path (eg. strokeColor). * @returns {PathItem} - the drawn convex path. */ function drawConvexPathAroundPathItems(container, pathItems, tolerance, appearance) { if (!container.hasOwnProperty('pathItems')) throw new Error('drawConvexPathAroundCircles: bad `container` supplied.'); var disks = getDisksFromPathItems(pathItems, tolerance); // discard disks wholly contained within another disks = removeContainedDisks(disks); if (disks.length == 0) throw new Error('drawConvexPathAroundCircles: no circles or points found in `pathItems`.'); // a single circle needs no tangents if (disks.length == 1) { var only = disks[0]; if (only.r < 1e-6) throw new Error('drawConvexPathAroundCircles: need more than a single point to make a hull.'); var ellipse = container.pathItems.ellipse(only.cy + only.r, only.cx - only.r, only.r * 2, only.r * 2); applyAppearance(ellipse, appearance); return ellipse; } var arcs = convexHullArcs(disks); return drawHullPath(container, arcs, appearance); }; /** * Extracts disks from `pathItems`. Each anchor point becomes a * radius-0 disk (a corner), and each circular segment contributes * to a disk carrying the *arc's actual angular span* — so a * half-circle's disk only wraps its real bulge, not a phantom full * circle. Segments of the same circle are merged: a full ellipse * becomes a full disk, a semicircle a 180° arc, and so on. * Non-circular *curved* segments are represented by their anchor * points only, so any bulge beyond the anchors is ignored. * @author m1b * @version 2026-08-24 * @param {PageItem|Array} pathItems - the items to inspect. * @param {Number} [tolerance] - tolerance used to detect circular segments (default: 0.1). * @returns {Array} - disks; a corner is { cx, cy, r:0 }, a full circle * has `full:true`, and a partial arc carries `arcStart` and `arcSpan` (radians). */ function getDisksFromPathItems(pathItems, tolerance) { if (tolerance == undefined) tolerance = 0.1; var TWO_PI = Math.PI * 2; var items = getPathItems(pathItems); var disks = []; var pointSeen = {}; // gather each circle's segments, keyed by rounded center + radius var circleMap = {}; var circleOrder = []; for (var i = 0; i < items.length; i++) { var item = items[i]; for (var j = 0, len = item.pathPoints.length - 1; j <= len; j++) { var p1 = item.pathPoints[j]; var p2 = j < len ? item.pathPoints[j + 1] : item.pathPoints[0]; // every anchor is a candidate corner (radius-0 disk) var pointKey = stringify(p1.anchor, 1); if (pointSeen[pointKey] != true) { pointSeen[pointKey] = true; disks.push({ cx: p1.anchor[0], cy: p1.anchor[1], r: 0 }); } var circular = segmentIsCircular(p1, p2, tolerance); if (circular == undefined) continue; var circleKey = stringify([circular.center[0], circular.center[1], circular.radius], 1); if (circleMap[circleKey] == undefined) { circleMap[circleKey] = { sumX: 0, sumY: 0, sumR: 0, count: 0, segments: [] }; circleOrder.push(circleKey); } var group = circleMap[circleKey]; group.sumX += circular.center[0]; group.sumY += circular.center[1]; group.sumR += circular.radius; group.count++; group.segments.push({ p1: p1.anchor, p2: p2.anchor, mid: bezierPointAtT(p1.anchor, p1.rightDirection, p2.leftDirection, p2.anchor, 0.5), }); } } // build each circle's disk(s) from its segments for (var c = 0; c < circleOrder.length; c++) { var circle = circleMap[circleOrder[c]]; // one averaged center for the whole circle, so every segment's span is // measured from the same origin and adjacent arcs meet with no gap var cx = circle.sumX / circle.count; var cy = circle.sumY / circle.count; var r = circle.sumR / circle.count; var intervals = []; for (var g = 0; g < circle.segments.length; g++) { var seg = circle.segments[g]; intervals.push(arcRangeForSegment([cx, cy], seg.p1, seg.p2, seg.mid)); } var merged = mergeCircularIntervals(intervals); for (var m = 0; m < merged.length; m++) { if (merged[m].span >= TWO_PI - 1e-3) disks.push({ cx: cx, cy: cy, r: r, full: true }); else disks.push({ cx: cx, cy: cy, r: r, arcStart: merged[m].start, arcSpan: merged[m].span }); } } return disks; }; /** * Returns the CCW angular span [start, start+span] (radians) that a * circular segment actually covers on its fitted circle, using the * segment's midpoint to pick the correct direction around the circle. * @author m1b * @version 2026-08-24 * @param {Array} center - the fitted circle center [cx, cy]. * @param {Array} p1 - the segment's start anchor [x, y]. * @param {Array} p2 - the segment's end anchor [x, y]. * @param {Array} midpoint - a point on the segment near its middle. * @returns {Object} - { start, span } in radians, CCW. */ function arcRangeForSegment(center, p1, p2, midpoint) { var a1 = Math.atan2(p1[1] - center[1], p1[0] - center[0]); var a2 = Math.atan2(p2[1] - center[1], p2[0] - center[0]); var am = Math.atan2(midpoint[1] - center[1], midpoint[0] - center[0]); var spanToEnd = mod2pi(a2 - a1); var spanToMid = mod2pi(am - a1); // the real arc is the direction (CCW from a1, or CCW from a2) that passes through the midpoint if (spanToMid <= spanToEnd) return { start: mod2pi(a1), span: spanToEnd }; else return { start: mod2pi(a2), span: mod2pi(a1 - a2) }; }; /** * Merges a set of CCW angular intervals on a circle into maximal * intervals, collapsing to a single full-circle interval when they * cover everything. Each interval is { start, span } in radians. * @author m1b * @version 2026-08-24 * @param {Array} intervals - array of { start, span }. * @returns {Array} - merged array of { start, span }. */ function mergeCircularIntervals(intervals) { var TWO_PI = Math.PI * 2; var EPS = 1e-7; if (intervals.length == 0) return []; // duplicate each interval one turn later, so wrap-around joins are found by a plain sweep var segments = []; for (var i = 0; i < intervals.length; i++) { if (intervals[i].span >= TWO_PI - EPS) return [{ start: 0, span: TWO_PI }]; var s = mod2pi(intervals[i].start); segments.push({ s: s, e: s + intervals[i].span }); segments.push({ s: s + TWO_PI, e: s + TWO_PI + intervals[i].span }); } segments.sort(function (a, b) { return a.s - b.s; }); var merged = []; for (var i = 0; i < segments.length; i++) { var seg = segments[i]; if ( merged.length == 0 || seg.s > merged[merged.length - 1].e + EPS ) merged.push({ s: seg.s, e: seg.e }); else merged[merged.length - 1].e = Math.max(merged[merged.length - 1].e, seg.e); } var result = []; for (var i = 0; i < merged.length; i++) { // a run that covers a whole turn means the circle is fully covered if (merged[i].e - merged[i].s >= TWO_PI - EPS) return [{ start: 0, span: TWO_PI }]; // keep one canonical copy of each run (those starting within the first turn) if (merged[i].s >= -EPS && merged[i].s < TWO_PI - EPS) result.push({ start: mod2pi(merged[i].s), span: Math.min(merged[i].e - merged[i].s, TWO_PI) }); } return result; }; /** * Returns true if `psi` (a normal angle) lies on the real part of * `disk`: always true for a corner (radius 0) or a full circle, * otherwise within the disk's arc span. * @author m1b * @version 2026-08-24 * @param {Object} disk - a disk from getDisksFromPathItems. * @param {Number} psi - a normal angle in radians. * @returns {Boolean} */ function diskContainsNormal(disk, psi) { if (disk.r < 1e-6 || disk.full) return true; return mod2pi(psi - disk.arcStart) <= disk.arcSpan + 1e-7; }; /** * Removes any disk wholly contained within another disk, and any * exact duplicate. Only a *full* circle can swallow another disk: * a partial arc must not remove its own endpoint corners, which the * hull needs as vertices where the flat side begins. * @author m1b * @version 2026-08-24 * @param {Array} disks - array of disks from getDisksFromPathItems. * @returns {Array} - the surviving disks. */ function removeContainedDisks(disks) { var result = []; for (var i = 0; i < disks.length; i++) { var a = disks[i]; var contained = false; for (var j = 0; j < disks.length; j++) { if (i == j) continue; var b = disks[j]; var d = distanceBetweenPoints([a.cx, a.cy], [b.cx, b.cy]); if (b.full) { // a is inside b (bias to keeping the earlier one when identical) if ( d + a.r <= b.r + 1e-7 && !(d < 1e-7 && a.r == b.r && a.full && j > i) ) { contained = true; break; } } else if ( a.r < 1e-6 && b.arcStart != undefined && Math.abs(d - b.r) < 0.5 ) { // a corner sitting on a partial arc's curve, strictly between its // endpoints, is already covered by the arc; drop it so it can't // split the arc with a stray chord (endpoint tips are kept) var off = mod2pi(Math.atan2(a.cy - b.cy, a.cx - b.cx) - b.arcStart); if (off > 1e-3 && off < b.arcSpan - 1e-3) { contained = true; break; } } } if (!contained) result.push(a); } return result; }; /** * Computes the convex hull of a set of disks as an ordered * (counter-clockwise) list of arcs. A radius-0 disk is a corner * and yields a degenerate (single-point) arc. Consecutive arcs are * joined by straight external-tangent segments, so the caller only * needs to connect each arc's end to the next arc's start with a line. * Uses a gift-wrapping march where the supporting line's normal * rotates continuously counter-clockwise around the hull. * @author m1b * @version 2026-08-23 * @param {Array} circles - array of { cx, cy, r } (at least 2, none contained; r may be 0). * @returns {Array} - ordered arcs { cx, cy, r, a0, a1 } where a0..a1 is the CCW span. */ function convexHullArcs(circles) { var TWO_PI = Math.PI * 2; var EPS = 1e-9; var n = circles.length; // start on the highest *real* point, which is guaranteed on the hull; // a partial arc's phantom top (normal +90° outside its span) doesn't count var start = 0; var bestTop = -Infinity; for (var i = 0; i < n; i++) { var disk = circles[i]; var top; if (disk.r < 1e-6) top = disk.cy; else if (disk.full) top = disk.cy + disk.r; else { // only count a partial arc's top when +90° is strictly inside its span var off = mod2pi(Math.PI / 2 - disk.arcStart); if (off > EPS && off < disk.arcSpan - EPS) top = disk.cy + disk.r; else top = -Infinity; } if ( top > bestTop + EPS || (Math.abs(top - bestTop) < EPS && disk.r > circles[start].r) ) { bestTop = top; start = i; } } var edges = []; var current = start; var phi = Math.PI / 2; var firstFrom = -1; var firstTo = -1; var guard = 0; var maxGuard = 4 * n + 4; while (guard++ < maxGuard) { // find the tangent whose normal is the smallest CCW step from phi var bestJ = -1; var bestIncrement = Infinity; var bestPsi = 0; for (var j = 0; j < n; j++) { if (j == current) continue; var psi = externalTangentNormal(circles[current], circles[j]); if (psi == null) continue; // the tangent touches both disks at normal `psi`; skip it if that // point falls on either disk's phantom (non-existent) part if ( !diskContainsNormal(circles[current], psi) || !diskContainsNormal(circles[j], psi) ) continue; var increment = mod2pi(psi - phi); // ignore a zero step (same direction we arrived from) if (increment < EPS) increment += TWO_PI; if (increment < bestIncrement - EPS) { bestIncrement = increment; bestJ = j; bestPsi = psi; } else if (Math.abs(increment - bestIncrement) < EPS) { // co-tangent circles: prefer the one farther along the tangent direction var tx = -Math.sin(psi); var ty = Math.cos(psi); var farBest = (circles[bestJ].cx - circles[current].cx) * tx + (circles[bestJ].cy - circles[current].cy) * ty; var farThis = (circles[j].cx - circles[current].cx) * tx + (circles[j].cy - circles[current].cy) * ty; if (farThis > farBest) { bestJ = j; bestPsi = psi; } } } if (bestJ == -1) break; // closing the loop: about to repeat the first edge if ( edges.length > 0 && current == firstFrom && bestJ == firstTo ) break; if (edges.length == 0) { firstFrom = current; firstTo = bestJ; } edges.push({ from: current, to: bestJ, psi: bestPsi }); phi = bestPsi; current = bestJ; } // each edge leaves circle `from`; the arc on that circle spans // from the previous edge's normal (where we arrived) to this edge's normal (where we leave) var arcs = []; var m = edges.length; for (var k = 0; k < m; k++) { var edge = edges[k]; var previous = edges[(k - 1 + m) % m]; arcs.push({ cx: circles[edge.from].cx, cy: circles[edge.from].cy, r: circles[edge.from].r, a0: previous.psi, a1: edge.psi, }); } return arcs; }; /** * Returns the normal angle of the counter-clockwise "left" * external tangent from circle `a` to circle `b`, ie. the * outward normal shared by both tangent points. Returns null * when no external tangent exists (one circle contains the other). * @author m1b * @version 2026-08-23 * @param {Object} a - circle { cx, cy, r } we travel from. * @param {Object} b - circle { cx, cy, r } we travel to. * @returns {Number|null} - the normal angle in radians, or null. */ function externalTangentNormal(a, b) { var dx = b.cx - a.cx; var dy = b.cy - a.cy; var distance = Math.sqrt(dx * dx + dy * dy); if (distance < 1e-9) return null; // no external tangent when one disk strictly contains the other; a tip that // sits a hair inside its own arc (fitting slop) is still allowed to tangent var clearance = 1e-6 + 1e-3 * Math.max(a.r, b.r); if (distance < Math.abs(a.r - b.r) - clearance) return null; var ratio = (a.r - b.r) / distance; // clamp tiny floating overshoot so an on-edge tip still yields a tangent if (ratio > 1) ratio = 1; else if (ratio < -1) ratio = -1; var base = Math.atan2(dy, dx); // the "- acos" branch keeps the hull interior on the left for CCW travel return base - Math.acos(ratio); }; /** * Draws the convex hull as a closed bezier path: each arc is drawn * as circular bezier segments and consecutive arcs are joined by * straight (tangent) segments. * @author m1b * @version 2026-08-23 * @param {Document|GroupItem|Layer} container - where the path is added. * @param {Array} arcs - ordered arcs from convexHullArcs. * @param {Object} [appearance] - properties to apply to the path. * @returns {PathItem} */ function drawHullPath(container, arcs, appearance) { var anchors = []; var lefts = []; var rights = []; for (var k = 0; k < arcs.length; k++) { var arc = arcs[k]; // a radius-0 disk is a corner: emit a single sharp point // whose neighbouring tangent segments meet as straight lines if (arc.r < 1e-6) { anchors.push([arc.cx, arc.cy]); lefts.push([arc.cx, arc.cy]); rights.push([arc.cx, arc.cy]); continue; } var span = mod2pi(arc.a1 - arc.a0); // subdivide so each bezier segment covers no more than 90° var steps = Math.max(1, Math.ceil(span / (Math.PI / 2) - 1e-9)); var deltaAngle = span / steps; // kappa handle length for a circular arc segment of `deltaAngle` var handle = arc.r * (4 / 3) * Math.tan(deltaAngle / 4); for (var s = 0; s <= steps; s++) { var angle = arc.a0 + deltaAngle * s; var cosA = Math.cos(angle); var sinA = Math.sin(angle); var ax = arc.cx + arc.r * cosA; var ay = arc.cy + arc.r * sinA; // unit tangent for CCW travel var tx = -sinA; var ty = cosA; anchors.push([ax, ay]); // straight into the arc's first point (from the incoming tangent segment) if (s > 0) lefts.push([ax - handle * tx, ay - handle * ty]); else lefts.push([ax, ay]); // straight out of the arc's last point (to the outgoing tangent segment) if (s < steps) rights.push([ax + handle * tx, ay + handle * ty]); else rights.push([ax, ay]); } } // an arc ending on a corner tip produces two near-coincident anchors joined // by a tiny tangent (the tip and the arc's drawn end differ by fitting slop); // merge them, keeping the curve on each side var mergeTol = 0.5; var mergedAnchors = []; var mergedLefts = []; var mergedRights = []; for (var i = 0; i < anchors.length; i++) { if ( mergedAnchors.length > 0 && distanceBetweenPoints(anchors[i], mergedAnchors[mergedAnchors.length - 1]) < mergeTol ) // fold into the previous point, taking this point's outgoing handle mergedRights[mergedRights.length - 1] = rights[i]; else { mergedAnchors.push(anchors[i]); mergedLefts.push(lefts[i]); mergedRights.push(rights[i]); } } // the closing tangent may also be near-zero-length (first and last coincide) if ( mergedAnchors.length > 1 && distanceBetweenPoints(mergedAnchors[0], mergedAnchors[mergedAnchors.length - 1]) < mergeTol ) { mergedLefts[0] = mergedLefts[mergedLefts.length - 1]; mergedAnchors.pop(); mergedLefts.pop(); mergedRights.pop(); } var path = container.pathItems.add(); path.setEntirePath(mergedAnchors); path.closed = true; for (var i = 0; i < path.pathPoints.length; i++) { var point = path.pathPoints[i]; point.leftDirection = mergedLefts[i]; point.rightDirection = mergedRights[i]; } applyAppearance(path, appearance); return path; }; /** * Applies the properties of `appearance` to `item`, * skipping any the item does not have. * @author m1b * @version 2026-08-23 * @param {PageItem} item - the item to modify. * @param {Object} [appearance] - properties to apply. */ function applyAppearance(item, appearance) { if (appearance == undefined) return; for (var key in appearance) if ( appearance.hasOwnProperty(key) && item.hasOwnProperty(key) ) item[key] = appearance[key]; }; /** * Returns a black color appropriate for the document's color space. * @author m1b * @version 2026-08-23 * @param {Document} doc - an Illustrator Document. * @returns {CMYKColor|RGBColor} */ function makeStrokeColor(doc) { if (DocumentColorSpace.CMYK == doc.documentColorSpace) { var cmyk = new CMYKColor(); cmyk.cyan = 0; cmyk.magenta = 0; cmyk.yellow = 0; cmyk.black = 100; return cmyk; } var rgb = new RGBColor(); rgb.red = 0; rgb.green = 0; rgb.blue = 0; return rgb; }; /** * Returns `angle` between 0 and 2π. * @author m1b * @version 2026-08-23 * @param {Number} angle - angle in radians. * @returns {Number} */ function mod2pi(angle) { var TWO_PI = Math.PI * 2; angle = angle % TWO_PI; if (angle < 0) angle += TWO_PI; return angle; }; /** * Determines if a segment (PathPoints p1 and p2) * is circular. If so, will return the center * position and radius of the circle. * A shallow (nearly straight) segment is rejected: it fits an * enormous circle that would balloon the hull, so we require the * arc to sweep at least `minArcAngle` at the fitted center. * @author m1b * @version 2026-08-23 * @param {PathPoint} p1 - the first point of the segment. * @param {PathPoint} p2 - the last point of the segment. * @param {Number} [tolerance] - tolerance for circularity (default: 0.1). * @param {Number} [minArcAngle] - smallest accepted arc sweep in radians (default: 30°). * @returns {Object|undefined} - { center, radius } when circular. */ function segmentIsCircular(p1, p2, tolerance, minArcAngle) { if (tolerance == undefined) tolerance = 0.1; if (minArcAngle == undefined) minArcAngle = Math.PI / 6; // can't be circular if no control points if ( arraysAreEqual(p1.anchor, p1.rightDirection) || arraysAreEqual(p2.anchor, p2.leftDirection) ) return; // find the intersection of rays normal to the two anchor points var intersectionPoint = bezierIntersection( p1.anchor, p1.rightDirection, p2.leftDirection, p2.anchor ); var halfwayPoint = bezierPointAtT( p1.anchor, p1.rightDirection, p2.leftDirection, p2.anchor, 0.5 ); // check length between intersection point and of start middle and end points var r1 = distanceBetweenPoints(intersectionPoint, p1.anchor); var r2 = distanceBetweenPoints(intersectionPoint, p2.anchor); var r3 = distanceBetweenPoints(intersectionPoint, halfwayPoint); var isCircle = range([r1, r2, r3]) < tolerance; if (!isCircle) return; // reject a shallow arc, whose huge fitted circle would balloon the hull var v1x = p1.anchor[0] - intersectionPoint[0]; var v1y = p1.anchor[1] - intersectionPoint[1]; var v2x = p2.anchor[0] - intersectionPoint[0]; var v2y = p2.anchor[1] - intersectionPoint[1]; var cosSweep = (v1x * v2x + v1y * v2y) / (r1 * r2); // clamp against tiny floating overshoot before acos if (cosSweep > 1) cosSweep = 1; else if (cosSweep < -1) cosSweep = -1; if (Math.acos(cosSweep) < minArcAngle) return; return { center: intersectionPoint, radius: r2, }; }; /** * Returns the position of the intersection between * rays normal to the start and end of the bezier. * @author m1b * @version 2023-11-18 * @param {Array} p0 - point 1 of bezier curve [x, y]. * @param {Array} p1 - point 2 of bezier curve [x, y]. * @param {Array} p2 - point 3 of bezier curve [x, y]. * @param {Array} p3 - point 4 of bezier curve [x, y]. * @returns {Array} - [x, y]. */ function bezierIntersection(p0, p1, p2, p3) { // calculate the normal angles at the start and end of the curve var startAngleRad = bezierNormalAngle(p0, p1, p2, p3, 0, false); var endAngleRad = bezierNormalAngle(p0, p1, p2, p3, 1, false); // calculate the slopes of the lines normal to the start and end points var startSlope = Math.tan(startAngleRad + Math.PI / 2); var endSlope = Math.tan(endAngleRad + Math.PI / 2); // calculate the y intercepts of the lines var startYIntercept = p0[1] - startSlope * p0[0]; var endYIntercept = p3[1] - endSlope * p3[0]; // calculate the intersection point var xIntersection = (startYIntercept - endYIntercept) / (endSlope - startSlope); var yIntersection = startSlope * xIntersection + startYIntercept; return [xIntersection, yIntersection]; }; /** * Returns the position of a point at `t` * on the bezier curve (p0,p1,p2,p3). * @version 2023-11-18 * @param {Array} p0 - point 1 of bezier curve [x, y]. * @param {Array} p1 - point 2 of bezier curve [x, y]. * @param {Array} p2 - point 3 of bezier curve [x, y]. * @param {Array} p3 - point 4 of bezier curve [x, y]. * @param {Number} t - parameter 0..1 where 0 is start and 1 is end of curve. * @returns {Array} - [x,y]. */ function bezierPointAtT(p0, p1, p2, p3, t) { var u = 1 - t; var tt = t * t; var uu = u * u; var uuu = uu * u; var ttt = tt * t; var p = []; p[0] = uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0]; p[1] = uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1]; return p; }; /** * Returns the tangent angle at `t` in either * radians (default) or degrees. * @author m1b * @version 2023-11-18 * @param {Array} p0 - point 1 of bezier curve [x, y]. * @param {Array} p1 - point 2 of bezier curve [x, y]. * @param {Array} p2 - point 3 of bezier curve [x, y]. * @param {Array} p3 - point 4 of bezier curve [x, y]. * @param {Number} t - parameter 0..1 where 0 is start and 1 is end of curve. * @param {Boolean} convertToDegrees - whether to convert to degrees (default: false). * @returns {Number} */ function bezierNormalAngle(p0, p1, p2, p3, t, convertToDegrees) { var smidgeon = 1e-5; // tiny amount on either side var t1 = Math.max(0, t - smidgeon); var t2 = Math.min(1, t + smidgeon); var point1 = bezierPointAtT(p0, p1, p2, p3, t1); var point2 = bezierPointAtT(p0, p1, p2, p3, t2); var dx = point2[0] - point1[0]; var dy = point2[1] - point1[1]; var angleRad = Math.atan2(dy, dx); if (convertToDegrees === true) return (angleRad * 180) / Math.PI; else return angleRad; }; /** * Returns distance between two points. * @author m1b * @version 2022-07-25 * @param {Array} p1 - a point array [x, y]. * @param {Array} p2 - a point array [x, y]. * @returns {Number} - distance in points. */ function distanceBetweenPoints(p1, p2) { var a = p1[0] - p2[0]; var b = p1[1] - p2[1]; return Math.sqrt(a * a + b * b); }; /** * Returns the range of an array of numbers, * ie. the difference between the highest * and lowest members of the array. * @param {Array} arr - the array. * @returns {Number} */ function range(arr) { if (arr.length == 0) return 0; return Math.max.apply(null, arr) - Math.min.apply(null, arr); }; /** * Returns true when arrays are equal. * @param {Array} arr1 - an array of comparable objects. * @param {Array} arr2 - an array of comparable objects. * @returns {Boolean} */ function arraysAreEqual(arr1, arr2) { if (arr1.length != arr2.length) return false; for (var i = 0; i < arr1.length; i++) if (arr1[i] !== arr2[i]) return false; return true; }; /** * Stringifier for purposes of * differentiating between objects. * @author m1b * @version 2023-11-18 * @param {*} obj - the object to stringify * @param {Number} precision - the number of decimal places to round numbers, can be negative. * @returns {String} */ function stringify(obj, precision) { if (precision == undefined) precision = 0; var str = ''; if (obj == undefined) str += obj; else if (obj.constructor.name === 'Array') for (var i = 0; i < obj.length; i++) str += stringify(obj[i], precision); else if (obj.constructor.name === 'Object') { for (var key in obj) if (obj.hasOwnProperty(key)) str += stringify(obj[key], precision); } else if (obj.constructor.name === 'Number') str += String(round(obj, precision)); else str += String(obj); return str; }; /** * Rounds a single number or an array of numbers. * @author m1b * @version 2022-08-02 * @param {Number|Array} nums - a Number or Array of Numbers. * @param {Number} [places] - round to this many decimal places (default: 0). * @return {Number|Array} - the rounded Number(s). */ function round(nums, places) { if (places == undefined) places = 0; places = Math.pow(10, places); var result = []; if (nums.constructor.name != 'Array') nums = [nums]; for (var i = 0; i < nums.length; i++) result[i] = Math.round(nums[i] * places) / places; return nums.length == 1 ? result[0] : result; }; /** * Returns every PathItem found * in `container` * @author m1b * @version 2023-11-18 * @param {*} container - any Illustrator DOM object that can contain path items. * @returns {Array} */ function getPathItems(container) { var items = []; if (container.constructor.name == 'Array') for (var i = 0, len = container.length; i < len; i++) items = items.concat(getPathItems(container[i])); else if (container.constructor.name == 'GroupItem') for (var i = 0, len = container.pageItems.length; i < len; i++) items = items.concat(getPathItems(container.pageItems[i])); else if (container.constructor.name == 'CompoundPathItem') for (var i = 0, len = container.pathItems.length; i < len; i++) items = items.concat(getPathItems(container.pathItems[i])); else if (container.constructor.name == 'PathItem') items.push(container); return items; };