pathfinder: Unite / Subtract / Intersect / Exclude on shape selection

Boolean ops on 2+ selected shape layers, backed by the vendored
polygon-clipping library (29 KB minified, MIT) loaded from
src/vendor/. Adds a new "Pathfinder" details block in the sidebar
under Align & distribute.

- Unite combines all selected shapes into one outline.
- Subtract removes upper shapes from the bottom-most shape.
- Intersect keeps only the overlapping region.
- Exclude keeps only the non-overlapping regions (XOR).

Pipeline: each selected layer's pen path is sampled to a polygon
(16 samples per Bézier segment, in world inches), the chosen op
runs, and the result is rebuilt as a new pen layer that inherits
the bottom-most input layer's z-order, target, fill and stroke —
matching Illustrator's Pathfinder convention. Non-pen primitives
(rect, circle, etc.) are auto-converted to pen paths first.

Limitations baked in:
- Rotated layers aren't supported yet (we'd need to rotate the
  sampled polygon before clipping). The op refuses with a toast.
- Curves are flattened — the result is a polygon approximation
  with `fill-rule: evenodd` so multi-ring outputs (donut from a
  subtraction) render correctly. The renderer learned to honor
  shape.fillRule for this commit.
- Multi-polygon results stay rendered correctly but only the
  first polygon's outer ring is preserved in pen.nodes, so
  Direct Select editing post-op is partial.
This commit is contained in:
thePR0M3TH3AN committed 2026-06-02 08:54:26 -04:00
1 parent 9bb401bea2
commit eb9aec19d8
2 files changed
+207 -1

No files matched your search

+183 -1
View File
@@ -3746,6 +3746,19 @@
<button type="button" id="distributeY" title="Distribute vertical spacing evenly">↕ Distribute V</button>
</div>
</details>
<details id="pathfinderDetails" style="margin-top:10px">
<summary style="cursor:pointer;font-size:.82rem;font-weight:600">Pathfinder</summary>
<p class="hint">Boolean ops on 2+ selected shape layers. Result is a polygon approximation (curves get flattened). Non-pen primitives are auto-converted; rotated shapes aren’t supported yet.</p>
<div class="buttonRow">
<button type="button" id="pathfinderUnite" title="Combine all selected shapes into one">Unite</button>
<button type="button" id="pathfinderSubtract" title="Subtract upper shapes from the bottom-most one">Subtract</button>
</div>
<div class="buttonRow">
<button type="button" id="pathfinderIntersect" title="Keep only the overlapping region">Intersect</button>
<button type="button" id="pathfinderExclude" title="Keep only the non-overlapping regions">Exclude</button>
</div>
</details>
</details>
<p class="hint">Click a layer to select it. Shift/Ctrl/Cmd-click to select multiple layers, then drag any selected layer to move the set together. Text layers are editable in place.</p>
@@ -4919,6 +4932,7 @@
</div>
<script src="vendor/qrcode.min.js"></script>
<script src="vendor/polygon-clipping.min.js"></script>
<script>
const $ = (id) => document.getElementById(id);
const safe$ = (id) => { try { return $(id); } catch { return null; } };
@@ -10609,6 +10623,169 @@
return true;
}
// ---- Pathfinder: boolean ops on selected shape layers --------
//
// Backed by the vendored polygon-clipping library (window.polygonClipping).
// The library operates on MultiPolygon geometries: [polygon, ...] where
// polygon = [outerRing, innerRing, ...] and ring = [[x,y], ...]. Curves
// can't be represented exactly so we sample each Bézier segment into a
// polyline; the result of every op is therefore a polygon approximation
// (matches what Illustrator's Pathfinder also produces internally).
function _samplePenLayerToWorldRing(layer, samplesPerSeg) {
const pen = layer.shape?.pen;
if (!pen?.nodes || pen.nodes.length < 2) return null;
const nodes = pen.nodes;
const vb = layer.shape.viewBox || { x: 0, y: 0, w: (Number(layer.w)||0)*96, h: (Number(layer.h)||0)*96 };
const lw = Number(layer.w) || 0, lh = Number(layer.h) || 0;
const vbw = Math.max(0.0001, Number(vb.w) || 0.0001);
const vbh = Math.max(0.0001, Number(vb.h) || 0.0001);
const toWorld = (p) => [
(Number(layer.x) || 0) + (p.x - (Number(vb.x) || 0)) / vbw * lw,
(Number(layer.y) || 0) + (p.y - (Number(vb.y) || 0)) / vbh * lh,
];
const ring = [];
const closed = !!pen.closed;
const segCount = closed ? nodes.length : nodes.length - 1;
ring.push(toWorld(nodes[0]));
for (let i = 0; i < segCount; i++) {
const a = nodes[i], b = nodes[(i + 1) % nodes.length];
const inner = sampleSegment(a, b, samplesPerSeg);
for (const s of inner) ring.push(toWorld(s));
ring.push(toWorld(b));
}
// polygon-clipping wants closed rings to end at the start point.
// For an open path, force-close: the user picked Pathfinder which only
// makes sense for filled regions, so treat open paths as polylines
// that get closed by a straight segment.
if (ring.length > 0) {
const first = ring[0], last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
}
return ring;
}
function layerToWorldMultiPolygon(layer, samplesPerSeg = 16) {
const ring = _samplePenLayerToWorldRing(layer, samplesPerSeg);
if (!ring || ring.length < 4) return null;
return [[ring]];
}
function worldMultiPolygonToShapeLayer(mp, attrs) {
if (!mp || !mp.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const poly of mp) {
for (const ring of poly) {
for (const [x, y] of ring) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
}
if (!Number.isFinite(minX)) return null;
const w = Math.max(0.05, maxX - minX);
const h = Math.max(0.05, maxY - minY);
const sx = 96, sy = 96;
// d-string: each ring becomes its own M..L..Z subpath. Outer +
// hole rings of a single polygon share fill via even-odd winding.
let d = '';
for (const poly of mp) {
for (const ring of poly) {
ring.forEach((pt, i) => {
const px = ((pt[0] - minX) * sx).toFixed(2);
const py = ((pt[1] - minY) * sy).toFixed(2);
d += (i === 0 ? `M ${px} ${py}` : ` L ${px} ${py}`);
});
d += ' Z';
}
}
// Pen.nodes for the first polygon's outer ring so the result is
// still editable with the Direct Select / Pen tools.
const firstRing = mp[0][0];
const ringForNodes = firstRing[0][0] === firstRing[firstRing.length - 1][0]
&& firstRing[0][1] === firstRing[firstRing.length - 1][1]
? firstRing.slice(0, -1) : firstRing;
const nodes = ringForNodes.map(([x, y]) => ({
x: (x - minX) * sx,
y: (y - minY) * sy,
hIn: null, hOut: null, kind: 'corner',
}));
return {
type: 'shape',
target: attrs?.target || (typeof currentDrawTarget === 'function' ? currentDrawTarget() : 'page'),
x: minX, y: minY, w, h,
rotate: 0, opacity: 1, z: attrs?.z ?? nextLayerZ(),
name: attrs?.name || 'Pathfinder result',
shape: {
kind: 'path',
d,
viewBox: { x: 0, y: 0, w: w * sx, h: h * sy },
pen: { closed: true, tool: 'pen', nodes },
fillRule: 'evenodd',
},
fill: attrs?.fill || { type: 'solid', color: '#3b82f6' },
stroke: attrs?.stroke || { type: 'none', color: '#000000', width: 0, dash: 'solid' },
};
}
// op: 'union' | 'intersection' | 'difference' | 'xor'
function runPathfinder(op) {
if (typeof window.polygonClipping !== 'object' || !window.polygonClipping?.union) {
toastError?.('Pathfinder library failed to load.');
return;
}
const layers = selectedLayers().filter(l => l && l.type === 'shape' && !l.locked && !l._generatedDiscLayer);
if (layers.length < 2) { toastError?.('Select at least two shape layers.'); return; }
const rotated = layers.find(l => (Number(l.rotate) || 0) !== 0);
if (rotated) { toastError?.('Pathfinder doesn’t support rotated shapes yet. Set rotation to 0 first.'); return; }
// Auto-convert any non-pen shape (rect, circle, etc.) to a pen
// path so we have nodes to sample. Bails out for SVG / multi-element layers.
for (const l of layers) {
if (!l.shape?.pen) {
const r = convertShapeToEditablePath(l);
if (!r.ok) { toastError?.(`Can't run Pathfinder on "${l.name || 'layer'}": ${r.reason}`); return; }
}
}
const polys = layers.map(l => layerToWorldMultiPolygon(l)).filter(Boolean);
if (polys.length < 2) { toastError?.('Could not sample selected paths.'); return; }
let result;
try {
if (op === 'union') result = window.polygonClipping.union(...polys);
else if (op === 'intersection') result = window.polygonClipping.intersection(...polys);
else if (op === 'difference') result = window.polygonClipping.difference(polys[0], ...polys.slice(1));
else if (op === 'xor') result = window.polygonClipping.xor(...polys);
} catch (err) {
console.warn('Pathfinder', op, 'failed:', err);
toastError?.('Pathfinder failed on these shapes — try simpler paths.');
return;
}
if (!result || !result.length) {
toastError?.('Pathfinder produced no result (shapes may not overlap).');
return;
}
pushHistory(`pathfinder ${op}`);
// Inherit style + z + target from the BOTTOM-most layer of the
// selection (matches Illustrator's Pathfinder convention).
const sortedByZ = [...layers].sort((a, b) => (Number(a.z) || 0) - (Number(b.z) || 0));
const bottom = sortedByZ[0];
const opName = { union: 'Unite', intersection: 'Intersect', difference: 'Subtract', xor: 'Exclude' }[op] || 'Pathfinder';
const newLayer = worldMultiPolygonToShapeLayer(result, {
z: bottom.z,
target: bottom.target,
fill: bottom.fill ? { ...bottom.fill } : undefined,
stroke: bottom.stroke ? { ...bottom.stroke } : undefined,
name: `${opName} result`,
});
if (!newLayer) { toastError?.('Could not build result layer.'); return; }
newLayer.id = makeLayerId();
const removeIds = new Set(layers.map(l => l.id));
state.layers = state.layers.filter(l => !removeIds.has(l.id));
state.layers.push(newLayer);
setLayerSelection([newLayer.id], newLayer.id);
render();
}
function deletePenAnchors(layer, idxList) {
const nodes = layer.shape.pen.nodes;
const sorted = Array.from(new Set(idxList))
@@ -16001,7 +16178,8 @@
const sy = vb.h > 0 ? H / vb.h : 1;
// SVG transform applies right-to-left: translate the path's bbox to
// the origin, then scale so it fills the 100×vbH shape space.
body = `<g transform="scale(${sx.toFixed(6)} ${sy.toFixed(6)}) translate(${(-vb.x).toFixed(3)} ${(-vb.y).toFixed(3)})"><path d="${escapeHtml(d)}" fill="${fillAttr}"${strokeAttr}/></g>`;
const fr = shape.fillRule === 'evenodd' ? ' fill-rule="evenodd"' : '';
body = `<g transform="scale(${sx.toFixed(6)} ${sy.toFixed(6)}) translate(${(-vb.x).toFixed(3)} ${(-vb.y).toFixed(3)})"><path d="${escapeHtml(d)}" fill="${fillAttr}"${fr}${strokeAttr}/></g>`;
}
} else if (kind === 'svg') {
// Pre-sanitized multi-element SVG content. Fills / strokes are baked
@@ -30016,6 +30194,10 @@
on('alignBottom', 'click', () => alignSelection('bottom'));
on('distributeX', 'click', () => distributeSelection('x'));
on('distributeY', 'click', () => distributeSelection('y'));
on('pathfinderUnite', 'click', () => runPathfinder('union'));
on('pathfinderSubtract', 'click', () => runPathfinder('difference'));
on('pathfinderIntersect', 'click', () => runPathfinder('intersection'));
on('pathfinderExclude', 'click', () => runPathfinder('xor'));
$('deleteLayer').addEventListener('click', deleteSelectedLayer);
+24
View File
@@ -0,0 +1,24 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).polygonClipping=e()}(this,(function(){"use strict";
/**
* splaytree v3.1.2
* Fast Splay tree for Node and browser
*
* @author Alexander Milevski <[email protected]>
* @license MIT
* @preserve
*/
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */function t(t,e){var n,r,i,o,s={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:l(0),throw:l(1),return:l(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function l(o){return function(l){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;s;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,r=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!(i=s.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){s.label=o[1];break}if(6===o[0]&&s.label<i[1]){s.label=i[1],i=o;break}if(i&&s.label<i[2]){s.label=i[2],s.ops.push(o);break}i[2]&&s.ops.pop(),s.trys.pop();continue}o=e.call(t,s)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,l])}}}var e=function(t,e){this.next=null,this.key=t,this.data=e,this.left=null,this.right=null};function n(t,e){return t>e?1:t<e?-1:0}function r(t,n,r){for(var i=new e(null,null),o=i,s=i;;){var l=r(t,n.key);if(l<0){if(null===n.left)break;if(r(t,n.left.key)<0){var h=n.left;if(n.left=h.right,h.right=n,null===(n=h).left)break}s.left=n,s=n,n=n.left}else{if(!(l>0))break;if(null===n.right)break;if(r(t,n.right.key)>0){h=n.right;if(n.right=h.left,h.left=n,null===(n=h).right)break}o.right=n,o=n,n=n.right}}return o.right=n.left,s.left=n.right,n.left=i.right,n.right=i.left,n}function i(t,n,i,o){var s=new e(t,n);if(null===i)return s.left=s.right=null,s;var l=o(t,(i=r(t,i,o)).key);return l<0?(s.left=i.left,s.right=i,i.left=null):l>=0&&(s.right=i.right,s.left=i,i.right=null),s}function o(t,e,n){var i=null,o=null;if(e){var s=n((e=r(t,e,n)).key,t);0===s?(i=e.left,o=e.right):s<0?(o=e.right,e.right=null,i=e):(i=e.left,e.left=null,o=e)}return{left:i,right:o}}function s(t,e,n,r,i){if(t){r(e+(n?"└── ":"├── ")+i(t)+"\n");var o=e+(n?" ":"│ ");t.left&&s(t.left,o,!1,r,i),t.right&&s(t.right,o,!0,r,i)}}var l=function(){function l(t){void 0===t&&(t=n),this._root=null,this._size=0,this._comparator=t}return l.prototype.insert=function(t,e){return this._size++,this._root=i(t,e,this._root,this._comparator)},l.prototype.add=function(t,n){var i=new e(t,n);null===this._root&&(i.left=i.right=null,this._size++,this._root=i);var o=this._comparator,s=r(t,this._root,o),l=o(t,s.key);return 0===l?this._root=s:(l<0?(i.left=s.left,i.right=s,s.left=null):l>0&&(i.right=s.right,i.left=s,s.right=null),this._size++,this._root=i),this._root},l.prototype.remove=function(t){this._root=this._remove(t,this._root,this._comparator)},l.prototype._remove=function(t,e,n){var i;return null===e?null:0===n(t,(e=r(t,e,n)).key)?(null===e.left?i=e.right:(i=r(t,e.left,n)).right=e.right,this._size--,i):e},l.prototype.pop=function(){var t=this._root;if(t){for(;t.left;)t=t.left;return this._root=r(t.key,this._root,this._comparator),this._root=this._remove(t.key,this._root,this._comparator),{key:t.key,data:t.data}}return null},l.prototype.findStatic=function(t){for(var e=this._root,n=this._comparator;e;){var r=n(t,e.key);if(0===r)return e;e=r<0?e.left:e.right}return null},l.prototype.find=function(t){return this._root&&(this._root=r(t,this._root,this._comparator),0!==this._comparator(t,this._root.key))?null:this._root},l.prototype.contains=function(t){for(var e=this._root,n=this._comparator;e;){var r=n(t,e.key);if(0===r)return!0;e=r<0?e.left:e.right}return!1},l.prototype.forEach=function(t,e){for(var n=this._root,r=[],i=!1;!i;)null!==n?(r.push(n),n=n.left):0!==r.length?(n=r.pop(),t.call(e,n),n=n.right):i=!0;return this},l.prototype.range=function(t,e,n,r){for(var i=[],o=this._comparator,s=this._root;0!==i.length||s;)if(s)i.push(s),s=s.left;else{if(o((s=i.pop()).key,e)>0)break;if(o(s.key,t)>=0&&n.call(r,s))return this;s=s.right}return this},l.prototype.keys=function(){var t=[];return this.forEach((function(e){var n=e.key;return t.push(n)})),t},l.prototype.values=function(){var t=[];return this.forEach((function(e){var n=e.data;return t.push(n)})),t},l.prototype.min=function(){return this._root?this.minNode(this._root).key:null},l.prototype.max=function(){return this._root?this.maxNode(this._root).key:null},l.prototype.minNode=function(t){if(void 0===t&&(t=this._root),t)for(;t.left;)t=t.left;return t},l.prototype.maxNode=function(t){if(void 0===t&&(t=this._root),t)for(;t.right;)t=t.right;return t},l.prototype.at=function(t){for(var e=this._root,n=!1,r=0,i=[];!n;)if(e)i.push(e),e=e.left;else if(i.length>0){if(e=i.pop(),r===t)return e;r++,e=e.right}else n=!0;return null},l.prototype.next=function(t){var e=this._root,n=null;if(t.right){for(n=t.right;n.left;)n=n.left;return n}for(var r=this._comparator;e;){var i=r(t.key,e.key);if(0===i)break;i<Line truncated
//# sourceMappingURL=polygon-clipping.umd.min.js.map