diff --git a/src/path/PathItem.Boolean.js b/src/path/PathItem.Boolean.js index 1ccc3b23..3472678f 100644 --- a/src/path/PathItem.Boolean.js +++ b/src/path/PathItem.Boolean.js @@ -38,10 +38,12 @@ PathItem.inject(new function() { // contribution contributes to the final result or not. They are applied // to for each segment after the paths are split at crossings. operators = { - unite: { 1: true }, - intersect: { 2: true }, - subtract: { 1: true }, - exclude: { 1: true } + unite: { '1': true, '2': true }, + intersect: { '2': true }, + subtract: { '1': true }, + // exclude only needs -1 to support reorientPaths() when there are + // no crossings. + exclude: { '1': true, '-1': true } }; /* @@ -54,8 +56,9 @@ PathItem.inject(new function() { var res = path.clone(false).reduce({ simplify: true }) .transform(null, true, true); return resolve - ? res.resolveCrossings().reorient(res.getFillRule() === 'nonzero') - : res; + ? res.resolveCrossings().reorient( + res.getFillRule() === 'nonzero', true) + : res; } function createResult(ctor, paths, reduce, path1, path2, options) { @@ -107,37 +110,6 @@ PathItem.inject(new function() { curves = [], paths; - // When there are no crossings, and the two paths are not contained - // within each other, the result can be known ahead of tracePaths(), - // largely simplifying the processing required: - if (!crossings.length) { - // If we have two operands, check their bounds to find cases where - // one path is fully contained in another. These cases cannot be - // simplified, we still need tracePaths() for them. - var ok = true; - if (paths2) { - for (var i1 = 0, l1 = paths1.length; i1 < l1 && ok; i1++) { - var bounds1 = paths1[i1].getBounds(); - for (var i2 = 0, l2 = paths2.length; i2 < l2 && ok; i2++) { - var bounds2 = paths2[i2].getBounds(); - // If either of the bounds fully contains the other, - // skip the simple approach and delegate to tracePaths() - ok = !bounds1._containsRectangle(bounds2) && - !bounds2._containsRectangle(bounds1); - } - } - } - if (ok) { - // See #1113 for a description of how to deal with operators: - paths = operator.unite || operator.exclude ? [_path1, _path2] - : operator.subtract ? [_path1] - // No result, but let's return an empty path to keep - // chainability and transfer styles to the result. - : operator.intersect ? [new Path(Item.NO_INSERT)] - : null; - } - } - function collect(paths) { for (var i = 0, l = paths.length; i < l; i++) { var path = paths[i]; @@ -149,7 +121,7 @@ PathItem.inject(new function() { } } - if (!paths) { + if (crossings.length) { // Collect all segments and curves of both involved operands. collect(paths1); if (paths2) @@ -165,7 +137,7 @@ PathItem.inject(new function() { for (var i = 0, l = segments.length; i < l; i++) { var segment = segments[i], inter = segment._intersection; - if (segment._winding == null) { + if (!segment._winding) { propagateWinding(segment, _path1, _path2, curves, operator); } // See if all encountered segments in a path are overlaps. @@ -173,6 +145,13 @@ PathItem.inject(new function() { segment._path._overlapsOnly = false; } paths = tracePaths(segments, operator); + } else { + // When there are no crossings, the result can be determined through + // a much faster call to reorientPaths(): + paths = reorientPaths(paths2 ? paths1.concat(paths2) : paths1, + function(w) { + return !!operator[w]; + }); } return createResult(CompoundPath, paths, true, path1, path2, options); @@ -249,6 +228,87 @@ PathItem.inject(new function() { curves[i].clearHandles(); } + /** + * Reorients the specified paths. + * + * @param {Item[]} paths the paths of which the orientation needs to be + * reoriented + * @param {Function} isInside determines if the inside of a path is filled. + * For non-zero fill rule this function would be implemented as follows: + * + * function isInside(w) { + * return w != 0; + * } + * @param {Boolean} [clockwise] if provided, the orientation of the root + * paths will be set to the orientation specified by `clockwise`, + * otherwise the orientation of the largest root child is used. + * @returns {Item[]} the reoriented paths + */ + function reorientPaths(paths, isInside, clockwise) { + var length = paths && paths.length; + if (length) { + var lookup = Base.each(paths, function (path, i) { + // Build a lookup table with information for each path's + // original index and winding contribution. + this[path._id] = { + container: null, + winding: path.isClockwise() ? 1 : -1, + index: i + }; + }, {}), + // Now sort the paths by their areas, from large to small. + sorted = paths.slice().sort(function (a, b) { + return abs(b.getArea()) - abs(a.getArea()); + }), + // Get reference to the first, largest path and insert it + // already. + first = sorted[0]; + if (clockwise == null) + clockwise = first.isClockwise(); + // Now determine the winding for each path, from large to small. + for (var i = 0; i < length; i++) { + var path1 = sorted[i], + entry1 = lookup[path1._id], + point = path1.getInteriorPoint(), + containerWinding = 0; + for (var j = i - 1; j >= 0; j--) { + var path2 = sorted[j]; + // As we run through the paths from largest to smallest, for + // any current path, all potentially containing paths have + // already been processed and their orientation fixed. + // To achieve correct orientation of contained paths based + // on winding, we have to find one containing path with + // different "insideness" and set opposite orientation. + if (path2.contains(point)) { + var entry2 = lookup[path2._id]; + containerWinding = entry2.winding; + entry1.winding += containerWinding; + entry1.container = entry2.exclude ? entry2.container + : path2; + break; + } + } + // Only keep paths if the "insideness" changes when crossing the + // path, e.g. the inside of the path is filled and the outside + // is not, or vice versa. + if (isInside(entry1.winding) === isInside(containerWinding)) { + entry1.exclude = true; + // No need to delete excluded entries. Setting to null is + // enough, as #setChildren() can handle arrays with gaps. + paths[entry1.index] = null; + } else { + // If the containing path is not excluded, we're done + // searching for the orientation defining path. + var container = entry1.container; + path1.setClockwise(container ? !container.isClockwise() + : clockwise); + } + } + } + return paths; + } + + /** * Divides the path-items at the given locations. * @@ -627,7 +687,6 @@ PathItem.inject(new function() { windingL: windingL, windingR: windingR, quality: quality, - onContour: !windingL ^ !windingR, onPath: onPath }; } @@ -715,11 +774,14 @@ PathItem.inject(new function() { function isValid(seg) { var winding; return !!(seg && !seg._visited && (!operator - || operator[(winding = seg._winding || {}).winding] - // Unite operations need special handling of segments with a - // winding contribution of two (part of both involved areas) - // but which are also part of the contour of the result. - || operator.unite && winding.onContour)); + || operator[(winding = seg._winding).winding] + // Unite operations need special handling of segments + // with a winding contribution of two (part of both + // areas), which are only valid if they are part of the + // result's contour, not contained inside another area. + && !(operator.unite && winding.winding === 2 + // No contour if both windings are non-zero. + && winding.windingL && winding.windingR))); } function isStart(seg) { @@ -1163,78 +1225,22 @@ PathItem.inject(new function() { * @param {Boolean} [nonZero=false] controls if the non-zero fill-rule * is to be applied, by counting the winding of each nested path and * discarding sub-paths that do not contribute to the final result + * @param {Boolean} [clockwise] if provided, the orientation of the root + * paths will be set to the orientation specified by `clockwise`, + * otherwise the orientation of the largest root child is used. * @return {PahtItem} a reference to the item itself, reoriented */ - reorient: function(nonZero) { - var children = this._children, - length = children && children.length; - if (length > 1) { - // Build a lookup table with information for each path's - // original index and winding contribution. - var lookup = Base.each(children, function(path, i) { - this[path._id] = { - winding: path.isClockwise() ? 1 : -1, - index: i - }; - }, {}), - // Now sort the paths by their areas, from large to small. - sorted = this.removeChildren().sort(function (a, b) { - return abs(b.getArea()) - abs(a.getArea()); - }), - // Get reference to the first, largest path and insert it - // already. - first = sorted[0], - paths = []; - // Always insert paths at their original index. With exclusion, - // this produces null entries, but #setChildren() handles those. - paths[lookup[first._id].index] = first; - // Walk through the sorted paths, from largest to smallest. - // Skip the first path, as it is already added. - for (var i1 = 1; i1 < length; i1++) { - var path1 = sorted[i1], - entry1 = lookup[path1._id], - point = path1.getInteriorPoint(), - isContained = false, - container = null, - exclude = false; - for (var i2 = i1 - 1; i2 >= 0 && !container; i2--) { - var path2 = sorted[i2]; - // We run through the paths from largest to smallest, - // meaning that for any current path, all potentially - // containing paths have already been processed and - // their orientation has been fixed. Since we want to - // achieve alternating orientation of contained paths, - // all we have to do is to find one include path that - // contains the current path, and then set the - // orientation to the opposite of the containing path. - if (path2.contains(point)) { - var entry2 = lookup[path2._id]; - if (nonZero && !isContained) { - entry1.winding += entry2.winding; - // Remove path if rule is nonzero and winding - // of path and containing path is not zero. - if (entry1.winding && entry2.winding) { - exclude = entry1.exclude = true; - break; - } - } - isContained = true; - // If the containing path is not excluded, we're - // done searching for the orientation defining path. - container = !entry2.exclude && path2; - } - } - if (!exclude) { - // Set to the opposite orientation of containing path, - // or the same orientation as the first path if the path - // is not contained in any other path. - path1.setClockwise(container - ? !container.isClockwise() - : first.isClockwise()); - paths[entry1.index] = path1; - } - } - this.setChildren(paths); + reorient: function(nonZero, clockwise) { + var children = this._children; + if (children && children.length) { + this.setChildren(reorientPaths(this.removeChildren(), + function(w) { + // Handle both even-odd and non-zero rule. + return !!(nonZero ? w : w & 1); + }, + clockwise)); + } else if (clockwise !== undefined) { + this.setClockwise(clockwise); } return this; }, diff --git a/test/tests/Path_Boolean.js b/test/tests/Path_Boolean.js index e9c5df2e..34fe1780 100644 --- a/test/tests/Path_Boolean.js +++ b/test/tests/Path_Boolean.js @@ -12,6 +12,84 @@ QUnit.module('Path Boolean Operations'); +function testOperations(path1, path2, results) { + compareBoolean(function() { return path1.unite(path2); }, results[0]); + compareBoolean(function() { return path2.unite(path1); }, results[0]); + compareBoolean(function() { return path1.subtract(path2); }, results[1]); + compareBoolean(function() { return path2.subtract(path1); }, results[2]); + compareBoolean(function() { return path1.intersect(path2); }, results[3]); + compareBoolean(function() { return path2.intersect(path1); }, results[3]); + compareBoolean(function() { return path1.exclude(path2); }, results[4]); + compareBoolean(function() { return path2.exclude(path1); }, results[4]); + +} + +test('Boolean operations without crossings', function() { + var path1 = new Path.Rectangle({ + point: [0, 0], + size: [200, 200] + }); + + var path2 = new Path.Rectangle({ + point: [50, 50], + size: [100, 100] + }); + + var path3 = new Path.Rectangle({ + point: [250, 50], + size: [100, 100] + }); + + testOperations(path1, path2, [ + 'M0,200v-200h200v200z', // path1.unite(path2); + 'M0,200v-200h200v200zM150,150v-100h-100v100z', // path1.subtract(path2); + '', // path2.subtract(path1); + 'M50,150v-100h100v100z', // path1.intersect(path2); + 'M0,200v-200h200v200zM150,150v-100h-100v100z' // path1.exclude(path2); + ]); + + testOperations(path1, path3, [ + 'M0,200v-200h200v200zM250,150v-100h100v100z', // path1.unite(path3); + 'M0,200v-200h200v200z', // path1.subtract(path3); + 'M350,150v-100h-100v100z', // path3.subtract(path1); + '', // path1.intersect(path3); + 'M0,200v-200h200v200zM250,150v-100h100v100z' // path1.exclude(path3); + ]); +}); + +test('frame.intersect(rect)', function() { + var frame = new CompoundPath(); + frame.addChild(new Path.Rectangle(new Point(140, 10), [100, 300])); + frame.addChild(new Path.Rectangle(new Point(150, 80), [50, 80])); + var rect = new Path.Rectangle(new Point(50, 50), [100, 150]); + + compareBoolean(function() { return frame.intersect(rect); }, + 'M140,50l10,0l0,150l-10,0z'); +}); + +test('PathItem#resolveCrossings()', function() { + var paths = [ + 'M100,300l0,-50l50,-50l-50,0l150,0l-150,0l50,0l-50,0l100,0l-100,0l0,-100l200,0l0,200z', + 'M50,300l0,-150l50,25l0,-75l200,0l0,200z M100,200l50,0l-50,-25z', + 'M330.1,388.5l-65,65c0,0 -49.1,-14.5 -36.6,-36.6c12.5,-22.1 92.4,25.1 92.4,25.1c0,0 -33.3,-73.3 -23.2,-85.9c10,-12.8 32.4,32.4 32.4,32.4z', + 'M570,290l5.8176000300452415,33.58556812220928l-28.17314339506561,-14.439003967264455l31.189735425395614,-4.568209255479985c-5.7225406635552645e-9,-3.907138079739525e-8 -59.366611385062015,8.695139599513823 -59.366611385062015,8.695139599513823z', + 'M228.26666666666668,222.72h55.46666666666667c3.05499999999995,0 5.546666666666624,2.4916666666666742 5.546666666666624,5.546666666666681v55.46666666666667c0,3.05499999999995 -2.4916666666666742,5.546666666666624 -5.546666666666624,5.546666666666624h-55.46666666666667c-3.055000000000007,0 -5.546666666666681,-2.4916666666666742 -5.546666666666681,-5.546666666666624v-55.46666666666667c0,-3.055000000000007 2.4916666666666742,-5.546666666666681 5.546666666666681,-5.546666666666681zM283.73333399705655,289.2799999999998c-2.212411231994338e-7,1.1368683772161603e-13 2.212409526691772e-7,0 0,0z' + ]; + var results = [ + 'M100,300l0,-50l50,-50l-50,0l0,-100l200,0l0,200z', + 'M50,300l0,-150l50,25l0,-75l200,0l0,200z M100,200l50,0l-50,-25z', + 'M291.85631,426.74369l-26.75631,26.75631c0,0 -49.1,-14.5 -36.6,-36.6c7.48773,-13.23831 39.16013,-1.61018 63.35631,9.84369z M330.1,388.5l-22.09831,22.09831c-8.06306,-21.54667 -15.93643,-47.46883 -10.30169,-54.49831c10,-12.8 32.4,32.4 32.4,32.4z M320.9,442c0,0 -12.84682,-7.58911 -29.04369,-15.25631l16.14539,-16.14539c6.38959,17.07471 12.89831,31.40169 12.89831,31.40169z', + 'M570,290l5.8176,33.58557l-28.17314,-14.439c-14.32289,2.0978 -28.17688,4.12693 -28.17688,4.12693z', + 'M228.26666666666668,222.72h55.46666666666667c3.05499999999995,0 5.546666666666624,2.4916666666666742 5.546666666666624,5.546666666666681v55.46666666666667c0,3.05499999999995 -2.4916666666666742,5.546666666666624 -5.546666666666624,5.546666666666624h-55.46666666666667c-3.055000000000007,0 -5.546666666666681,-2.4916666666666742 -5.546666666666681,-5.546666666666624v-55.46666666666667c0,-3.055000000000007 2.4916666666666742,-5.546666666666681 5.546666666666681,-5.546666666666681z' + ]; + for (var i = 0; i < paths.length; i++) { + var path = PathItem.create(paths[i]), + result = PathItem.create(results[i]); + path.fillRule = 'evenodd'; + compareBoolean(path.resolveCrossings(), result, 'path.resolveCrossings(); // Test ' + (i + 1)); + } +}); + test('#541', function() { var shape0 = new Path.Rectangle({ insert: false, @@ -794,39 +872,6 @@ test('#1239', function() { 'M923.0175,265.805c21.3525,23.505 33.2725,54.305 33.2725,86.065c0,0.01833 0,0.03667 -0.00001,0.055h0.00001c-0.00005,0.10258 -0.00022,0.20515 -0.00052,0.3077c-0.06338,22.18242 -5.9393,43.88534 -16.78017,62.94682c-4.63138,8.14369 -10.16899,15.8051 -16.54682,22.81548l-11.8125,-10.75c8.97181,-9.86302 16.01692,-21.11585 20.93099,-33.22212c5.34364,-13.16533 8.16725,-27.34044 8.20856,-41.83592c0.0003,-0.10564 0.00044,-0.21129 0.00044,-0.31697c0,-27.9075 -10.32,-54.655 -29.0875,-75.315z'); }); -test('frame.intersect(rect);', function() { - var frame = new CompoundPath(); - frame.addChild(new Path.Rectangle(new Point(140, 10), [100, 300])); - frame.addChild(new Path.Rectangle(new Point(150, 80), [50, 80])); - var rect = new Path.Rectangle(new Point(50, 50), [100, 150]); - - compareBoolean(function() { return frame.intersect(rect); }, - 'M140,50l10,0l0,150l-10,0z'); -}); - -test('PathItem#resolveCrossings()', function() { - var paths = [ - 'M100,300l0,-50l50,-50l-50,0l150,0l-150,0l50,0l-50,0l100,0l-100,0l0,-100l200,0l0,200z', - 'M50,300l0,-150l50,25l0,-75l200,0l0,200z M100,200l50,0l-50,-25z', - 'M330.1,388.5l-65,65c0,0 -49.1,-14.5 -36.6,-36.6c12.5,-22.1 92.4,25.1 92.4,25.1c0,0 -33.3,-73.3 -23.2,-85.9c10,-12.8 32.4,32.4 32.4,32.4z', - 'M570,290l5.8176000300452415,33.58556812220928l-28.17314339506561,-14.439003967264455l31.189735425395614,-4.568209255479985c-5.7225406635552645e-9,-3.907138079739525e-8 -59.366611385062015,8.695139599513823 -59.366611385062015,8.695139599513823z', - 'M228.26666666666668,222.72h55.46666666666667c3.05499999999995,0 5.546666666666624,2.4916666666666742 5.546666666666624,5.546666666666681v55.46666666666667c0,3.05499999999995 -2.4916666666666742,5.546666666666624 -5.546666666666624,5.546666666666624h-55.46666666666667c-3.055000000000007,0 -5.546666666666681,-2.4916666666666742 -5.546666666666681,-5.546666666666624v-55.46666666666667c0,-3.055000000000007 2.4916666666666742,-5.546666666666681 5.546666666666681,-5.546666666666681zM283.73333399705655,289.2799999999998c-2.212411231994338e-7,1.1368683772161603e-13 2.212409526691772e-7,0 0,0z' - ]; - var results = [ - 'M100,300l0,-50l50,-50l-50,0l0,-100l200,0l0,200z', - 'M50,300l0,-150l50,25l0,-75l200,0l0,200z M100,200l50,0l-50,-25z', - 'M291.85631,426.74369l-26.75631,26.75631c0,0 -49.1,-14.5 -36.6,-36.6c7.48773,-13.23831 39.16013,-1.61018 63.35631,9.84369z M330.1,388.5l-22.09831,22.09831c-8.06306,-21.54667 -15.93643,-47.46883 -10.30169,-54.49831c10,-12.8 32.4,32.4 32.4,32.4z M320.9,442c0,0 -12.84682,-7.58911 -29.04369,-15.25631l16.14539,-16.14539c6.38959,17.07471 12.89831,31.40169 12.89831,31.40169z', - 'M570,290l5.8176,33.58557l-28.17314,-14.439c-14.32289,2.0978 -28.17688,4.12693 -28.17688,4.12693z', - 'M228.26666666666668,222.72h55.46666666666667c3.05499999999995,0 5.546666666666624,2.4916666666666742 5.546666666666624,5.546666666666681v55.46666666666667c0,3.05499999999995 -2.4916666666666742,5.546666666666624 -5.546666666666624,5.546666666666624h-55.46666666666667c-3.055000000000007,0 -5.546666666666681,-2.4916666666666742 -5.546666666666681,-5.546666666666624v-55.46666666666667c0,-3.055000000000007 2.4916666666666742,-5.546666666666681 5.546666666666681,-5.546666666666681z' - ]; - for (var i = 0; i < paths.length; i++) { - var path = PathItem.create(paths[i]), - result = PathItem.create(results[i]); - path.fillRule = 'evenodd'; - compareBoolean(path.resolveCrossings(), result, 'path.resolveCrossings(); // Test ' + (i + 1)); - } -}); - test('Selected edge-cases from @hari\'s boolean-test suite', function() { var g = PathItem.create('M316.6,266.4Q332.6,266.4,343.8,272.8Q355,279.2,362,289.8Q369,300.4,372.2,313.6Q375.4,326.8,375.4,340.4Q375.4,354.8,372,369.2Q368.6,383.6,361.4,395Q354.2,406.4,342.4,413.4Q330.6,420.4,313.8,420.4Q297,420.4,285.8,413.4Q274.6,406.4,267.8,395Q261,383.6,258.2,369.6Q255.4,355.6,255.4,341.6Q255.4,326.8,258.8,313.2Q262.2,299.6,269.6,289.2Q277,278.8,288.6,272.6Q300.2,266.4,316.6,266.4Z M315,236.4Q288.2,236.4,269.8,246.6Q251.4,256.8,240.2,272.6Q229,288.4,224.2,307.8Q219.4,327.2,219.4,345.6Q219.4,366.8,225.2,385.8Q231,404.8,242.6,419Q254.2,433.2,271.4,441.6Q288.6,450,311.8,450Q331.8,450,349.6,441Q367.4,432,376.2,412.8L377,412.8L377,426.4Q377,443.6,373.6,458Q370.2,472.4,362.6,482.6Q355,492.8,343.4,498.6Q331.8,504.4,315,504.4Q306.6,504.4,297.4,502.6Q288.2,500.8,280.4,496.8Q272.6,492.8,267.2,486.4Q261.8,480,261.4,470.8L227.4,470.8Q228.2,487.6,236.2,499.2Q244.2,510.8,256.4,518Q268.6,525.2,283.6,528.4Q298.6,531.6,313,531.6Q362.6,531.6,385.8,506.4Q409,481.2,409,430.4L409,241.2L377,241.2L377,270.8L376.6,270.8Q367.4,253.6,351,245Q334.6,236.4,315,236.4Z'); var u = PathItem.create('M253,316.74Q242.25,316.74,232.77,318.39Q218.77,320.83,208.21,328.52Q197.65,336.21,191.32,349.4Q185,362.6,183.59,382.95Q182.01,405.69,189.83,423.08Q197.64,440.46,216.05,452.56L215.99,453.36L183.27,451.09L181.06,483.01L387.37,497.31L389.72,463.39L273.2,455.32Q259.23,454.35,247.72,449.74Q236.21,445.14,227.96,436.95Q219.7,428.76,215.7,417.05Q211.7,405.35,212.78,389.78Q214.14,370.23,226.09,359.83Q236.68,350.61,252.94,350.61Q255.02,350.61,257.19,350.76L396.85,360.44L399.2,326.52L263.53,317.12Q258.12,316.74,253,316.74Z');