diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab22110 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +/lib +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4ddba9a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "trailingComma": "all", + "singleQuote": true +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..68695fa --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "paper-clipper", + "version": "1.0.0", + "description": "Use Clipper's boolean and offsetting operations in paper.js", + "main": "index.js", + "scripts": { + "build": "tsc", + "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"", + "lint": "tslint -p tsconfig.json" + }, + "files": ["lib/**/*"], + "repository": "https://gitlab.com/northamerican/paper-clipper", + "author": "Chris Bitsakis", + "license": "MIT", + "private": false, + "dependencies": { + "js-angusj-clipper": "^1.1.0", + "paper": "^0.12.11", + "prettier": "^2.1.1", + "simplify-js": "^1.2.4", + "tslint": "^6.1.3", + "tslint-config-prettier": "^1.18.0" + }, + "devDependencies": { + "typescript": "^4.0.2" + } +} diff --git a/src/betterSimplify.ts b/src/betterSimplify.ts new file mode 100644 index 0000000..85a6126 --- /dev/null +++ b/src/betterSimplify.ts @@ -0,0 +1,176 @@ +import paper from 'paper' + +const geomEpsilon = 1e-4 + +const clearData = (item: paper.Item) => { + item.data = {} + return item +} + +const cloneWithoutData = (item: paper.Item) => + clearData(item.clone({ insert: false })) + +// Get part of a path from offset to offset +// Returns new path +const getPathPart = (targetPath: paper.Path, from: number, distance = Infinity) => { + const reverse = distance < 0 + const path = cloneWithoutData(targetPath) as paper.Path + const pathPart = path.splitAt(from) || path + + if (reverse) { + const pathLength = path.length + const reverseOffset = pathLength - Math.abs(distance) + const withinPath = reverseOffset > 0 + + if (withinPath) { + const pathPartReverse = path.splitAt(reverseOffset) + + pathPartReverse.reverse() + + return pathPartReverse + } + + path.reverse() + + return path + } else { + const withinPath = distance < pathPart.length + + if (withinPath) { + pathPart.splitAt(distance) + } + + return pathPart + } +} + +// Must be a segment with no handles +const getSegmentAngle = (segment: paper.Segment) => { + if (!segment.path.closed && (segment.isFirst() || segment.isLast())) return null + + const { handleIn, handleOut, point, path } = segment + + const hasHandleIn = handleIn.length > geomEpsilon + const hasHandleOut = handleOut.length > geomEpsilon + + const inPointAngleLocation = path.getLocationAt(segment.isFirst() ? path.length - 1 : segment.previous.location.offset) + const outPointAngleLocation = path.getLocationAt(segment.isLast() ? 1 : segment.next.location.offset) + + if (!inPointAngleLocation || !outPointAngleLocation) return null + + const inPointAngle = inPointAngleLocation.point.subtract(point).angle + const outPointAngle = outPointAngleLocation.point.subtract(point).angle + + const inAngle = hasHandleIn ? handleIn.angle : inPointAngle + const outAngle = hasHandleOut ? handleOut.angle : outPointAngle + + const angle = 180 - Math.abs(Math.abs(inAngle - outAngle) - 180) + + return angle +} + +const segmentIsAngled = (threshold = 1) => (segment: paper.Segment) => { + const angle = getSegmentAngle(segment) as number + const isAngled = angle > geomEpsilon && angle < (180 - threshold) + + return isAngled +} + +const removeDuplicateAdjacentSegments = (path: paper.Path): paper.Path => { + const { segments } = path + const segmentsBefore = segments.length + + segments.forEach(segment => { + const { next } = segment + + if (!next) return + + const duplicateSegment = segment.point.isClose(next.point, geomEpsilon) + + if (duplicateSegment) { + next.handleIn = segment.handleIn.clone() + + segment.remove() + } + }) + + return segmentsBefore > segments.length ? removeDuplicateAdjacentSegments(path) : path +} + +const splitAtOffsets = (path: paper.Path) => (offsets: number[]) => { + if (offsets.length === 0) return [path] + if (offsets.length === 1 && path.closed) return [path] + + return offsets.reduce((pathParts: paper.Path[], offset, i, offsetsArr) => { + const prevOffset = offsetsArr[i - 1] || 0 + const pathPart = getPathPart(path, prevOffset, offset - prevOffset) + const isLast = i === offsetsArr.length - 1 + + pathParts = pathParts.concat(pathPart) + + if (isLast && !path.closed) { + const lastPathPart = getPathPart(path, offset, Infinity) + + pathParts = pathParts.concat(lastPathPart) + } + + return pathParts + }, []) +} + +const joinPaths = (paths: paper.Path[]) => { + if (paths.length === 0) return null + + return paths.reduce((path, pathPart, i) => { + if (i === 0) return pathPart + + path.join(pathPart, geomEpsilon) + return path + }) +} + +const simplifyCopy = (tolerance: number) => (targetPathPart: paper.Path) => { + const pathPart = targetPathPart.clone({ insert: false }) as paper.Path + + pathPart.simplify(tolerance) + + const hasMoreSegments = pathPart.segments.length >= targetPathPart.segments.length + + return hasMoreSegments ? targetPathPart : pathPart +} + +const betterSimplify = (tolerance: number) => (targetPath: paper.Path): paper.Path => { + const path = removeDuplicateAdjacentSegments(targetPath) + const isClosed = path.closed + + if (path.length === 0) return targetPath + + if (isClosed) { + path.closed = false + path.addSegments([path.firstSegment.clone()]) + } + + const angledSegments = path.segments.filter(segmentIsAngled(45)) + const angledSegmentOffsets = angledSegments.map( + segment => segment.location.offset + ) + + const pathParts = splitAtOffsets(path)(angledSegmentOffsets) + .map(removeDuplicateAdjacentSegments) + + const simplifiedPathParts = pathParts + .map(simplifyCopy(tolerance)) + + const joinedPath = joinPaths(simplifiedPathParts) + + if (!joinedPath) return targetPath + + if (isClosed) { + joinedPath.join(joinedPath) + joinedPath.closed = true + } + + return joinedPath +} + +export default betterSimplify diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e75c517 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +import clipperOffset from './offset' +import clipperUnite from './unite' + +export { + clipperOffset, + clipperUnite +} \ No newline at end of file diff --git a/src/offset.ts b/src/offset.ts new file mode 100644 index 0000000..32f6125 --- /dev/null +++ b/src/offset.ts @@ -0,0 +1,64 @@ +import * as clipperLib from 'js-angusj-clipper' +import paper from 'paper' +import simplify from 'simplify-js' +import betterSimplify from './betterSimplify' + +// @ts-ignore +paper.setup() + +enum EndTypes { + round = clipperLib.EndType.OpenRound, + square = clipperLib.EndType.OpenSquare, + butt = clipperLib.EndType.OpenButt, + closed = clipperLib.EndType.ClosedPolygon // clipperLib.EndType.ClosedLine +} + +enum JoinTypes { + miter = clipperLib.JoinType.Miter, + round = clipperLib.JoinType.Round, + bevel = clipperLib.JoinType.Square, +} + +const scale = 1000 + +const clipperOffset = (clipper: clipperLib.ClipperLibWrapper) => async (path: paper.Path, offset: number, tolerance: number = 0.5): Promise => { + const { closed, strokeJoin, strokeCap } = path + const pathCopy = path.clone() as paper.Path + pathCopy.flatten(1) + + const data = pathCopy.segments.map(({ point }) => + ({ + x: Math.round(point.x * scale), + y: Math.round(point.y * scale) + }) + ) + + const offsetPaths = clipper.offsetToPaths({ + delta: offset * scale, + arcTolerance: 0.25 * scale, + offsetInputs: [{ + // @ts-ignore + joinType: JoinTypes[strokeJoin], + // @ts-ignore + endType: closed ? EndTypes.closed : endTypes[strokeCap], + data + }] + }) + + if (!offsetPaths) return [] + + return offsetPaths + .map(offsetPath => + new paper.Path({ + closed, + segments: simplify(offsetPath.map(point => ({ + x: point.x / scale, + y: point.y / scale + })), tolerance) + }) + ) + .map(betterSimplify(0.25)) + .filter(offsetPath => offsetPath.length) +} + +export default clipperOffset diff --git a/src/unite.ts b/src/unite.ts new file mode 100644 index 0000000..57f4000 --- /dev/null +++ b/src/unite.ts @@ -0,0 +1,41 @@ +import * as clipperLib from 'js-angusj-clipper' +import paper from 'paper' + +// @ts-ignore +paper.setup() + +enum FillTypes { + evenodd = clipperLib.PolyFillType.EvenOdd, + nonzero = clipperLib.PolyFillType.NonZero +} + +const clipperOffset = (clipper: clipperLib.ClipperLibWrapper) => async (paths: paper.Path[]): Promise => { + const scale = 1000 + const data = paths.map(path => + path.segments.map(({ point }) => ({ x: Math.round(point.x * scale), y: Math.round(point.y * scale) })) + ) + + const { closed, fillRule } = paths[0] + + const unitedPaths = clipper.clipToPaths({ + clipType: clipperLib.ClipType.Union, + // @ts-ignore + subjectFillType: FillTypes[fillRule], + subjectInputs: [{ + closed, + data + }] + }) + + if (!unitedPaths) return [] + + return unitedPaths + .map(path => + new paper.Path({ + closed, + segments: path.map(point => ({ x: point.x / scale, y: point.y / scale })) + }) + ) +} + +export default clipperOffset \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1da622d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "commonjs", + "declaration": true, + "outDir": "./lib", + "strict": true, + "esModuleInterop": true + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/*"] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..f6be702 --- /dev/null +++ b/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": ["tslint:recommended", "tslint-config-prettier"] +} \ No newline at end of file