@pixerate/editor 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,22 +2,26 @@ import {
2
2
  HistoryManager
3
3
  } from "../chunk-QVZZGT4X.js";
4
4
  import {
5
+ PlanarMap,
5
6
  bezierPathBounds,
6
7
  bezierPathToSvg,
7
8
  closeContour,
8
9
  contourSignedArea,
9
10
  contoursToPath,
11
+ cutOutPiece,
10
12
  distanceToBezierPath,
11
13
  fitCubicWithTangents,
12
14
  fitCubics,
13
15
  flattenChain,
14
16
  formatNumber,
15
17
  gradientToSvgDef,
18
+ hatchPath,
16
19
  isStraightCubic,
17
20
  normalizeBezierPath,
18
21
  outlineStroke,
19
22
  pathBoolean,
20
23
  pathToContours,
24
+ pathfinder,
21
25
  pointInBezierPath,
22
26
  polygonBoolean,
23
27
  segmentBounds,
@@ -34,13 +38,30 @@ import {
34
38
  transformBezierPath,
35
39
  trimSegments,
36
40
  windingNumber
37
- } from "../chunk-HPEKB352.js";
41
+ } from "../chunk-3LHWTGHW.js";
38
42
 
39
43
  // src/vector/types.ts
40
44
  var VECTOR_CORNER_KINDS = ["round", "inverted", "chamfer"];
41
45
  var VECTOR_ARROWHEADS = ["triangle", "arrow", "open", "circle", "square", "bar"];
42
- var VECTOR_TOOLS = ["select", "direct", "pen", "pencil", "rect", "ellipse", "polygon", "star", "line", "scissors", "knife", "eraser", "width"];
46
+ var VECTOR_TOOLS = [
47
+ "select",
48
+ "direct",
49
+ "pen",
50
+ "pencil",
51
+ "rect",
52
+ "ellipse",
53
+ "polygon",
54
+ "star",
55
+ "line",
56
+ "scissors",
57
+ "knife",
58
+ "eraser",
59
+ "width",
60
+ "shapeBuilder",
61
+ "paintBucket"
62
+ ];
43
63
  var VECTOR_TRANSFORM_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w", "rotate"];
64
+ var VECTOR_PATHFINDER_OPERATIONS = ["divide", "trim", "merge", "crop", "outline", "minusBack"];
44
65
 
45
66
  // src/vector/corners.ts
46
67
  var EPS = 1e-9;
@@ -344,14 +365,14 @@ function mirrorWidthProfile(points) {
344
365
  function editable(points) {
345
366
  return points?.length ? points.map((p) => ({ ...p })) : [sym(0, 1), sym(1, 1)];
346
367
  }
347
- function insertWidthPoint(points, t, sides = {}, options = {}) {
368
+ function insertWidthPoint(points, t, sides2 = {}, options = {}) {
348
369
  const pts = editable(points);
349
370
  const tt = Math.min(Math.max(Number.isFinite(t) ? t : 0, 0), 1);
350
371
  const here = widthProfileAt(points?.length ? points : null, tt);
351
372
  const p = {
352
373
  t: tt,
353
- left: clean(sides.left, VECTOR_WIDTH_PROFILE_MAX_SCALE) ?? here.left,
354
- right: clean(sides.right, VECTOR_WIDTH_PROFILE_MAX_SCALE) ?? here.right
374
+ left: clean(sides2.left, VECTOR_WIDTH_PROFILE_MAX_SCALE) ?? here.left,
375
+ right: clean(sides2.right, VECTOR_WIDTH_PROFILE_MAX_SCALE) ?? here.right
355
376
  };
356
377
  const same = pts.findIndex((q) => Math.abs(q.t - tt) <= VECTOR_WIDTH_SAME_T);
357
378
  if (same >= 0 && !options.step) {
@@ -581,7 +602,7 @@ function variableStrokeRings(path, options) {
581
602
  const poly = flatten(contour.segments, contour.closed, Math.min(tol, half) / 4);
582
603
  if (!poly) continue;
583
604
  const len2 = poly.s[poly.s.length - 1];
584
- const sides = (s) => {
605
+ const sides2 = (s) => {
585
606
  const w = widthProfileAt(options.profile, s / len2);
586
607
  return [w.left * half, w.right * half];
587
608
  };
@@ -595,7 +616,7 @@ function variableStrokeRings(path, options) {
595
616
  pts.push({ p: pb, s: b, corner: false });
596
617
  const run = pts.filter((q, i) => i === 0 || dist(q.p, pts[i - 1].p) > 1e-9);
597
618
  if (run.length < 2) {
598
- const [l, r] = sides(a);
619
+ const [l, r] = sides2(a);
599
620
  const w2 = (l + r) / 2;
600
621
  if (cap === "round" && w2 > 0) rings.push([[pa[0] + w2, pa[1]], ...arcPoints(pa, 0, 2 * Math.PI, w2, w2, tol)]);
601
622
  continue;
@@ -605,7 +626,7 @@ function variableStrokeRings(path, options) {
605
626
  const dirs = [];
606
627
  for (let i = 0; i < m; i++) dirs.push(unitOf(sub2(run[i + 1].p, run[i].p)));
607
628
  const normals = dirs.map(leftNormal);
608
- const w = run.map((q) => sides(q.s));
629
+ const w = run.map((q) => sides2(q.s));
609
630
  const side = (sigma) => {
610
631
  const k = sigma > 0 ? 0 : 1;
611
632
  const S = (i) => along(run[i].p, normals[i], sigma * w[i][k]);
@@ -1474,8 +1495,8 @@ function ellipseSubpath(b) {
1474
1495
  ]
1475
1496
  };
1476
1497
  }
1477
- function polygonSubpath(b, sides = 6) {
1478
- const n = Math.max(3, Math.round(sides));
1498
+ function polygonSubpath(b, sides2 = 6) {
1499
+ const n = Math.max(3, Math.round(sides2));
1479
1500
  const cx = b.x + b.width / 2;
1480
1501
  const cy = b.y + b.height / 2;
1481
1502
  const anchors = [];
@@ -3157,13 +3178,13 @@ function loopSample(l) {
3157
3178
  }
3158
3179
  var contains2 = (l, p) => windingNumber([l.segs], p) !== 0;
3159
3180
  function groupFaces(loops) {
3160
- const usable = loops.filter((l) => l.segs.length && Math.abs(loopArea(l)) > 1e-12);
3161
- if (!usable.length) return [];
3162
- const largest = usable.reduce((a, b) => Math.abs(loopArea(b)) > Math.abs(loopArea(a)) ? b : a);
3181
+ const usable2 = loops.filter((l) => l.segs.length && Math.abs(loopArea(l)) > 1e-12);
3182
+ if (!usable2.length) return [];
3183
+ const largest = usable2.reduce((a, b) => Math.abs(loopArea(b)) > Math.abs(loopArea(a)) ? b : a);
3163
3184
  const sign = Math.sign(loopArea(largest));
3164
- const outers = usable.filter((l) => Math.sign(loopArea(l)) === sign).sort((a, b) => Math.abs(loopArea(a)) - Math.abs(loopArea(b)));
3185
+ const outers = usable2.filter((l) => Math.sign(loopArea(l)) === sign).sort((a, b) => Math.abs(loopArea(a)) - Math.abs(loopArea(b)));
3165
3186
  const faces = new Map(outers.map((o) => [o, { loops: [o] }]));
3166
- for (const hole of usable.filter((l) => Math.sign(loopArea(l)) !== sign)) {
3187
+ for (const hole of usable2.filter((l) => Math.sign(loopArea(l)) !== sign)) {
3167
3188
  const p = loopSample(hole);
3168
3189
  const parent = outers.find((o) => Math.abs(loopArea(o)) > Math.abs(loopArea(hole)) && contains2(o, p));
3169
3190
  if (parent) faces.get(parent).loops.push(hole);
@@ -3715,15 +3736,226 @@ function recognizeInkShape(points, options = {}) {
3715
3736
  return roundish && rms < 0.1 ? ellipse() : null;
3716
3737
  }
3717
3738
 
3739
+ // src/vector/builder.ts
3740
+ function vectorPathEntries(doc, roots, options = {}) {
3741
+ const out = [];
3742
+ const visit = (node, parent, inside) => {
3743
+ if (node.visible === false || node.locked && !options.locked) return;
3744
+ const picked = inside || roots === null || roots.includes(node.id);
3745
+ if (node.type === "group") {
3746
+ const m = multiplyMatrices(nodeMatrix(node), parent);
3747
+ for (const c of node.children) visit(c, m, picked);
3748
+ } else if (picked) out.push({ node, parent });
3749
+ };
3750
+ for (const n of doc.nodes) visit(n, IDENTITY_MATRIX, false);
3751
+ return out;
3752
+ }
3753
+ var usable = (sp) => sp.anchors.length > 1;
3754
+ function worldOf(entry, subpaths) {
3755
+ return vectorPathWorldBezier({ ...entry.node, subpaths }, entry.parent);
3756
+ }
3757
+ function subpathEnclosesArea(sp) {
3758
+ const contour = pathToContours(subpathsToBezier([{ ...sp, closed: true }]))[0];
3759
+ if (!contour) return false;
3760
+ const segs = closeContour(contour, true);
3761
+ const b = bezierPathBounds(contoursToPath([{ segments: segs, closed: true }]));
3762
+ if (!b) return false;
3763
+ const w = b.maxX - b.minX;
3764
+ const h = b.maxY - b.minY;
3765
+ return Math.abs(contourSignedArea(segs)) > 1e-9 * (w * w + h * h);
3766
+ }
3767
+ var paintKey = (paint) => typeof paint === "string" ? paint.trim().toLowerCase() : JSON.stringify(paint ?? null);
3768
+ function vectorPathfinderShape(entry, op) {
3769
+ const { node } = entry;
3770
+ const subpaths = node.subpaths.filter(usable);
3771
+ const closed = subpaths.map((s) => ({ ...s, closed: true }));
3772
+ const fillRule = node.fillRule ?? "nonzero";
3773
+ if (hasPaint(node.fill)) return { path: worldOf(entry, closed), fillRule, key: `fill:${paintKey(node.fill)}`, paint: "fill" };
3774
+ const open = subpaths.length > 0 && subpaths.every((s) => !s.closed);
3775
+ if (op === "divide" && open) return { path: worldOf(entry, subpaths), line: true, paint: "line" };
3776
+ if (hasPaint(node.stroke) && (node.strokeWidth ?? 1) > 0) {
3777
+ const path = vectorStrokeOutline({ ...node, subpaths }, entry.parent);
3778
+ return { path, fillRule: "nonzero", key: `fill:${paintKey(node.stroke)}`, paint: "stroke" };
3779
+ }
3780
+ return { path: worldOf(entry, closed), fillRule, key: "none", paint: "outline" };
3781
+ }
3782
+ var STROKE_KEYS = [
3783
+ "stroke",
3784
+ "strokeWidth",
3785
+ "strokeLinecap",
3786
+ "strokeLinejoin",
3787
+ "strokeDasharray",
3788
+ "strokeAlign",
3789
+ "startArrow",
3790
+ "endArrow",
3791
+ "arrowAlign",
3792
+ "startArrowScale",
3793
+ "endArrowScale",
3794
+ "strokeWidthProfile"
3795
+ ];
3796
+ function vectorResultStyle(node, paint, edge = false) {
3797
+ const { id: _id, subpaths: _s, transform: _t, ...rest } = node;
3798
+ const base = { ...rest, fillRule: "nonzero" };
3799
+ if (edge) {
3800
+ for (const k of STROKE_KEYS) delete base[k];
3801
+ const stroke = paint === "stroke" || !hasPaint(node.fill) ? node.stroke : node.fill;
3802
+ return { ...base, fill: null, stroke: stroke ?? "#000000", strokeWidth: node.strokeWidth ?? 1 };
3803
+ }
3804
+ if (paint === "stroke") {
3805
+ for (const k of STROKE_KEYS) delete base[k];
3806
+ return { ...base, fill: node.stroke ?? null, stroke: null };
3807
+ }
3808
+ return base;
3809
+ }
3810
+ function vectorShapeBuilderMap(entries) {
3811
+ const lines = [];
3812
+ const inputs = entries.map((entry) => {
3813
+ const filled = hasPaint(entry.node.fill);
3814
+ const subpaths = entry.node.subpaths.filter(usable).map((sp) => !sp.closed && filled && subpathEnclosesArea(sp) ? { ...sp, closed: true } : sp);
3815
+ lines.push(!subpaths.some((s) => s.closed));
3816
+ return { path: worldOf(entry, subpaths), fillRule: entry.node.fillRule ?? "nonzero" };
3817
+ });
3818
+ const map = PlanarMap.build(inputs);
3819
+ if (!map) return null;
3820
+ let chains = null;
3821
+ const runs = (line2) => (chains ??= map.chains()).filter((c) => lines[c.source] === line2 && !(line2 && c.closed)).map((c) => {
3822
+ const path = contoursToPath([{ segments: c.segments, closed: c.closed }]);
3823
+ return { path, segments: c.segments, closed: c.closed, source: c.source, bounds: bezierPathBounds(path) };
3824
+ });
3825
+ const pieces = lines.some(Boolean) ? runs(true) : [];
3826
+ let edges = null;
3827
+ let size = 0;
3828
+ for (const input of inputs) {
3829
+ const b = bezierPathBounds(input.path);
3830
+ if (b) size = Math.max(size, b.maxX - b.minX, b.maxY - b.minY);
3831
+ }
3832
+ return {
3833
+ entries: [...entries],
3834
+ map,
3835
+ lines,
3836
+ pieces,
3837
+ get edges() {
3838
+ return edges ??= runs(false);
3839
+ },
3840
+ tolerance: 0.2 * Math.max(size / 2e3, 0.25)
3841
+ };
3842
+ }
3843
+ function pieceNear(pieces, p, tol) {
3844
+ const point = [p.x, p.y];
3845
+ let best = null;
3846
+ pieces.forEach((piece, index) => {
3847
+ const b = piece.bounds;
3848
+ if (p.x < b.minX - tol || p.x > b.maxX + tol || p.y < b.minY - tol || p.y > b.maxY + tol) return;
3849
+ const d = distanceToBezierPath(piece.path, point);
3850
+ if (d <= tol && (!best || d < best.d)) best = { index, d };
3851
+ });
3852
+ return best;
3853
+ }
3854
+ function vectorShapeBuilderHit(m, p, lineTolerance = null) {
3855
+ if (lineTolerance !== null && lineTolerance > 0) {
3856
+ const line2 = pieceNear(m.pieces, p, lineTolerance);
3857
+ const edge = pieceNear(m.edges, p, lineTolerance);
3858
+ if (edge && (!line2 || edge.d < line2.d)) return { edge: edge.index };
3859
+ if (line2) return { line: line2.index };
3860
+ }
3861
+ const face = m.map.faceAt([p.x, p.y]);
3862
+ return face >= 0 ? { face } : null;
3863
+ }
3864
+ function samplePolyline(points, step) {
3865
+ const out = [];
3866
+ const s = Math.max(step, 1e-3);
3867
+ points.forEach((p, i) => {
3868
+ if (i > 0) {
3869
+ const a = points[i - 1];
3870
+ const n = Math.min(4e3, Math.ceil(Math.hypot(p.x - a.x, p.y - a.y) / s));
3871
+ for (let k = 1; k < n; k++) out.push({ x: a.x + (p.x - a.x) * k / n, y: a.y + (p.y - a.y) * k / n });
3872
+ }
3873
+ out.push(p);
3874
+ });
3875
+ return out;
3876
+ }
3877
+ function addBuilderHit(touched, hit) {
3878
+ if (!hit) return false;
3879
+ const [list, i] = "face" in hit ? [touched.faces, hit.face] : "line" in hit ? [touched.lines, hit.line] : [touched.edges, hit.edge];
3880
+ if (list.includes(i)) return false;
3881
+ list.push(i);
3882
+ return true;
3883
+ }
3884
+ function sides(m, piece) {
3885
+ const seg = piece.segments[Math.floor(piece.segments.length / 2)];
3886
+ const mid = segmentPoint(seg, 0.5);
3887
+ const t = segmentTangent(seg, 0.5);
3888
+ const chord = Math.hypot(seg[seg.length - 1][0] - seg[0][0], seg[seg.length - 1][1] - seg[0][1]);
3889
+ const off = Math.max(1e-6, Math.min(0.05, 0.05 * chord));
3890
+ return [m.map.faceAt([mid[0] - t[1] * off, mid[1] + t[0] * off]), m.map.faceAt([mid[0] + t[1] * off, mid[1] - t[0] * off])];
3891
+ }
3892
+ function vectorBuilderErasedEdges(m, touched) {
3893
+ if (!touched.edges.length) return [];
3894
+ const faces = new Set(touched.faces);
3895
+ return touched.edges.filter((k) => m.edges[k] && !sides(m, m.edges[k]).some((f) => f >= 0 && faces.has(f)));
3896
+ }
3897
+ function planShapeBuilder(m, touched, erase) {
3898
+ const replace = /* @__PURE__ */ new Map();
3899
+ const picked = new Set(touched.faces);
3900
+ const faces = m.map.faces;
3901
+ const sourcesOf = faces.map((_, i) => m.map.faceSources(i));
3902
+ const cuts = erase ? vectorBuilderErasedEdges(m, touched).map((k) => m.edges[k]) : [];
3903
+ m.entries.forEach((entry, i) => {
3904
+ if (m.lines[i]) {
3905
+ const own = m.pieces.flatMap((piece, k) => piece.source === i ? [k] : []);
3906
+ const keep = own.filter((k) => !touched.lines.includes(k) && !sides(m, m.pieces[k]).every((f) => f >= 0 && picked.has(f)));
3907
+ if (keep.length === own.length) return;
3908
+ replace.set(i, keep.length ? { kind: "pieces", paths: keep.map((k) => m.pieces[k].path) } : null);
3909
+ return;
3910
+ }
3911
+ const inUnion = touched.faces.some((f) => sourcesOf[f].includes(i));
3912
+ if (!inUnion && !cuts.length) return;
3913
+ let rest;
3914
+ if (inUnion) {
3915
+ const kept = faces.flatMap((_, f) => sourcesOf[f].includes(i) && !picked.has(f) ? [f] : []);
3916
+ rest = kept.length ? m.map.merge(kept) : [];
3917
+ } else rest = worldOf(entry, entry.node.subpaths.filter(usable));
3918
+ const fillCloses = hasPaint(entry.node.fill);
3919
+ let cut = false;
3920
+ for (const piece of cuts) {
3921
+ const r = rest.length ? cutOutPiece(rest, piece, fillCloses, m.tolerance) : null;
3922
+ if (r) {
3923
+ rest = r;
3924
+ cut = true;
3925
+ }
3926
+ }
3927
+ if (!inUnion && !cut) return;
3928
+ replace.set(i, rest.length ? { kind: inUnion ? "fill" : "outline", path: rest } : null);
3929
+ });
3930
+ let merged = null;
3931
+ if (!erase && touched.faces.length) {
3932
+ const path = m.map.merge(touched.faces);
3933
+ const covered = touched.faces.map((f) => sourcesOf[f]).find((s) => s.length);
3934
+ const lastShape = m.lines.lastIndexOf(false);
3935
+ const style = covered ? covered[covered.length - 1] : lastShape >= 0 ? lastShape : m.entries.length - 1;
3936
+ if (path.length) merged = { path, style, currentFill: !covered };
3937
+ }
3938
+ return { replace, merged };
3939
+ }
3940
+ function vectorFillMap(doc, gap = 0) {
3941
+ const entries = vectorPathEntries(doc, null, { locked: true });
3942
+ return PlanarMap.build(
3943
+ entries.map((e) => ({ path: worldOf(e, e.node.subpaths.filter(usable)), fills: false })),
3944
+ { gap, maxSegments: 2e4 }
3945
+ );
3946
+ }
3947
+
3718
3948
  // src/vector/controller.ts
3719
3949
  var DEFAULT_VECTOR_SNAP = { grid: 0, objects: true, guides: true, threshold: 5, angles: VECTOR_CONSTRUCTION_ANGLES };
3950
+ var VECTOR_FILL_GAP = 6;
3720
3951
  var DEFAULT_VECTOR_PATH_EDIT = {
3721
3952
  keepShapeOnDelete: true,
3722
3953
  curvatureMoves: false,
3723
3954
  pencilSmoothing: 0,
3724
3955
  recognizeShapes: false,
3725
3956
  eraserSize: 10,
3726
- pencilPressure: "pen"
3957
+ pencilPressure: "pen",
3958
+ fillGap: 0
3727
3959
  };
3728
3960
  var VECTOR_PENCIL_STRING_PX = 100;
3729
3961
  var VECTOR_STYLE_KEYS = [
@@ -3962,9 +4194,27 @@ var TOOL_LABELS = {
3962
4194
  scissors: "Scissors",
3963
4195
  knife: "Knife",
3964
4196
  eraser: "Eraser",
3965
- width: "Width"
4197
+ width: "Width",
4198
+ shapeBuilder: "Shape Builder",
4199
+ paintBucket: "Paint Bucket"
4200
+ };
4201
+ var TOOL_KEYS = {
4202
+ v: "select",
4203
+ a: "direct",
4204
+ p: "pen",
4205
+ n: "pencil",
4206
+ r: "rect",
4207
+ o: "ellipse",
4208
+ y: "polygon",
4209
+ s: "star",
4210
+ l: "line",
4211
+ c: "scissors",
4212
+ k: "knife",
4213
+ e: "eraser",
4214
+ w: "width",
4215
+ m: "shapeBuilder",
4216
+ b: "paintBucket"
3966
4217
  };
3967
- var TOOL_KEYS = { v: "select", a: "direct", p: "pen", n: "pencil", r: "rect", o: "ellipse", y: "polygon", s: "star", l: "line", c: "scissors", k: "knife", e: "eraser", w: "width" };
3968
4218
  function vectorToolLabel(tool) {
3969
4219
  return TOOL_LABELS[tool];
3970
4220
  }
@@ -3981,7 +4231,9 @@ var VECTOR_TOOL_SHORTCUTS = {
3981
4231
  scissors: "C",
3982
4232
  knife: "K",
3983
4233
  eraser: "E",
3984
- width: "W"
4234
+ width: "W",
4235
+ shapeBuilder: "M",
4236
+ paintBucket: "B"
3985
4237
  };
3986
4238
  var VECTOR_TOOL_GLYPHS = {
3987
4239
  select: "\u2196",
@@ -3996,7 +4248,9 @@ var VECTOR_TOOL_GLYPHS = {
3996
4248
  scissors: "\u2702",
3997
4249
  knife: "\u27CB",
3998
4250
  eraser: "\u232B",
3999
- width: "\u21F9"
4251
+ width: "\u21F9",
4252
+ shapeBuilder: "\u2295",
4253
+ paintBucket: "\u25CD"
4000
4254
  };
4001
4255
  var SHAPE_TOOLS = /* @__PURE__ */ new Set(["rect", "ellipse", "polygon", "star", "line"]);
4002
4256
  var BOOLEAN_OPS = {
@@ -4006,6 +4260,14 @@ var BOOLEAN_OPS = {
4006
4260
  exclude: "xor"
4007
4261
  };
4008
4262
  var BOOLEAN_LABELS = { union: "Union", subtract: "Subtract", intersect: "Intersect", exclude: "Exclude" };
4263
+ var VECTOR_PATHFINDER_LABELS = {
4264
+ divide: "Divide",
4265
+ trim: "Trim",
4266
+ merge: "Merge",
4267
+ crop: "Crop",
4268
+ outline: "Outline",
4269
+ minusBack: "Minus Back"
4270
+ };
4009
4271
  function mapTree(nodes, fn) {
4010
4272
  let changed = false;
4011
4273
  const out = [];
@@ -4070,6 +4332,13 @@ var VectorEditorController = class {
4070
4332
  hoverTargets = null;
4071
4333
  spaceHeld = false;
4072
4334
  clipboard = null;
4335
+ /** Shape Builder map of the selection, kept while the document and the selection stay the same. */
4336
+ builderCache = null;
4337
+ /** Paint bucket map of the visible outlines. */
4338
+ fillCache = null;
4339
+ builderPreview = null;
4340
+ /** What the highlight shows, to skip notifying when the pointer stays over the same face. */
4341
+ builderHoverKey = null;
4073
4342
  createId;
4074
4343
  onChange;
4075
4344
  listeners = /* @__PURE__ */ new Set();
@@ -4144,6 +4413,7 @@ var VectorEditorController = class {
4144
4413
  interaction: this.interaction,
4145
4414
  pen: this.pen,
4146
4415
  pointer: this.pointer,
4416
+ builder: this.builderPreview,
4147
4417
  canUndo: h.canUndo,
4148
4418
  canRedo: h.canRedo,
4149
4419
  undoLabel: h.undoName,
@@ -4202,6 +4472,11 @@ var VectorEditorController = class {
4202
4472
  if (this.drag?.kind === "width") return this.drag.mode === "move" ? "grabbing" : "ew-resize";
4203
4473
  if (h?.widthPoint) return h.widthPoint.part === "center" ? "grab" : "ew-resize";
4204
4474
  return h?.strokeSpot ? "copy" : "default";
4475
+ case "shapeBuilder":
4476
+ if (!this.builderPreview?.faces.length && !this.builderPreview?.lines.length) return "crosshair";
4477
+ return this.builderPreview.mode === "erase" ? "not-allowed" : "cell";
4478
+ case "paintBucket":
4479
+ return this.builderPreview ? "cell" : "crosshair";
4205
4480
  default:
4206
4481
  return "crosshair";
4207
4482
  }
@@ -4328,6 +4603,8 @@ var VectorEditorController = class {
4328
4603
  this.hover = null;
4329
4604
  this.guides = null;
4330
4605
  this.widthPoint = null;
4606
+ this.builderPreview = null;
4607
+ this.builderHoverKey = null;
4331
4608
  if (tool !== "direct") {
4332
4609
  this.anchors = [];
4333
4610
  this.editingId = null;
@@ -5115,6 +5392,255 @@ var VectorEditorController = class {
5115
5392
  return { ok: true };
5116
5393
  }
5117
5394
  // -------------------------------------------------------------------------
5395
+ // Pathfinder, Shape Builder and paint bucket
5396
+ // -------------------------------------------------------------------------
5397
+ /**
5398
+ * Pathfinder: Divide, Trim, Merge, Crop, Outline or Minus Back over the selected (or given)
5399
+ * paths, in document space (rotated nodes and groups included, live corners cut). Unfilled
5400
+ * stroked paths take part as the area their stroke paints (Divide: open ones cut instead).
5401
+ * The results replace the operands at the place of the top-most one, grouped when there are
5402
+ * several; each keeps the style of the operand whose area it is. Returns the new path ids,
5403
+ * or an empty list when nothing came out.
5404
+ */
5405
+ pathfinder(op, options = {}) {
5406
+ const roots = options.ids ?? this.selectedRoots();
5407
+ const entries = vectorPathEntries(this.doc, roots);
5408
+ if (entries.length < (op === "divide" || op === "outline" ? 1 : 2)) return [];
5409
+ const shapes = entries.map((e) => vectorPathfinderShape(e, op));
5410
+ const result = pathfinder(op, shapes);
5411
+ if (result.error || !result.pieces.length) return [];
5412
+ const last = entries[entries.length - 1].node.id;
5413
+ const topRoot = [...roots].reverse().find((r) => {
5414
+ const n = this.getNode(r);
5415
+ return !!n && !!findVectorNode([n], last);
5416
+ }) ?? last;
5417
+ const nodes = [];
5418
+ for (const piece of result.pieces) {
5419
+ const subpaths = bezierToSubpaths(this.intoParentSpace(topRoot, piece.path)).map((sp) => piece.open ? { ...sp, closed: false } : sp);
5420
+ if (!subpaths.length) continue;
5421
+ const source = entries[piece.source].node;
5422
+ const style = vectorResultStyle(source, shapes[piece.source].paint, op === "outline");
5423
+ nodes.push({ ...style, type: "path", id: this.newId(), name: source.name ?? "Path", subpaths });
5424
+ }
5425
+ if (!nodes.length) return [];
5426
+ const label = VECTOR_PATHFINDER_LABELS[op];
5427
+ const container = nodes.length > 1 ? { id: this.newId(), type: "group", name: label, children: nodes } : nodes[0];
5428
+ const drop = /* @__PURE__ */ new Set([...entries.map((e) => e.node.id), ...roots]);
5429
+ this.change(label, () => {
5430
+ this.setDoc(mapTree(this.doc.nodes, (n) => n.id === topRoot ? container : drop.has(n.id) ? null : n));
5431
+ this.selection = [container.id];
5432
+ this.anchors = [];
5433
+ this.editingId = null;
5434
+ });
5435
+ return nodes.map((n) => n.id);
5436
+ }
5437
+ /** The Shape Builder map of the selected (or given) paths, cached per document and selection. */
5438
+ shapeBuilderMap(ids) {
5439
+ const roots = ids ?? this.selectedRoots();
5440
+ const key = roots.join("\0");
5441
+ if (this.builderCache?.doc === this.doc && this.builderCache.key === key) return this.builderCache.map;
5442
+ const entries = roots.length ? vectorPathEntries(this.doc, roots) : [];
5443
+ const map = entries.length ? vectorShapeBuilderMap(entries) : null;
5444
+ this.builderCache = { doc: this.doc, key, map };
5445
+ return map;
5446
+ }
5447
+ /** The faces the Shape Builder offers for the selected (or given) paths (document units). */
5448
+ getShapeBuilderFaces(options = {}) {
5449
+ const m = this.shapeBuilderMap(options.ids);
5450
+ if (!m) return [];
5451
+ return m.map.faces.map((f, index) => {
5452
+ const p = m.map.faceInterior(index);
5453
+ return {
5454
+ index,
5455
+ d: bezierPathToSvg(f.path, { precision: 3 }),
5456
+ area: f.area,
5457
+ sources: m.map.faceSources(index).map((i) => m.entries[i].node.id),
5458
+ point: p ? { x: p[0], y: p[1] } : null
5459
+ };
5460
+ });
5461
+ }
5462
+ /** Pick distance of the Shape Builder's erase for line pieces (document units). */
5463
+ builderLineTolerance() {
5464
+ return Math.max(this.hitRadius, 4) / this.viewport.zoom;
5465
+ }
5466
+ /**
5467
+ * Shape Builder gesture along `points` (document units; one point is a click) over the faces
5468
+ * the selected (or given) paths make. Merges the faces touched into one shape styled like the
5469
+ * top-most shape over the first face (the current fill for a face only outlines enclose);
5470
+ * with `erase` deletes them, the pieces of unfilled open paths and the edges of other outlines
5471
+ * (cut where paths meet) within `tolerance`: a deleted edge opens a closed subpath there,
5472
+ * splits an open one or takes a subpath it runs all the way round, and edges bounding a face
5473
+ * deleted in the same gesture go with the face. Every shape covering a touched face loses
5474
+ * that area; paths keep their own transforms. One undo step.
5475
+ */
5476
+ shapeBuilder(points, options = {}) {
5477
+ const none = (message) => ({ ok: false, message, merged: null, faces: 0, lines: 0, edges: 0 });
5478
+ if (!points.length || !points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) return none("Give the points of the gesture.");
5479
+ const m = this.shapeBuilderMap(options.ids);
5480
+ if (!m) return none("Select paths that make faces.");
5481
+ const erase = !!options.erase;
5482
+ const tol = erase ? options.tolerance ?? this.builderLineTolerance() : null;
5483
+ const touched = { faces: [], lines: [], edges: [] };
5484
+ const size = m.map.faces.reduce((s, f) => Math.max(s, f.bounds.maxX - f.bounds.minX, f.bounds.maxY - f.bounds.minY), 1);
5485
+ for (const p of samplePolyline(points, Math.max(size / 2e3, 0.25 / this.viewport.zoom))) addBuilderHit(touched, vectorShapeBuilderHit(m, p, tol));
5486
+ if (!touched.faces.length && !touched.lines.length && !touched.edges.length) return none("There is no face under the pointer.");
5487
+ return this.applyShapeBuilder(m, touched, erase);
5488
+ }
5489
+ applyShapeBuilder(m, touched, erase) {
5490
+ const plan = planShapeBuilder(m, touched, erase);
5491
+ let mergedId = null;
5492
+ const created = /* @__PURE__ */ new Map();
5493
+ this.change(erase ? "Shape Builder Delete" : "Shape Builder", () => {
5494
+ let nodes = this.doc.nodes;
5495
+ m.entries.forEach((entry, i) => {
5496
+ const node = entry.node;
5497
+ const toLocal = (path) => transformBezierPath(path, invertMatrix(multiplyMatrices(nodeMatrix(node), entry.parent)));
5498
+ const list = [];
5499
+ const r = plan.replace.get(i);
5500
+ if (r === void 0) list.push(node);
5501
+ else if (r?.kind === "fill") list.push({ ...node, fillRule: "nonzero", subpaths: bezierToSubpaths(toLocal(r.path)) });
5502
+ else if (r?.kind === "outline") list.push({ ...node, subpaths: bezierToSubpaths(toLocal(r.path)) });
5503
+ else if (r?.kind === "pieces") {
5504
+ r.paths.forEach(
5505
+ (path, k) => list.push({ ...node, id: k === 0 ? node.id : this.newId(), subpaths: bezierToSubpaths(toLocal(path)).map((sp) => ({ ...sp, closed: false })) })
5506
+ );
5507
+ }
5508
+ if (plan.merged && plan.merged.style === i) {
5509
+ mergedId = this.newId();
5510
+ const merged = {
5511
+ ...vectorResultStyle(node, "fill"),
5512
+ type: "path",
5513
+ id: mergedId,
5514
+ name: "Shape",
5515
+ ...node.transform ? { transform: node.transform } : {},
5516
+ subpaths: bezierToSubpaths(toLocal(plan.merged.path))
5517
+ };
5518
+ if (plan.merged.currentFill || !hasPaint(merged.fill)) merged.fill = hasPaint(this.defaults.fill) ? this.defaults.fill : "#808080";
5519
+ list.push(merged);
5520
+ }
5521
+ if (r === void 0 && list.length === 1) return;
5522
+ created.set(node.id, list.map((n) => n.id));
5523
+ nodes = spliceTree(nodes, node.id, list);
5524
+ });
5525
+ this.setDoc(nodes);
5526
+ const selection = this.selection.flatMap((id) => created.get(id) ?? [id]);
5527
+ if (mergedId && !selection.includes(mergedId)) selection.push(mergedId);
5528
+ this.selection = selection.filter((id, k) => !!this.getNode(id) && selection.indexOf(id) === k);
5529
+ this.anchors = [];
5530
+ this.editingId = null;
5531
+ });
5532
+ this.builderCache = null;
5533
+ return { ok: true, merged: mergedId, faces: touched.faces.length, lines: touched.lines.length, edges: erase ? vectorBuilderErasedEdges(m, touched).length : 0 };
5534
+ }
5535
+ /** The paint bucket's map of the visible outlines, cached per document and gap. */
5536
+ fillMap(gap) {
5537
+ if (this.fillCache?.doc === this.doc && this.fillCache.gap === gap) return this.fillCache.map;
5538
+ const map = vectorFillMap(this.doc, gap);
5539
+ this.fillCache = { doc: this.doc, gap, map };
5540
+ return map;
5541
+ }
5542
+ /**
5543
+ * Paint bucket: the region the visible outlines enclose around `point` (document units),
5544
+ * crossings split and islands left as holes, becomes a new path filled with the current fill
5545
+ * default, placed just below the selection (or on top when nothing is selected). `gap`
5546
+ * (default `editing.fillGap`) closes gaps up to that size between loose ends. Returns the new
5547
+ * path's id, or null when nothing encloses the point.
5548
+ */
5549
+ fillRegion(point, options = {}) {
5550
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
5551
+ const region = this.fillMap(options.gap ?? this.editing.fillGap)?.regionAt([point.x, point.y]);
5552
+ if (!region?.length) return null;
5553
+ let below = null;
5554
+ const roots = new Set(this.selectedRoots());
5555
+ walkVectorNodes(this.doc.nodes, (n) => {
5556
+ if (!below && roots.has(n.id)) below = n.id;
5557
+ });
5558
+ const subpaths = bezierToSubpaths(below ? this.intoParentSpace(below, region) : region);
5559
+ if (!subpaths.length) return null;
5560
+ const id = this.newId();
5561
+ const node = { id, type: "path", name: "Fill", ...this.styleFor(false), stroke: null, fillRule: "nonzero", subpaths };
5562
+ if (!hasPaint(node.fill)) node.fill = "#808080";
5563
+ const target = below;
5564
+ this.change("Fill Region", () => {
5565
+ if (target) {
5566
+ const current = this.getNode(target);
5567
+ this.setDoc(spliceTree(this.doc.nodes, target, [node, current]));
5568
+ } else this.setDoc([...this.doc.nodes, node]);
5569
+ if (!target) this.selection = [id];
5570
+ this.anchors = [];
5571
+ this.editingId = null;
5572
+ });
5573
+ return id;
5574
+ }
5575
+ /** Updates the Shape Builder / paint bucket highlight for the pointer at `p`; true when it changed. */
5576
+ hoverBuilder(p, alt) {
5577
+ if (this.tool === "paintBucket") {
5578
+ const region = this.fillMap(this.editing.fillGap)?.regionAt([p.x, p.y]) ?? null;
5579
+ const key2 = region ? bezierPathToSvg(region, { precision: 2 }) : null;
5580
+ if (key2 === this.builderHoverKey) return false;
5581
+ this.builderHoverKey = key2;
5582
+ this.builderPreview = region ? { mode: "fill", faces: [region], lines: [], trail: null } : null;
5583
+ return true;
5584
+ }
5585
+ const m = this.selectedRoots().length ? this.shapeBuilderMap() : null;
5586
+ const hit = m ? vectorShapeBuilderHit(m, p, alt ? this.builderLineTolerance() : null) : null;
5587
+ const key = hit ? `${alt ? "erase" : "merge"}:${JSON.stringify(hit)}` : null;
5588
+ if (key === this.builderHoverKey) return false;
5589
+ this.builderHoverKey = key;
5590
+ const touched = { faces: [], lines: [], edges: [] };
5591
+ addBuilderHit(touched, hit);
5592
+ this.builderPreview = hit && m ? this.builderPreviewOf(m, touched, alt, null) : null;
5593
+ return true;
5594
+ }
5595
+ builderPreviewOf(m, touched, erase, trail) {
5596
+ const faces = m.map.faces;
5597
+ return {
5598
+ mode: erase ? "erase" : "merge",
5599
+ faces: touched.faces.map((f) => faces[f].path),
5600
+ // Edges bounding a face touched too go with the face, so they are not shown apart.
5601
+ lines: [...touched.lines.map((k) => m.pieces[k].path), ...erase ? vectorBuilderErasedEdges(m, touched).map((k) => m.edges[k].path) : []],
5602
+ trail
5603
+ };
5604
+ }
5605
+ downBuilder(p, input) {
5606
+ this.builderHoverKey = null;
5607
+ if (this.tool === "paintBucket") {
5608
+ this.fillRegion(p);
5609
+ this.builderPreview = null;
5610
+ this.hoverBuilder(p, false);
5611
+ this.notify();
5612
+ return;
5613
+ }
5614
+ const m = this.selectedRoots().length ? this.shapeBuilderMap() : null;
5615
+ if (!m) return;
5616
+ const erase = !!input.alt;
5617
+ const touched = { faces: [], lines: [], edges: [] };
5618
+ addBuilderHit(touched, vectorShapeBuilderHit(m, p, erase ? this.builderLineTolerance() : null));
5619
+ this.drag = { kind: "builder", erase, points: [p], touched, map: m };
5620
+ this.interaction = { type: "builder", mode: erase ? "erase" : "merge", points: [p] };
5621
+ this.builderPreview = this.builderPreviewOf(m, touched, erase, [p]);
5622
+ this.notify();
5623
+ }
5624
+ moveBuilder(drag, p) {
5625
+ const last = drag.points[drag.points.length - 1];
5626
+ if (Math.hypot(p.x - last.x, p.y - last.y) * this.viewport.zoom < 1) return;
5627
+ const tol = drag.erase ? this.builderLineTolerance() : null;
5628
+ for (const q of samplePolyline([last, p], 2 / this.viewport.zoom)) addBuilderHit(drag.touched, vectorShapeBuilderHit(drag.map, q, tol));
5629
+ drag.points.push(p);
5630
+ this.interaction = { type: "builder", mode: drag.erase ? "erase" : "merge", points: drag.points.slice() };
5631
+ this.builderPreview = this.builderPreviewOf(drag.map, drag.touched, drag.erase, drag.points.slice());
5632
+ this.notify();
5633
+ }
5634
+ upBuilder(drag) {
5635
+ this.builderPreview = null;
5636
+ this.builderHoverKey = null;
5637
+ if (!drag.touched.faces.length && !drag.touched.lines.length && !drag.touched.edges.length) {
5638
+ this.notify();
5639
+ return;
5640
+ }
5641
+ this.applyShapeBuilder(drag.map, drag.touched, drag.erase);
5642
+ }
5643
+ // -------------------------------------------------------------------------
5118
5644
  // Anchor editing
5119
5645
  // -------------------------------------------------------------------------
5120
5646
  updateAnchors(doc, refs, fn) {
@@ -5651,8 +6177,8 @@ var VectorEditorController = class {
5651
6177
  if (!frame) return null;
5652
6178
  const { box, handles } = getVectorSelectionBoxGeometry(frame, this.viewport);
5653
6179
  const r = this.hitRadius + 2;
5654
- const usable = box.width < 2 * r || box.height < 2 * r ? handles.filter((h) => h.handle.length === 2 || h.handle === "rotate") : handles;
5655
- for (const h of usable) if (Math.hypot(h.screen.x - screen.x, h.screen.y - screen.y) <= r * Math.SQRT2) return h.handle;
6180
+ const usable2 = box.width < 2 * r || box.height < 2 * r ? handles.filter((h) => h.handle.length === 2 || h.handle === "rotate") : handles;
6181
+ for (const h of usable2) if (Math.hypot(h.screen.x - screen.x, h.screen.y - screen.y) <= r * Math.SQRT2) return h.handle;
5656
6182
  return null;
5657
6183
  }
5658
6184
  hitHandle(screen) {
@@ -5936,6 +6462,9 @@ var VectorEditorController = class {
5936
6462
  return this.downPencil(p, input);
5937
6463
  case "width":
5938
6464
  return this.downWidth(screen, p, input);
6465
+ case "shapeBuilder":
6466
+ case "paintBucket":
6467
+ return this.downBuilder(p, input);
5939
6468
  case "scissors": {
5940
6469
  const seg = this.nearestOutline(p);
5941
6470
  if (seg) this.cutPathAt(seg.nodeId, seg.point);
@@ -6165,6 +6694,11 @@ var VectorEditorController = class {
6165
6694
  pointerMove(screen, input = {}) {
6166
6695
  const p = this.screenToDocument(screen);
6167
6696
  const drag = this.drag;
6697
+ if (!drag && (this.tool === "shapeBuilder" || this.tool === "paintBucket")) {
6698
+ this.pointer = p;
6699
+ if (this.hoverBuilder(p, !!input.alt)) this.notify();
6700
+ return;
6701
+ }
6168
6702
  if (!drag) {
6169
6703
  const hover = this.computeHover(screen);
6170
6704
  const pointerMatters = this.tool === "pen" && !!this.pen;
@@ -6296,6 +6830,8 @@ var VectorEditorController = class {
6296
6830
  this.notify(true);
6297
6831
  return;
6298
6832
  }
6833
+ case "builder":
6834
+ return this.moveBuilder(drag, p);
6299
6835
  case "stroke": {
6300
6836
  const last = drag.points[drag.points.length - 1];
6301
6837
  if (Math.hypot(p.x - last.x, p.y - last.y) * this.viewport.zoom < 1.5) return;
@@ -6526,6 +7062,8 @@ var VectorEditorController = class {
6526
7062
  } else this.notify();
6527
7063
  return;
6528
7064
  }
7065
+ case "builder":
7066
+ return this.upBuilder(drag);
6529
7067
  case "stroke": {
6530
7068
  this.notify();
6531
7069
  if (drag.tool === "knife") this.knife(drag.points);
@@ -6597,7 +7135,8 @@ var VectorEditorController = class {
6597
7135
  this.drag = null;
6598
7136
  this.interaction = null;
6599
7137
  this.guides = null;
6600
- if (drag.kind === "pan") {
7138
+ if (drag.kind === "pan" || drag.kind === "builder") {
7139
+ if (drag.kind === "builder") this.builderPreview = null;
6601
7140
  this.notify();
6602
7141
  return;
6603
7142
  }
@@ -6832,6 +7371,7 @@ function pencilPressureProfile(points, pressures) {
6832
7371
  // src/vector/render.ts
6833
7372
  var fmt = (n) => Math.round(n * 100) / 100;
6834
7373
  var fmtMatrix = (m) => `matrix(${m.map((n) => Math.round(n * 1e6) / 1e6).join(" ")})`;
7374
+ var VECTOR_BUILDER_HATCH_GAP = 6;
6835
7375
  function renderNode(node, defs, idPrefix) {
6836
7376
  if (node.visible === false) return null;
6837
7377
  if (node.type === "group") {
@@ -7006,7 +7546,27 @@ function getVectorOverlayRenderData(state) {
7006
7546
  const spot = state.tool === "width" && !it ? state.hover?.strokeSpot : null;
7007
7547
  const widthSpot = spot ? { ...sc(spot.point), left: sc(spot.left), right: sc(spot.right) } : null;
7008
7548
  if (it?.type === "width") status = it.mode === "move" ? "Move width point" : `W ${fmt(it.width)}`;
7009
- return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, guides, status, toolStroke, pencilString, cutPoint, cornerWidgets, widthPoints, widthSpot };
7549
+ let builder = null;
7550
+ if (state.builder) {
7551
+ const v2 = state.viewport;
7552
+ const view = [v2.zoom, 0, 0, v2.zoom, v2.x, v2.y];
7553
+ const svg = (path) => bezierPathToSvg(path, { precision: 2 });
7554
+ const screen = state.builder.faces.map((f) => transformBezierPath(f, view));
7555
+ const hatch = state.builder.mode !== "fill";
7556
+ const trail = state.builder.trail && state.builder.trail.length > 1 ? state.builder.trail.map((q, i) => `${i ? "L" : "M"}${sc(q).x} ${sc(q).y}`).join("") : null;
7557
+ builder = {
7558
+ mode: state.builder.mode,
7559
+ faces: screen.map(svg),
7560
+ hatches: screen.map((f) => hatch ? svg(hatchPath(f, { gap: VECTOR_BUILDER_HATCH_GAP })) : ""),
7561
+ lines: state.builder.lines.map((l) => svg(transformBezierPath(l, view))),
7562
+ trail
7563
+ };
7564
+ if (state.builder.mode !== "fill" && it?.type === "builder") {
7565
+ const n = state.builder.faces.length;
7566
+ status = state.builder.mode === "erase" ? `Delete ${n} ${n === 1 ? "face" : "faces"}` : `Merge ${n} ${n === 1 ? "face" : "faces"}`;
7567
+ }
7568
+ }
7569
+ return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, guides, status, toolStroke, pencilString, cutPoint, cornerWidgets, widthPoints, widthSpot, builder };
7010
7570
  }
7011
7571
  var VECTOR_OVERLAY_THEME = {
7012
7572
  accent: "#0ea5e9",
@@ -7022,7 +7582,14 @@ var VECTOR_OVERLAY_THEME = {
7022
7582
  /** Radius of a live corner widget (a ring with a dot). */
7023
7583
  cornerWidgetRadius: 4,
7024
7584
  /** Half the diagonal of a width point's diamond. */
7025
- widthPointSize: 5
7585
+ widthPointSize: 5,
7586
+ /** Shape Builder faces to merge and paint bucket regions. */
7587
+ builderFill: "rgba(14, 165, 233, 0.22)",
7588
+ /** Shape Builder faces and line pieces an Alt gesture deletes. */
7589
+ builderErase: "#ef4444",
7590
+ builderEraseFill: "rgba(239, 68, 68, 0.22)",
7591
+ /** Opacity of the hatch lines over Shape Builder faces (drawn in the face's outline colour). */
7592
+ builderHatchOpacity: 0.6
7026
7593
  };
7027
7594
  function vectorDiamondPath(x, y, r = VECTOR_OVERLAY_THEME.widthPointSize) {
7028
7595
  return `M${fmt(x)} ${fmt(y - r)}L${fmt(x + r)} ${fmt(y)}L${fmt(x)} ${fmt(y + r)}L${fmt(x - r)} ${fmt(y)}Z`;
@@ -7138,6 +7705,14 @@ var VECTOR_PATH_ACTIONS = [
7138
7705
  { id: "smooth", label: "\u223F", title: "Smooth", enabled: (s, p) => s + p > 0, run: (c) => c.smoothPaths() },
7139
7706
  { id: "outline", label: "\u22A1", title: "Outline Stroke", enabled: (s) => s > 0, run: (c) => c.outlineStroke() }
7140
7707
  ];
7708
+ var VECTOR_PATHFINDER_ACTIONS = [
7709
+ { id: "divide", label: "\u229E", title: "Divide", min: 1 },
7710
+ { id: "trim", label: "\u29C9", title: "Trim", min: 2 },
7711
+ { id: "merge", label: "\u29C8", title: "Merge", min: 2 },
7712
+ { id: "crop", label: "\u2317", title: "Crop", min: 2 },
7713
+ { id: "outline", label: "\u2B1A", title: "Outline", min: 1 },
7714
+ { id: "minusBack", label: "\u2296", title: "Minus Back", min: 2 }
7715
+ ];
7141
7716
  var VECTOR_EDIT_TOGGLES = [
7142
7717
  { key: "keepShapeOnDelete", label: "Keep", title: "Keep shape when deleting points" },
7143
7718
  { key: "curvatureMoves", label: "Curv", title: "Curvature point moves (Alt flips while dragging)" },
@@ -7461,6 +8036,62 @@ function createVectorCommands(controller, options = {}) {
7461
8036
  return { id };
7462
8037
  }
7463
8038
  },
8039
+ {
8040
+ ...base,
8041
+ id: `${prefix}pathfinder`,
8042
+ label: "Pathfinder\u2026",
8043
+ keywords: ["divide", "trim", "merge", "crop", "outline", "minus back", "split", "faces"],
8044
+ description: "Pathfinder over the given paths (bottom to top): divide (every face its own shape, painted like the top-most shape over it; open unfilled paths cut), trim (hidden parts removed), merge (trim, then touching parts of one fill joined), crop (the top shape crops the others), outline (outlines cut into open strokes where they meet) or minusBack (the top shape minus everything below). Results replace the paths, grouped when there are several.",
8045
+ params: { type: "object", properties: { operation: { type: "string", enum: [...VECTOR_PATHFINDER_OPERATIONS] }, ids: IDS }, required: ["operation"] },
8046
+ undoable: true,
8047
+ run: (_ctx, params) => {
8048
+ if (!VECTOR_PATHFINDER_OPERATIONS.includes(params.operation)) fail(`Unknown Pathfinder operation "${params.operation}".`);
8049
+ const created = controller.pathfinder(params.operation, { ids: ids(params) });
8050
+ if (!created.length) fail("Nothing came out: select paths that overlap (two or more, except for divide and outline).");
8051
+ return { ids: created, selection: [...controller.getState().selection] };
8052
+ }
8053
+ },
8054
+ {
8055
+ ...base,
8056
+ id: `${prefix}shapeBuilderFaces`,
8057
+ label: "List Shape Builder Faces",
8058
+ keywords: ["shape builder", "regions", "faces", "planar"],
8059
+ description: "Lists the faces the given paths divide the plane into: path data, area, a point inside and the ids of the paths filling each (empty when only outlines enclose it).",
8060
+ params: { type: "object", properties: { ids: IDS } },
8061
+ run: (_ctx, params) => ({ faces: controller.getShapeBuilderFaces({ ids: ids(params) }) })
8062
+ },
8063
+ {
8064
+ ...base,
8065
+ id: `${prefix}shapeBuilder`,
8066
+ label: "Shape Builder",
8067
+ keywords: ["merge faces", "build shape", "combine regions", "delete region"],
8068
+ description: "Shape Builder gesture over the faces of the given paths: `points` is the pointer path in document units (one point is a click; [[x, y], \u2026]; use the points from shapeBuilderFaces). Merges the faces it touches into one shape, or with `erase: true` deletes them, the pieces of unfilled open paths and the edges of other outlines (cut where paths meet) within `tolerance`; a deleted edge opens its path there, and edges bounding a deleted face go with it. Shapes covering those faces lose that area. Returns the counts of faces, line pieces and edges.",
8069
+ params: {
8070
+ type: "object",
8071
+ properties: { points: POINTS, erase: { type: "boolean" }, tolerance: { type: "number", minimum: 0 }, ids: IDS },
8072
+ required: ["points"]
8073
+ },
8074
+ undoable: true,
8075
+ run: (_ctx, params) => {
8076
+ const result = controller.shapeBuilder(params.points.map(([x, y]) => ({ x, y })), { ids: ids(params), erase: params.erase, tolerance: params.tolerance });
8077
+ check(result);
8078
+ return { merged: result.merged, faces: result.faces, lines: result.lines, edges: result.edges };
8079
+ }
8080
+ },
8081
+ {
8082
+ ...base,
8083
+ id: `${prefix}fillRegion`,
8084
+ label: "Fill Region",
8085
+ keywords: ["paint bucket", "live paint", "fill area", "enclosed"],
8086
+ description: "Paint bucket: fills the area the visible outlines enclose around `point` (crossings split, nested shapes left as holes) with a new path in the current fill, below the selection or on top. `gap` closes gaps up to that size between loose ends.",
8087
+ params: { type: "object", properties: { point: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, description: "[x, y] in document units." }, gap: { type: "number", minimum: 0 } }, required: ["point"] },
8088
+ undoable: true,
8089
+ run: (_ctx, params) => {
8090
+ const id = controller.fillRegion({ x: params.point[0], y: params.point[1] }, { gap: params.gap });
8091
+ if (!id) fail("No closed region surrounds that point.");
8092
+ return { id };
8093
+ }
8094
+ },
7464
8095
  {
7465
8096
  ...base,
7466
8097
  id: `${prefix}align`,
@@ -7759,14 +8390,19 @@ export {
7759
8390
  LazyMouse,
7760
8391
  VECTOR_ARROWHEADS,
7761
8392
  VECTOR_ARROWHEAD_LABELS,
8393
+ VECTOR_BUILDER_HATCH_GAP,
7762
8394
  VECTOR_CLIPBOARD_TYPE,
7763
8395
  VECTOR_CONSTRUCTION_ANGLES,
7764
8396
  VECTOR_CORNER_KINDS,
7765
8397
  VECTOR_CORNER_KIND_LABELS,
7766
8398
  VECTOR_CORNER_WIDGET_INSET,
7767
8399
  VECTOR_EDIT_TOGGLES,
8400
+ VECTOR_FILL_GAP,
7768
8401
  VECTOR_INK_MAX_CORNERS,
7769
8402
  VECTOR_OVERLAY_THEME,
8403
+ VECTOR_PATHFINDER_ACTIONS,
8404
+ VECTOR_PATHFINDER_LABELS,
8405
+ VECTOR_PATHFINDER_OPERATIONS,
7770
8406
  VECTOR_PATH_ACTIONS,
7771
8407
  VECTOR_PENCIL_STRING_PX,
7772
8408
  VECTOR_PRESSURE_NOMINAL,
@@ -7784,6 +8420,7 @@ export {
7784
8420
  VECTOR_WIDTH_SAME_T,
7785
8421
  VectorEditorController,
7786
8422
  VectorSnapTargets,
8423
+ addBuilderHit,
7787
8424
  applyMatrix,
7788
8425
  applyMatrixLinear,
7789
8426
  applyVectorCornersAttribute,
@@ -7860,6 +8497,7 @@ export {
7860
8497
  nodeMatrix,
7861
8498
  parseSvgTransform,
7862
8499
  parseVectorSvg,
8500
+ planShapeBuilder,
7863
8501
  polygonSubpath,
7864
8502
  polylineBounds,
7865
8503
  pressureWidthProfile,
@@ -7874,6 +8512,7 @@ export {
7874
8512
  reverseWidthProfile,
7875
8513
  rotateMatrix,
7876
8514
  roundSubpaths,
8515
+ samplePolyline,
7877
8516
  sanitizeWidthProfile,
7878
8517
  scaleFor,
7879
8518
  scaleMatrix,
@@ -7887,6 +8526,7 @@ export {
7887
8526
  starSubpath,
7888
8527
  strokePositionPoint,
7889
8528
  strokeWidthProfileOf,
8529
+ subpathEnclosesArea,
7890
8530
  subpathPointAt,
7891
8531
  subpathSegment,
7892
8532
  subpathSegmentCount,
@@ -7901,11 +8541,13 @@ export {
7901
8541
  unionBounds,
7902
8542
  variableStrokeOutline,
7903
8543
  variableStrokeRings,
8544
+ vectorBuilderErasedEdges,
7904
8545
  vectorCornerAt,
7905
8546
  vectorCorners,
7906
8547
  vectorCornersAttribute,
7907
8548
  vectorDiamondPath,
7908
8549
  vectorDocumentToSvg,
8550
+ vectorFillMap,
7909
8551
  vectorInkShapeName,
7910
8552
  vectorNodeBounds,
7911
8553
  vectorNodeRotation,
@@ -7914,8 +8556,13 @@ export {
7914
8556
  vectorPaintToSvg,
7915
8557
  vectorParentMatrix,
7916
8558
  vectorPathData,
8559
+ vectorPathEntries,
7917
8560
  vectorPathWorldBezier,
8561
+ vectorPathfinderShape,
7918
8562
  vectorResizeCursor,
8563
+ vectorResultStyle,
8564
+ vectorShapeBuilderHit,
8565
+ vectorShapeBuilderMap,
7919
8566
  vectorStrokeLineArea,
7920
8567
  vectorStrokeOutline,
7921
8568
  vectorStrokeOutlinePath,