Add options object to boolean operations and improve handling of open paths.

This closes #1036, closes #1072, closes #1089 and closes #1121
This commit is contained in:
Jürg Lehni 2016-07-27 17:09:52 +02:00
parent a29ada8f23
commit e643338422
5 changed files with 182 additions and 99 deletions

View file

@ -30,6 +30,9 @@ var CompoundPath = PathItem.extend(/** @lends CompoundPath# */{
_serializeFields: {
children: []
},
// Enforce creation of beans, as bean getters have hidden parameters.
// See #getPathData() and #getArea below.
beans: true,
/**
* Creates a new compound path item and places it in the active layer.
@ -248,11 +251,11 @@ var CompoundPath = PathItem.extend(/** @lends CompoundPath# */{
* @bean
* @type Number
*/
getArea: function() {
getArea: function(_closed) {
var children = this._children,
area = 0;
for (var i = 0, l = children.length; i < l; i++)
area += children[i].getArea();
area += children[i].getArea(_closed);
return area;
},
@ -269,10 +272,7 @@ var CompoundPath = PathItem.extend(/** @lends CompoundPath# */{
for (var i = 0, l = children.length; i < l; i++)
length += children[i].getLength();
return length;
}
}, /** @lends CompoundPath# */{
// Enforce bean creation for getPathData(), as it has hidden parameters.
beans: true,
},
getPathData: function(_matrix, _precision) {
// NOTE: #setPathData() is defined in PathItem.
@ -285,8 +285,8 @@ var CompoundPath = PathItem.extend(/** @lends CompoundPath# */{
? _matrix.appended(mx) : _matrix, _precision));
}
return paths.join('');
}
}, /** @lends CompoundPath# */{
},
_hitTestChildren: function _hitTestChildren(point, options, viewMatrix) {
return _hitTestChildren.base.call(this, point,
// If we're not specifically asked to returns paths through

View file

@ -295,8 +295,8 @@ var Path = PathItem.extend(/** @lends Path# */{
}
}
}, /** @lends Path# */{
// Enforce bean creation for getPathData() and getArea(), as they have
// hidden parameters.
// Enforce creation of beans, as bean getters have hidden parameters.
// See #getPathData() and #getArea below.
beans: true,
getPathData: function(_matrix, _precision) {
@ -829,24 +829,28 @@ var Path = PathItem.extend(/** @lends Path# */{
* @type Number
*/
getArea: function(_closed) {
// If the call overrides the 'closed' state, do not cache the result.
// This is used in tracePaths().
var cached = _closed === undefined,
area = this._area;
if (!cached || area == null) {
// Cache the area for the open path, and the the final curve separately,
// so open and closed area can be returned at almost no additional cost.
var closed = Base.pick(_closed, this._closed),
cached = this._area;
if (cached == null) {
var segments = this._segments,
count = segments.length,
closed = cached ? this._closed : _closed,
last = count - 1;
area = 0;
for (var i = 0, l = closed ? count : last; i < l; i++) {
area += Curve.getArea(Curve.getValues(
segments[i], segments[i < last ? i + 1 : 0]));
sum = 0,
close = 0;
for (var i = 0, l = segments.length; i < l; i++) {
var next = i + 1,
last = next >= l,
area = Curve.getArea(Curve.getValues(
segments[i], segments[last ? 0 : i + 1]));
if (last) {
close = area;
} else {
sum += area;
}
}
if (cached)
this._area = area;
cached = this._area = [sum, close];
}
return area;
return cached[0] + (closed ? cached[1] : 0);
},
/**

View file

@ -50,52 +50,52 @@ PathItem.inject(new function() {
* remove empty curves, #resolveCrossings() to resolve self-intersection
* make sure all paths have correct winding direction.
*/
function preparePath(path, closed) {
function preparePath(path, resolve) {
var res = path.clone(false).reduce({ simplify: true })
.transform(null, true, true);
if (closed)
res.setClosed(true);
return closed
return resolve
? res.resolveCrossings().reorient(res.getFillRule() === 'nonzero')
: res;
}
function createResult(ctor, paths, reduce, path1, path2) {
function createResult(ctor, paths, reduce, path1, path2, options) {
var result = new ctor(Item.NO_INSERT);
result.addChildren(paths, true);
// See if the item can be reduced to just a simple Path.
if (reduce)
result = result.reduce({ simplify: true });
// Insert the resulting path above whichever of the two paths appear
// further up in the stack.
result.insertAbove(path2 && path1.isSibling(path2)
&& path1.getIndex() < path2.getIndex() ? path2 : path1);
if (!(options && options.insert === false)) {
// Insert the resulting path above whichever of the two paths appear
// further up in the stack.
result.insertAbove(path2 && path1.isSibling(path2)
&& path1.getIndex() < path2.getIndex() ? path2 : path1);
}
// Copy over the input path attributes, excluding matrix and we're done.
result.copyAttributes(path1, true);
return result;
}
function computeBoolean(path1, path2, operation) {
// Retrieve the operator lookup table for winding numbers.
var operator = operators[operation];
function computeBoolean(path1, path2, operation, options) {
// Only support subtract and intersect operations when computing stroke
// based boolean operations.
if (options && options.stroke &&
/^(subtract|intersect)$/.test(operation))
return computeStrokeBoolean(path1, path2, operation === 'subtract');
// We do not modify the operands themselves, but create copies instead,
// fas produced by the calls to preparePath().
// NOTE: The result paths might not belong to the same type i.e.
// subtract(A:Path, B:Path):CompoundPath etc.
var _path1 = preparePath(path1, true),
_path2 = path2 && path1 !== path2 && preparePath(path2, true),
// Retrieve the operator lookup table for winding numbers.
operator = operators[operation];
// Add a simple boolean property to check for a given operation,
// e.g. `if (operator.unite)`
operator[operation] = true;
// If path1 is open, delegate to computeOpenBoolean().
// NOTE: Do not access private _closed property here, since path1 may
// be a CompoundPath.
if (!path1.isClosed())
return computeOpenBoolean(path1, path2, operator);
// We do not modify the operands themselves, but create copies instead,
// fas produced by the calls to preparePath().
// Note that the result paths might not belong to the same type
// i.e. subtraction(A:Path, B:Path):CompoundPath etc.
var _path1 = preparePath(path1, true),
_path2 = path2 && path1 !== path2 && preparePath(path2, true);
// Give both paths the same orientation except for subtraction
// and exclusion, where we need them at opposite orientation.
if (_path2 && (operator.subtract || operator.exclude)
^ (_path2.isClockwise() ^ _path1.isClockwise()))
^ (_path2.isClockwise(true) ^ _path1.isClockwise(true)))
_path2.reverse();
// Split curves at crossings on both paths. Note that for self-
// intersection, path2 is null and getIntersections() handles it.
@ -183,26 +183,20 @@ PathItem.inject(new function() {
paths = tracePaths(segments, operator);
}
return createResult(CompoundPath, paths, true, path1, path2);
return createResult(CompoundPath, paths, true, path1, path2, options);
}
function computeOpenBoolean(path1, path2, operator) {
// Only support subtract and intersect operations between an open
// and a closed path.
if (!path2 || !operator.subtract && !operator.intersect) {
throw new Error('Boolean operations on open paths only support ' +
'subtraction and intersection with another path.');
}
var _path1 = preparePath(path1, false),
_path2 = preparePath(path2, false),
function computeStrokeBoolean(path1, path2, subtract) {
var _path1 = preparePath(path1),
_path2 = preparePath(path2),
crossings = _path1.getCrossings(_path2),
sub = operator.subtract,
paths = [];
function addPath(path) {
// Simple see if the point halfway across the open path is inside
// path2, and include / exclude the path based on the operator.
if (_path2.contains(path.getPointAt(path.getLength() / 2)) ^ sub) {
if (_path2.contains(path.getPointAt(path.getLength() / 2))
^ subtract) {
paths.unshift(path);
return true;
}
@ -358,13 +352,17 @@ PathItem.inject(new function() {
* {@link CompoundPath#getCurves()}
* @param {Number} [dir=0] the direction in which to determine the
* winding contribution, `0`: in x-direction, `1`: in y-direction
* @param {Boolean} [closed=false] determines how areas should be closed
* when a curve is part of an open path, `false`: area is closed with a
* straight line, `true`: area is closed taking the handles of the first
* and last segment into account
* @param {Boolean} [dontFlip=false] controls whether the algorithm is
* allowed to flip direction if it is deemed to produce better results
* @return {Object} an object containing the calculated winding number, as
* well as an indication whether the point was situated on the contour
* @private
*/
function getWinding(point, curves, dir, dontFlip) {
function getWinding(point, curves, dir, closed, dontFlip) {
var epsilon = /*#=*/Numerical.WINDING_EPSILON,
// Determine the index of the abscissa and ordinate values in the
// curve values arrays, based on the direction:
@ -465,7 +463,7 @@ PathItem.inject(new function() {
// again with flipped direction and return that result instead.
return !dontFlip && a > paL && a < paR
&& Curve.getTangent(v, t)[dir ? 'x' : 'y'] === 0
&& getWinding(point, curves, dir ? 0 : 1, true);
&& getWinding(point, curves, dir ? 0 : 1, closed, true);
}
function handleCurve(v) {
@ -505,14 +503,22 @@ PathItem.inject(new function() {
// We're on a new (sub-)path, so we need to determine values of
// the last non-horizontal curve on this path.
vPrev = null;
// If the path is not closed, connect the end points with a
// straight curve, just like how filling open paths works.
// If the path is not closed, connect the first and last segment
// based on the value of `closed`:
// - `false`: Connect with a straight curve, just like how
// filling open paths works.
// - `true`: Connect with a curve that takes the segment handles
// into account, just like how closed paths behave.
if (!path._closed) {
var p1 = path.getLastCurve().getPoint2(),
p2 = curve.getPoint1(),
var s1 = path.getLastCurve().getSegment2(),
s2 = curve.getSegment1(),
p1 = s1._point,
p2 = s2._point,
x1 = p1._x, y1 = p1._y,
x2 = p2._x, y2 = p2._y;
vClose = [x1, y1, x1, y1, x2, y2, x2, y2];
vClose = closed
? Curve.getValues(s1, s2)
: [x1, y1, x1, y1, x2, y2, x2, y2];
// This closing curve is a potential candidate for the last
// non-horizontal curve.
if (vClose[io] !== vClose[io + 6]) {
@ -555,7 +561,7 @@ PathItem.inject(new function() {
// for clockwise and [-1,+1] for counter-clockwise paths.
// If the ray is cast in y direction (dir == 1), the
// windings always have opposite sign.
var add = path.isClockwise() ^ dir ? 1 : -1;
var add = path.isClockwise(closed) ^ dir ? 1 : -1;
windingL += add;
windingR -= add;
onPathWinding += add;
@ -626,10 +632,12 @@ PathItem.inject(new function() {
// contributing to the second operand and is outside the
// first operand.
winding = !(operator.subtract && path2 && (
path === path1 && path2._getWinding(pt, dir).winding ||
path === path2 && !path1._getWinding(pt, dir).winding))
? getWinding(pt, curves, dir)
: { winding: 0 };
path === path1 &&
path2._getWinding(pt, dir, true).winding ||
path === path2 &&
!path1._getWinding(pt, dir, true).winding))
? getWinding(pt, curves, dir, true)
: { winding: 0 };
break;
}
length -= curveLength;
@ -734,6 +742,7 @@ PathItem.inject(new function() {
for (var i = 0, l = segments.length; i < l; i++) {
var path = null,
finished = false,
closed = true,
seg = segments[i],
inter = seg._intersection,
handleIn;
@ -793,18 +802,21 @@ PathItem.inject(new function() {
seg = other;
}
}
// Bail out if we're done, or if we encounter an already visited
// next segment.
if (finished || seg._visited) {
// It doesn't hurt to set again to share some code.
if (finished) {
seg._visited = true;
// If we end up on the first or last segment of an operand,
// copy over its closed state, to support mixed open/closed
// scenarios as described in #1036
if (seg.isFirst() || seg.isLast())
closed = seg._path._closed;
break;
}
// If there are only valid overlaps and we encounter and invalid
// segment, bail out immediately. Otherwise we need to be more
// tolerant due to complex situations of crossing,
// see findBestIntersection()
if (seg._path._validOverlapsOnly && !isValid(seg))
// If a visited or invalid segment is encountered, bail out
// immediately. But if there aren't only valid overlaps, be more
// tolerant due to complex crossing situations.
// See findBestIntersection()
if (seg._visited
|| seg._path._validOverlapsOnly && !isValid(seg))
break;
if (!path) {
path = new Path(Item.NO_INSERT);
@ -830,7 +842,7 @@ PathItem.inject(new function() {
// Finish with closing the paths, and carrying over the last
// handleIn to the first segment.
path.firstSegment.setHandleIn(handleIn);
path.setClosed(true);
path.setClosed(closed);
} else if (path) {
// Only complain about open paths if they would actually contain
// an area when closed. Open paths that can silently discarded
@ -873,39 +885,58 @@ PathItem.inject(new function() {
* winding contribution, `0`: in x-direction, `1`: in y-direction
* @return {Number} the winding number
*/
_getWinding: function(point, dir) {
return getWinding(point, this.getCurves(), dir);
_getWinding: function(point, dir, closed) {
return getWinding(point, this.getCurves(), dir, closed);
},
/**
* {@grouptitle Boolean Path Operations}
*
* Merges the geometry of the specified path with this path's geometry
* Unites the geometry of the specified path with this path's geometry
* and returns the result as a new path item.
*
* @option [options.insert=true] {Boolean} whether the resulting item
* should be inserted back into the scene graph, above both paths
* involved in the operation
*
* @param {PathItem} path the path to unite with
* @param {Object} [options] the boolean operation options
* @return {PathItem} the resulting path item
*/
unite: function(path) {
return computeBoolean(this, path, 'unite');
unite: function(path, options) {
return computeBoolean(this, path, 'unite', options);
},
/**
* Intersects the geometry of the specified path with this path's
* geometry and returns the result as a new path item.
*
* @option [options.insert=true] {Boolean} whether the resulting item
* should be inserted back into the scene graph, above both paths
* involved in the operation
* @option [options.stroke=false] {Boolean} whether the operation should
* be performed on the stroke or on the fill of the first path
*
* @param {PathItem} path the path to intersect with
* @param {Object} [options] the boolean operation options
* @return {PathItem} the resulting path item
*/
intersect: function(path) {
return computeBoolean(this, path, 'intersect');
intersect: function(path, options) {
return computeBoolean(this, path, 'intersect', options);
},
/**
* Subtracts the geometry of the specified path from this path's
* geometry and returns the result as a new path item.
*
* @option [options.insert=true] {Boolean} whether the resulting item
* should be inserted back into the scene graph, above both paths
* involved in the operation
* @option [options.stroke=false] {Boolean} whether the operation should
* be performed on the stroke or on the fill of the first path
*
* @param {PathItem} path the path to subtract
* @param {Object} [options] the boolean operation options
* @return {PathItem} the resulting path item
*/
subtract: function(path) {
@ -916,11 +947,16 @@ PathItem.inject(new function() {
* Excludes the intersection of the geometry of the specified path with
* this path's geometry and returns the result as a new path item.
*
* @option [options.insert=true] {Boolean} whether the resulting item
* should be inserted back into the scene graph, above both paths
* involved in the operation
*
* @param {PathItem} path the path to exclude the intersection of
* @param {Object} [options] the boolean operation options
* @return {PathItem} the resulting group item
*/
exclude: function(path) {
return computeBoolean(this, path, 'exclude');
exclude: function(path, options) {
return computeBoolean(this, path, 'exclude', options);
},
/**
@ -929,12 +965,21 @@ PathItem.inject(new function() {
* calling {@link #subtract(path)} and {@link #subtract(path)} and
* putting the results into a new group.
*
* @option [options.insert=true] {Boolean} whether the resulting item
* should be inserted back into the scene graph, above both paths
* involved in the operation
* @option [options.stroke=false] {Boolean} whether the operation should
* be performed on the stroke or on the fill of the first path
*
* @param {PathItem} path the path to divide by
* @param {Object} [options] the boolean operation options
* @return {Group} the resulting group item
*/
divide: function(path) {
return createResult(Group, [this.subtract(path),
this.intersect(path)], true, this, path);
divide: function(path, options) {
return createResult(Group, [
this.subtract(path, options),
this.intersect(path, options)
], true, this, path, options);
},
/*

View file

@ -23,6 +23,9 @@ var PathItem = Item.extend(/** @lends PathItem# */{
_class: 'PathItem',
_selectBounds: false,
_canScaleStroke: true,
// Enforce creation of beans, as bean getters have hidden parameters.
// See #isClockwise() below.
beans: true,
initialize: function PathItem() {
// Do nothing.
@ -101,8 +104,8 @@ var PathItem = Item.extend(/** @lends PathItem# */{
* @see Path#getArea()
* @see CompoundPath#getArea()
*/
isClockwise: function() {
return this.getArea() >= 0;
isClockwise: function(_closed) {
return this.getArea(_closed) >= 0;
},
setClockwise: function(clockwise) {

View file

@ -99,7 +99,7 @@ test('#719', function() {
compareBoolean(result, expected);
});
test('#757 (support for open paths)', function() {
test('#757 (path1.intersect(pat2, { stroke: true }))', function() {
var rect = new Path.Rectangle({
from: [100, 250],
to: [350, 350]
@ -116,7 +116,7 @@ test('#757 (support for open paths)', function() {
]
});
var res = line.intersect(rect);
var res = line.intersect(rect, { stroke: true });
var children = res.removeChildren();
var first = children[0];
@ -540,6 +540,37 @@ test('#973', function() {
'children orientation after calling path.resolveCrossings()');
});
test('#1036', function() {
var line1 = new Path([
[[305.10732,101.34786],[0,0],[62.9214,0]],
[[499.20274,169.42611],[-29.38716,-68.57004],[5.78922,13.50818]],
[[497.75426,221.57115],[2.90601,-13.5614],[-9.75434,45.52027]],
[[416.63976,331.65512],[31.9259,-30.40562],[-21.77284,20.73604]],
[[350.00999,391.04252],[23.92578,-18.22917],[-23.33885,17.78198]],
[[277.58633,431.59977],[27.45996,-10.67887],[-1.72805,0.67202]],
[[251.51381,437.39367],[0,5.7145],[0,0]]
]);
var line2 = new Path([
[[547.00236,88.31161],[0,0],[-1.36563,0]],
[[544.10541,85.41466],[1.29555,0.43185],[-9.83725,-3.27908]],
[[509.34205,82.51771],[10.32634,-1.29079],[-10.20055,1.27507]],
[[444.16075,97.00245],[7.10741,-4.06138],[-4.93514,2.82008]],
[[431.12449,105.69328],[4.27047,-2.13524],[-14.94798,7.47399]],
[[407.94892,175.22],[1.27008,-13.33587],[-3.16966,33.28138]],
[[399.25808,279.51008],[-5.61644,-33.69865],[1.73417,10.40499]],
[[415.19129,307.03107],[-5.98792,-8.16534],[2.74694,3.74583]],
[[432.57297,328.75817],[-3.89061,-3.29206],[2.9716,2.51443]],
[[442.71228,334.55206],[-3.01275,-2.46498],[2.39275,1.95771]],
[[448.50617,341.79443],[-2.37502,-1.97918],[39.75954,33.13295]],
[[578.86877,378.00626],[-51.65429,10.33086],[11.28627,-2.25725]],
[[612.18365,366.41848],[-10.6547,4.26188],[3.10697,-1.24279]],
[[617.97755,362.07306],[-3.19904,0],[0,0]]
]);
compareBoolean(function() { return line1.intersect(line2); },
'M424.54226,112.15158c32.89387,9.15202 61.28089,26.0555 74.66048,57.27453c5.78922,13.50818 1.45753,38.58364 -1.44848,52.14504c-8.75233,40.8442 -41.40003,72.54068 -71.07836,100.58668c-4.48065,-5.55963 -9.68976,-12.67924 -11.48461,-15.12676c-5.98792,-8.16534 -14.19904,-17.116 -15.93321,-27.52099c-5.61644,-33.69865 5.52118,-71.0087 8.69084,-104.29008c1.06648,-11.19805 6.14308,-47.34273 16.59334,-63.06842z');
});
test('#1054', function() {
var p1 = new Path({
segments: [