UNPKG

paperjs-offset

Version:
370 lines (346 loc) 14.6 kB
import paper from 'paper'; export type StrokeJoinType = 'miter' | 'bevel' | 'round'; export type StrokeCapType = 'round' | 'butt'; export type PathType = paper.Path | paper.CompoundPath; type HandleType = 'handleIn' | 'handleOut'; /** * Offset the start/terminal segment of a bezier curve * @param segment segment to offset * @param curve curve to offset * @param handleNormal the normal of the the line formed of two handles * @param offset offset value */ function offsetSegment(segment: paper.Segment, curve: paper.Curve, handleNormal: paper.Point, offset: number) { const isFirst = segment.curve === curve; // get offset vector const offsetVector = (curve.getNormalAtTime(isFirst ? 0 : 1)).multiply(offset); // get offset point const point = segment.point.add(offsetVector); const newSegment = new paper.Segment(point); // handleOut for start segment & handleIn for terminal segment const handle = (isFirst ? 'handleOut' : 'handleIn') as HandleType; newSegment[handle] = segment[handle]!.add(handleNormal.subtract(offsetVector).divide(2)); return newSegment; } /** * Adaptive offset a curve by repeatly apply the approximation proposed by Tiller and Hanson. * @param curve curve to offset * @param offset offset value */ function adaptiveOffsetCurve(curve: paper.Curve, offset: number): paper.Segment[] { const hNormal = (new paper.Curve(curve.segment1.handleOut!.add(curve.segment1.point), new paper.Point(0, 0), new paper.Point(0, 0), curve.segment2.handleIn!.add(curve.segment2.point))).getNormalAtTime(0.5).multiply(offset); const segment1 = offsetSegment(curve.segment1, curve, hNormal, offset); const segment2 = offsetSegment(curve.segment2, curve, hNormal, offset); // divide && re-offset const offsetCurve = new paper.Curve(segment1, segment2); // if the offset curve is not self intersected, divide it if (offsetCurve.getIntersections(offsetCurve).length === 0) { const threshold = Math.min(Math.abs(offset) / 10, 1); const midOffset = offsetCurve.getPointAtTime(0.5).getDistance(curve.getPointAtTime(0.5)); if (Math.abs(midOffset - Math.abs(offset)) > threshold) { const subCurve = curve.divideAtTime(0.5); if (subCurve != null) { return [...adaptiveOffsetCurve(curve, offset), ...adaptiveOffsetCurve(subCurve, offset)]; } } } return [segment1, segment2]; } /** * Create a round join segment between two adjacent segments. */ function makeRoundJoin(segment1: paper.Segment, segment2: paper.Segment, originPoint: paper.Point, radius: number) { const through = segment1.point.subtract(originPoint).add(segment2.point.subtract(originPoint)) .normalize(Math.abs(radius)).add(originPoint); const arc = new paper.Path.Arc({ from: segment1.point, to: segment2.point, through, insert: false }); segment1.handleOut = arc.firstSegment.handleOut; segment2.handleIn = arc.lastSegment.handleIn; return arc.segments.length === 3 ? arc.segments[1] : null; } function det(p1: paper.Point, p2: paper.Point) { return p1.x * p2.y - p1.y * p2.x; } /** * Get the intersection point of point based lines */ function getPointLineIntersections(p1: paper.Point, p2: paper.Point, p3: paper.Point, p4: paper.Point) { const l1 = p1.subtract(p2); const l2 = p3.subtract(p4); const dl1 = det(p1, p2); const dl2 = det(p3, p4); return new paper.Point(dl1 * l2.x - l1.x * dl2, dl1 * l2.y - l1.y * dl2).divide(det(l1, l2)); } /** * Connect two adjacent bezier curve, each curve is represented by two segments, * create different types of joins or simply removal redundant segment. */ function connectAdjacentBezier(segments1: paper.Segment[], segments2: paper.Segment[], origin: paper.Segment, joinType: StrokeJoinType, offset: number, limit: number) { const curve1 = new paper.Curve(segments1[0], segments1[1]); const curve2 = new paper.Curve(segments2[0], segments2[1]); const intersection = curve1.getIntersections(curve2); const distance = segments1[1].point.getDistance(segments2[0].point); if (origin.isSmooth()) { segments2[0].handleOut = segments2[0].handleOut!.project(origin.handleOut!); segments2[0].handleIn = segments1[1].handleIn!.project(origin.handleIn!); segments2[0].point = segments1[1].point.add(segments2[0].point).divide(2); segments1.pop(); } else { if (intersection.length === 0) { if (distance > Math.abs(offset) * 0.1) { // connect switch (joinType) { case 'miter': const join = getPointLineIntersections(curve1.point2, curve1.point2.add(curve1.getTangentAtTime(1)), curve2.point1, curve2.point1.add(curve2.getTangentAtTime(0))); // prevent sharp angle const joinOffset = Math.max(join.getDistance(curve1.point2), join.getDistance(curve2.point1)); if (joinOffset < Math.abs(offset) * limit) { segments1.push(new paper.Segment(join)); } break; case 'round': const mid = makeRoundJoin(segments1[1], segments2[0], origin.point, offset); if (mid) { segments1.push(mid); } break; default: break; } } else { segments2[0].handleIn = segments1[1].handleIn; segments1.pop(); } } else { const second1 = curve1.divideAt(intersection[0]); if (second1) { const join = second1.segment1; const second2 = curve2.divideAt(curve2.getIntersections(curve1)[0]); join.handleOut = second2 ? second2.segment1.handleOut : segments2[0].handleOut; segments1.pop(); segments2[0] = join; } else { segments2[0].handleIn = segments1[1].handleIn; segments1.pop(); } } } } /** * Connect all the segments together. */ function connectBeziers(rawSegments: paper.Segment[][], join: StrokeJoinType, source: paper.Path, offset: number, limit: number) { const originSegments = source.segments; const first = rawSegments[0].slice(); for (let i = 0; i < rawSegments.length - 1; ++i) { connectAdjacentBezier(rawSegments[i], rawSegments[i + 1], originSegments[i + 1], join, offset, limit); } if (source.closed) { connectAdjacentBezier(rawSegments[rawSegments.length - 1], first, originSegments[0], join, offset, limit); rawSegments[0][0] = first[0]; } return rawSegments; } function reduceSingleChildCompoundPath(path: PathType) { if (path.children.length === 1) { path = path.children[0] as paper.Path; path.remove(); // remove from parent, this is critical, or the style attributes will be ignored } return path; } /** Normalize a path, always clockwise, non-self-intersection, ignore really small components, and no one-component compound path. */ function normalize(path: PathType, areaThreshold = 0.01) { if (path.closed) { const ignoreArea = Math.abs(path.area * areaThreshold); if (!path.clockwise) { path.reverse(); } path = path.unite(path, { insert: false }) as PathType; if (path instanceof paper.CompoundPath) { path.children.filter((c) => Math.abs((c as PathType).area) < ignoreArea).forEach((c) => c.remove()); if (path.children.length === 1) { return reduceSingleChildCompoundPath(path); } } } return path; } function isSameDirection(partialPath: paper.Path, fullPath: PathType) { const offset1 = partialPath.segments[0].location.offset; const offset2 = partialPath.segments[Math.max(1, Math.floor(partialPath.segments.length / 2))].location.offset; const sampleOffset = (offset1 + offset2) / 3; const originOffset1 = fullPath.getNearestLocation(partialPath.getPointAt(sampleOffset)).offset; const originOffset2 = fullPath.getNearestLocation(partialPath.getPointAt(2 * sampleOffset)).offset; return originOffset1 < originOffset2; } /** Remove self intersection when offset is negative by point direction dectection. */ function removeIntersection(path: PathType) { if (path.closed) { const newPath = path.unite(path, { insert: false }) as PathType; if (newPath instanceof paper.CompoundPath) { (newPath.children as paper.Path[]).filter((c) => { if (c.segments.length > 1) { return !isSameDirection(c, path); } else { return true; } }).forEach((c) => c.remove()); return reduceSingleChildCompoundPath(newPath); } } return path; } function getSegments(path: PathType) { if (path instanceof paper.CompoundPath) { return path.children.map((c) => (c as paper.Path).segments).flat(); } else { return (path as paper.Path).segments; } } /** * Remove impossible segments in negative offset condition. */ function removeOutsiders(newPath: PathType, path: PathType) { const segments = getSegments(newPath).slice(); segments.forEach((segment) => { if (!path.contains(segment.point)) { segment.remove(); } }); } function preparePath(path: paper.Path, offset: number): [paper.Path, number] { const source = path.clone({ insert: false }) as paper.Path; source.reduce({}); if (!path.clockwise) { source.reverse(); offset = -offset; } return [source, offset]; } function offsetSimpleShape(path: paper.Path, offset: number, join: StrokeJoinType, limit: number): PathType { let source: paper.Path; [source, offset] = preparePath(path, offset); const curves = source.curves.slice(); const offsetCurves = curves.map((curve) => adaptiveOffsetCurve(curve, offset)).flat(); const raws: paper.Segment[][] = []; for (let i = 0; i < offsetCurves.length; i += 2) { raws.push(offsetCurves.slice(i, i + 2)); } const segments = connectBeziers(raws, join, source, offset, limit).flat(); const newPath = removeIntersection(new paper.Path({ segments, insert: false, closed: path.closed })); newPath.reduce({}); if (source.closed && ((source.clockwise && offset < 0) || (!source.clockwise && offset > 0))) { removeOutsiders(newPath, path); } // recovery path if (source.clockwise !== path.clockwise) { newPath.reverse(); } return normalize(newPath); } function makeRoundCap(from: paper.Segment, to: paper.Segment, offset: number) { const origin = from.point.add(to.point).divide(2); const normal = to.point.subtract(from.point).rotate(-90, new paper.Point(0, 0)).normalize(offset); const through = origin.add(normal); const arc = new paper.Path.Arc({ from: from.point, to: to.point, through, insert: false }); return arc.segments; } function connectSide(outer: PathType, inner: paper.Path, offset: number, cap: StrokeCapType): paper.Path { if (outer instanceof paper.CompoundPath) { let cs = outer.children.map((c) => ({ c, a: Math.abs((c as paper.Path).area) })); cs = cs.sort((c1, c2) => c2.a - c1.a); outer = cs[0].c as paper.Path; } const oSegments = (outer as paper.Path).segments.slice(); const iSegments = inner.segments.slice(); switch (cap) { case 'round': const heads = makeRoundCap(iSegments[iSegments.length - 1], oSegments[0], offset); const tails = makeRoundCap(oSegments[oSegments.length - 1], iSegments[0], offset); const result = new paper.Path({ segments: [...heads, ...oSegments, ...tails, ...iSegments], closed: true, insert: false }); result.reduce({}); return result; default: return new paper.Path({ segments: [...oSegments, ...iSegments], closed: true, insert: false }); } } function offsetSimpleStroke(path: paper.Path, offset: number, join: StrokeJoinType, cap: StrokeCapType, limit: number): PathType { offset = path.clockwise ? offset : -offset; const positiveOffset = offsetSimpleShape(path, offset, join, limit); const negativeOffset = offsetSimpleShape(path, -offset, join, limit); if (path.closed) { return positiveOffset.subtract(negativeOffset, { insert: false }) as PathType; } else { let inner = negativeOffset; let holes = new Array<paper.Path>(); if (negativeOffset instanceof paper.CompoundPath) { holes = negativeOffset.children.filter((c) => (c as paper.Path).closed) as paper.Path[]; holes.forEach((h) => h.remove()); inner = negativeOffset.children[0] as paper.Path; } inner.reverse(); let final = connectSide(positiveOffset, inner as paper.Path, offset, cap) as PathType; if (holes.length > 0) { for (const hole of holes) { final = final.subtract(hole, { insert: false }) as PathType; } } return final; } } function getNonSelfItersectionPath(path: PathType) { if (path.closed) { return path.unite(path, { insert: false }) as PathType; } return path; } export function offsetPath(path: PathType, offset: number, join: StrokeJoinType, limit: number): PathType { const nonSIPath = getNonSelfItersectionPath(path); let result = nonSIPath; if (nonSIPath instanceof paper.Path) { result = offsetSimpleShape(nonSIPath, offset, join, limit); } else { const offsetParts = (nonSIPath.children as paper.Path[]).map((c) => { if (c.segments.length > 1) { if (!isSameDirection(c, path)) { c.reverse(); } let offseted = offsetSimpleShape(c, offset, join, limit); offseted = normalize(offseted); if (offseted.clockwise !== c.clockwise) { offseted.reverse(); } if (offseted instanceof paper.CompoundPath) { offseted.applyMatrix = true; return offseted.children; } else { return offseted; } } else { return null; } }); const children = offsetParts.flat().filter((c) => !!c) as paper.Item[]; result = new paper.CompoundPath({ children, insert: false }); } result.copyAttributes(nonSIPath, false); result.remove(); return result; } export function offsetStroke(path: PathType, offset: number, join: StrokeJoinType, cap: StrokeCapType, limit: number): PathType { const nonSIPath = getNonSelfItersectionPath(path); let result = nonSIPath; if (nonSIPath instanceof paper.Path) { result = offsetSimpleStroke(nonSIPath, offset, join, cap, limit); } else { const children = (nonSIPath.children as paper.Path[]).flatMap((c) => { return offsetSimpleStroke(c, offset, join, cap, limit); }); result = children.reduce((c1, c2) => c1.unite(c2, { insert: false }) as PathType); } result.strokeWidth = 0; result.fillColor = nonSIPath.strokeColor; result.shadowBlur = nonSIPath.shadowBlur; result.shadowColor = nonSIPath.shadowColor; result.shadowOffset = nonSIPath.shadowOffset; return result; }