@pixerate/editor 0.38.0 → 0.40.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.
@@ -8,12 +8,14 @@ import {
8
8
  closeContour,
9
9
  contourSignedArea,
10
10
  contoursToPath,
11
+ cutOutPiece,
11
12
  distanceToBezierPath,
12
13
  fitCubicWithTangents,
13
14
  fitCubics,
14
15
  flattenChain,
15
16
  formatNumber,
16
17
  gradientToSvgDef,
18
+ hatchPath,
17
19
  isStraightCubic,
18
20
  normalizeBezierPath,
19
21
  outlineStroke,
@@ -36,7 +38,7 @@ import {
36
38
  transformBezierPath,
37
39
  trimSegments,
38
40
  windingNumber
39
- } from "../chunk-ZCCU3VNI.js";
41
+ } from "../chunk-3LHWTGHW.js";
40
42
 
41
43
  // src/vector/types.ts
42
44
  var VECTOR_CORNER_KINDS = ["round", "inverted", "chamfer"];
@@ -827,7 +829,11 @@ var capExtent = (style, width) => style.strokeLinecap === "round" || style.strok
827
829
  function arrowheadWeight(style, end) {
828
830
  const scale = end === "start" ? style.startArrowScale : style.endArrowScale;
829
831
  const s = typeof scale === "number" && scale > 0 && Number.isFinite(scale) ? scale : 1;
830
- return Math.max(0.25, (style.strokeWidth ?? 1) * s);
832
+ const width = style.strokeWidth ?? 1;
833
+ const profile = strokeWidthProfileOf(style);
834
+ const at2 = profile ? widthProfileAt(profile, end === "start" ? 0 : 1) : null;
835
+ const here = at2 ? Math.max(width, width * (at2.left + at2.right) / 2) : width;
836
+ return Math.max(0.25, here * s);
831
837
  }
832
838
  function arrowheadInset(kind, hw, cap) {
833
839
  const l = 4 * hw;
@@ -3815,29 +3821,48 @@ function vectorShapeBuilderMap(entries) {
3815
3821
  });
3816
3822
  const map = PlanarMap.build(inputs);
3817
3823
  if (!map) return null;
3818
- const pieces = map.chains().filter((c) => lines[c.source] && !c.closed).map((c) => {
3819
- const path = contoursToPath([{ segments: c.segments, closed: false }]);
3820
- return { path, segments: c.segments, source: c.source, bounds: bezierPathBounds(path) };
3824
+ let chains = null;
3825
+ const runs = (line2) => (chains ??= map.chains()).filter((c) => lines[c.source] === line2 && !(line2 && c.closed)).map((c) => {
3826
+ const path = contoursToPath([{ segments: c.segments, closed: c.closed }]);
3827
+ return { path, segments: c.segments, closed: c.closed, source: c.source, bounds: bezierPathBounds(path) };
3821
3828
  });
3822
- return { entries: [...entries], map, lines, pieces };
3829
+ const pieces = lines.some(Boolean) ? runs(true) : [];
3830
+ let edges = null;
3831
+ let size = 0;
3832
+ for (const input of inputs) {
3833
+ const b = bezierPathBounds(input.path);
3834
+ if (b) size = Math.max(size, b.maxX - b.minX, b.maxY - b.minY);
3835
+ }
3836
+ return {
3837
+ entries: [...entries],
3838
+ map,
3839
+ lines,
3840
+ pieces,
3841
+ get edges() {
3842
+ return edges ??= runs(false);
3843
+ },
3844
+ tolerance: 0.2 * Math.max(size / 2e3, 0.25)
3845
+ };
3823
3846
  }
3824
- function vectorShapeBuilderHit(m, p, lineTolerance = null) {
3847
+ function pieceNear(pieces, p, tol) {
3825
3848
  const point = [p.x, p.y];
3849
+ let best = null;
3850
+ pieces.forEach((piece, index) => {
3851
+ const b = piece.bounds;
3852
+ if (p.x < b.minX - tol || p.x > b.maxX + tol || p.y < b.minY - tol || p.y > b.maxY + tol) return;
3853
+ const d = distanceToBezierPath(piece.path, point);
3854
+ if (d <= tol && (!best || d < best.d)) best = { index, d };
3855
+ });
3856
+ return best;
3857
+ }
3858
+ function vectorShapeBuilderHit(m, p, lineTolerance = null) {
3826
3859
  if (lineTolerance !== null && lineTolerance > 0) {
3827
- let best = -1;
3828
- let bestD = lineTolerance;
3829
- m.pieces.forEach((piece, i) => {
3830
- const b = piece.bounds;
3831
- if (p.x < b.minX - bestD || p.x > b.maxX + bestD || p.y < b.minY - bestD || p.y > b.maxY + bestD) return;
3832
- const d = distanceToBezierPath(piece.path, point);
3833
- if (d <= bestD) {
3834
- best = i;
3835
- bestD = d;
3836
- }
3837
- });
3838
- if (best >= 0) return { line: best };
3860
+ const line2 = pieceNear(m.pieces, p, lineTolerance);
3861
+ const edge = pieceNear(m.edges, p, lineTolerance);
3862
+ if (edge && (!line2 || edge.d < line2.d)) return { edge: edge.index };
3863
+ if (line2) return { line: line2.index };
3839
3864
  }
3840
- const face = m.map.faceAt(point);
3865
+ const face = m.map.faceAt([p.x, p.y]);
3841
3866
  return face >= 0 ? { face } : null;
3842
3867
  }
3843
3868
  function samplePolyline(points, step) {
@@ -3855,7 +3880,7 @@ function samplePolyline(points, step) {
3855
3880
  }
3856
3881
  function addBuilderHit(touched, hit) {
3857
3882
  if (!hit) return false;
3858
- const [list, i] = "face" in hit ? [touched.faces, hit.face] : [touched.lines, hit.line];
3883
+ const [list, i] = "face" in hit ? [touched.faces, hit.face] : "line" in hit ? [touched.lines, hit.line] : [touched.edges, hit.edge];
3859
3884
  if (list.includes(i)) return false;
3860
3885
  list.push(i);
3861
3886
  return true;
@@ -3868,12 +3893,18 @@ function sides(m, piece) {
3868
3893
  const off = Math.max(1e-6, Math.min(0.05, 0.05 * chord));
3869
3894
  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])];
3870
3895
  }
3896
+ function vectorBuilderErasedEdges(m, touched) {
3897
+ if (!touched.edges.length) return [];
3898
+ const faces = new Set(touched.faces);
3899
+ return touched.edges.filter((k) => m.edges[k] && !sides(m, m.edges[k]).some((f) => f >= 0 && faces.has(f)));
3900
+ }
3871
3901
  function planShapeBuilder(m, touched, erase) {
3872
3902
  const replace = /* @__PURE__ */ new Map();
3873
3903
  const picked = new Set(touched.faces);
3874
3904
  const faces = m.map.faces;
3875
3905
  const sourcesOf = faces.map((_, i) => m.map.faceSources(i));
3876
- m.entries.forEach((_, i) => {
3906
+ const cuts = erase ? vectorBuilderErasedEdges(m, touched).map((k) => m.edges[k]) : [];
3907
+ m.entries.forEach((entry, i) => {
3877
3908
  if (m.lines[i]) {
3878
3909
  const own = m.pieces.flatMap((piece, k) => piece.source === i ? [k] : []);
3879
3910
  const keep = own.filter((k) => !touched.lines.includes(k) && !sides(m, m.pieces[k]).every((f) => f >= 0 && picked.has(f)));
@@ -3881,10 +3912,24 @@ function planShapeBuilder(m, touched, erase) {
3881
3912
  replace.set(i, keep.length ? { kind: "pieces", paths: keep.map((k) => m.pieces[k].path) } : null);
3882
3913
  return;
3883
3914
  }
3884
- if (!touched.faces.some((f) => sourcesOf[f].includes(i))) return;
3885
- const kept = faces.flatMap((_2, f) => sourcesOf[f].includes(i) && !picked.has(f) ? [f] : []);
3886
- const rest = kept.length ? m.map.merge(kept) : [];
3887
- replace.set(i, rest.length ? { kind: "fill", path: rest } : null);
3915
+ const inUnion = touched.faces.some((f) => sourcesOf[f].includes(i));
3916
+ if (!inUnion && !cuts.length) return;
3917
+ let rest;
3918
+ if (inUnion) {
3919
+ const kept = faces.flatMap((_, f) => sourcesOf[f].includes(i) && !picked.has(f) ? [f] : []);
3920
+ rest = kept.length ? m.map.merge(kept) : [];
3921
+ } else rest = worldOf(entry, entry.node.subpaths.filter(usable));
3922
+ const fillCloses = hasPaint(entry.node.fill);
3923
+ let cut = false;
3924
+ for (const piece of cuts) {
3925
+ const r = rest.length ? cutOutPiece(rest, piece, fillCloses, m.tolerance) : null;
3926
+ if (r) {
3927
+ rest = r;
3928
+ cut = true;
3929
+ }
3930
+ }
3931
+ if (!inUnion && !cut) return;
3932
+ replace.set(i, rest.length ? { kind: inUnion ? "fill" : "outline", path: rest } : null);
3888
3933
  });
3889
3934
  let merged = null;
3890
3935
  if (!erase && touched.faces.length) {
@@ -4037,6 +4082,12 @@ function vectorStrokeWidthGeometry(node) {
4037
4082
  const parts = vectorStrokeParts(subpathsToBezier(node.subpaths), node);
4038
4083
  return parts.line.length ? { line: parts.line, unit: parts.drawWidth / 2 } : null;
4039
4084
  }
4085
+ function shiftWidthPoints(profile, indices, dt) {
4086
+ const moving = new Set(indices);
4087
+ const tagged = profile.map((p, i) => ({ p: moving.has(i) ? { ...p, t: Math.min(Math.max(p.t + dt, 0), 1) } : { ...p }, i }));
4088
+ tagged.sort((a, b) => a.p.t - b.p.t || a.i - b.i);
4089
+ return { points: tagged.map((x) => x.p), renumber: new Map(tagged.map((x, k) => [x.i, k])) };
4090
+ }
4040
4091
  function getVectorWidthHandles(state) {
4041
4092
  if (state.tool !== "width") return [];
4042
4093
  const ids = /* @__PURE__ */ new Set();
@@ -4046,7 +4097,7 @@ function getVectorWidthHandles(state) {
4046
4097
  }
4047
4098
  if (state.hover?.nodeId) ids.add(state.hover.nodeId);
4048
4099
  if (state.interaction?.type === "width") ids.add(state.interaction.ref.nodeId);
4049
- if (state.selectedWidthPoint) ids.add(state.selectedWidthPoint.nodeId);
4100
+ for (const r of state.selectedWidthPoints) ids.add(r.nodeId);
4050
4101
  const out = [];
4051
4102
  for (const id of ids) {
4052
4103
  const node = findVectorNode(state.document.nodes, id);
@@ -4065,7 +4116,6 @@ function getVectorWidthHandles(state) {
4065
4116
  const p = applyMatrix(m, c);
4066
4117
  const l = applyMatrix(m, { x: c.x + n.x * w.left * g.unit, y: c.y + n.y * w.left * g.unit });
4067
4118
  const r = applyMatrix(m, { x: c.x - n.x * w.right * g.unit, y: c.y - n.y * w.right * g.unit });
4068
- const sel = state.selectedWidthPoint;
4069
4119
  out.push({
4070
4120
  ref: { nodeId: id, index, subpath: sp },
4071
4121
  x: p.x,
@@ -4075,7 +4125,7 @@ function getVectorWidthHandles(state) {
4075
4125
  t: w.t,
4076
4126
  leftScale: w.left,
4077
4127
  rightScale: w.right,
4078
- selected: !!sel && sel.nodeId === id && sel.index === index
4128
+ selected: state.selectedWidthPoints.some((r2) => r2.nodeId === id && r2.index === index)
4079
4129
  });
4080
4130
  });
4081
4131
  }
@@ -4271,7 +4321,8 @@ var VectorEditorController = class {
4271
4321
  doc;
4272
4322
  selection = [];
4273
4323
  anchors = [];
4274
- widthPoint = null;
4324
+ widthPoints = [];
4325
+ widthEdit = null;
4275
4326
  editingId = null;
4276
4327
  pen = null;
4277
4328
  tool;
@@ -4357,7 +4408,8 @@ var VectorEditorController = class {
4357
4408
  tool: this.tool,
4358
4409
  selection: this.selection,
4359
4410
  selectedAnchors: this.anchors,
4360
- selectedWidthPoint: this.widthPoint,
4411
+ selectedWidthPoints: this.widthPoints,
4412
+ widthPointEdit: this.widthEdit,
4361
4413
  editingId: this.editingId,
4362
4414
  selectionBounds: this.boundsOf(this.selection),
4363
4415
  selectionFrame: this.frameOf(this.selectedRoots()),
@@ -4428,7 +4480,7 @@ var VectorEditorController = class {
4428
4480
  case "pen":
4429
4481
  return h?.anchor ? "pointer" : "crosshair";
4430
4482
  case "width":
4431
- if (this.drag?.kind === "width") return this.drag.mode === "move" ? "grabbing" : "ew-resize";
4483
+ if (this.drag?.kind === "width") return this.drag.mode === "width" ? "ew-resize" : "grabbing";
4432
4484
  if (h?.widthPoint) return h.widthPoint.part === "center" ? "grab" : "ew-resize";
4433
4485
  return h?.strokeSpot ? "copy" : "default";
4434
4486
  case "shapeBuilder":
@@ -4448,6 +4500,7 @@ var VectorEditorController = class {
4448
4500
  }
4449
4501
  restore(s) {
4450
4502
  const docChanged = s.doc !== this.doc || this.committing;
4503
+ if (!this.committing) this.widthEdit = null;
4451
4504
  this.committing = false;
4452
4505
  this.applying = true;
4453
4506
  try {
@@ -4456,6 +4509,13 @@ var VectorEditorController = class {
4456
4509
  this.anchors = s.anchors.filter((r) => getAnchor(this.doc, r));
4457
4510
  this.editingId = s.editingId && this.getNode(s.editingId) ? s.editingId : null;
4458
4511
  this.pen = s.pen && this.getNode(s.pen.nodeId)?.subpaths?.[s.pen.subpath] ? s.pen : null;
4512
+ if (this.widthPoints.length) {
4513
+ const kept = this.widthPoints.filter((r) => {
4514
+ const n = this.getNode(r.nodeId);
4515
+ return n?.type === "path" && !!n.strokeWidthProfile?.[r.index];
4516
+ });
4517
+ if (kept.length !== this.widthPoints.length) this.widthPoints = kept;
4518
+ }
4459
4519
  } finally {
4460
4520
  this.applying = false;
4461
4521
  }
@@ -4561,7 +4621,8 @@ var VectorEditorController = class {
4561
4621
  this.tool = tool;
4562
4622
  this.hover = null;
4563
4623
  this.guides = null;
4564
- this.widthPoint = null;
4624
+ this.widthPoints = [];
4625
+ this.widthEdit = null;
4565
4626
  this.builderPreview = null;
4566
4627
  this.builderHoverKey = null;
4567
4628
  if (tool !== "direct") {
@@ -4837,18 +4898,143 @@ var VectorEditorController = class {
4837
4898
  const p = profile;
4838
4899
  points = p.taper || p.wave ? generateWidthProfile(p) : [];
4839
4900
  }
4840
- this.widthPoint = null;
4901
+ this.widthPoints = [];
4902
+ this.widthEdit = null;
4841
4903
  return this.setStyle({ strokeWidthProfile: points.length ? points : null }, options.ids ?? this.selection, { label: points.length ? "Width Profile" : "Uniform Width" });
4842
4904
  }
4843
- /** Removes one width point of a path's profile (the width tool's Delete). */
4905
+ /** Removes one width point of a path's profile. */
4844
4906
  removeWidthPoint(ref) {
4845
- const node = this.getNode(ref.nodeId);
4846
- if (node?.type !== "path" || !node.strokeWidthProfile?.[ref.index]) return { ok: false, message: "There is no such width point." };
4847
- const rest = removeWidthPoints(sanitizeWidthProfile(node.strokeWidthProfile), [ref.index]);
4848
- this.widthPoint = null;
4849
- this.change("Delete Width Point", () => this.replaceNode(node.id, withStyle(node, { strokeWidthProfile: rest.length ? rest : null })));
4907
+ return this.removeWidthPoints([ref]);
4908
+ }
4909
+ /** Removes width points (default: the selected ones — the width tool's Delete), as one undo step. */
4910
+ removeWidthPoints(refs = this.widthPoints) {
4911
+ const byNode = /* @__PURE__ */ new Map();
4912
+ for (const r of refs) {
4913
+ const node = this.getNode(r.nodeId);
4914
+ if (node?.type !== "path" || !node.strokeWidthProfile?.[r.index]) continue;
4915
+ byNode.set(r.nodeId, [...byNode.get(r.nodeId) ?? [], r.index]);
4916
+ }
4917
+ if (!byNode.size) return { ok: false, message: "There is no such width point." };
4918
+ const count = [...byNode.values()].reduce((n, l) => n + new Set(l).size, 0);
4919
+ this.widthPoints = [];
4920
+ this.widthEdit = null;
4921
+ this.change(count > 1 ? "Delete Width Points" : "Delete Width Point", () => {
4922
+ for (const [id, indices] of byNode) {
4923
+ const node = this.getNode(id);
4924
+ const rest = removeWidthPoints(sanitizeWidthProfile(node.strokeWidthProfile), indices);
4925
+ this.replaceNode(id, withStyle(node, { strokeWidthProfile: rest.length ? rest : null }));
4926
+ }
4927
+ });
4928
+ return { ok: true };
4929
+ }
4930
+ /** Selects width points (width tool); points on other paths than the first are dropped. */
4931
+ selectWidthPoints(refs) {
4932
+ const first = refs[0]?.nodeId;
4933
+ const next = [];
4934
+ for (const r of refs) {
4935
+ if (r.nodeId !== first || next.some((q) => q.index === r.index)) continue;
4936
+ const n = this.getNode(r.nodeId);
4937
+ if (n?.type === "path" && n.strokeWidthProfile?.[r.index]) next.push({ nodeId: r.nodeId, index: r.index, subpath: r.subpath ?? 0 });
4938
+ }
4939
+ this.widthPoints = next;
4940
+ this.notify();
4941
+ }
4942
+ /**
4943
+ * Sets one width point numerically: `index` names an existing point of path `nodeId`, null
4944
+ * (or left out) adds one at `values.t`. `left` / `right` are side widths in document units
4945
+ * (how far the stroke reaches on each side of the path), `t` the position along each subpath
4946
+ * (0–1). A point moved onto another one's `t` makes a step there. Selects the point and
4947
+ * refreshes an open width-point editor; one undo step. Ported from ArtCraft vectorcraft's
4948
+ * Width Point Edit dialog (`crates/tools/src/distort/width.rs`, MIT OR Apache-2.0).
4949
+ */
4950
+ setWidthPoint(target, values) {
4951
+ const node = this.getNode(target.nodeId);
4952
+ if (node?.type !== "path") return { ok: false, message: "There is no such path." };
4953
+ const g = vectorStrokeWidthGeometry(node);
4954
+ if (!g || !(g.unit > 0)) return { ok: false, message: "The path has no visible stroke." };
4955
+ const num = (v2) => typeof v2 === "number" && Number.isFinite(v2) ? v2 : void 0;
4956
+ const t = num(values.t);
4957
+ const left = num(values.left);
4958
+ const right = num(values.right);
4959
+ const scale = (w) => w === void 0 ? void 0 : Math.max(0, w) / g.unit;
4960
+ const profile = sanitizeWidthProfile(node.strokeWidthProfile);
4961
+ let points;
4962
+ let index;
4963
+ const existing = target.index ?? null;
4964
+ if (existing === null) {
4965
+ if (t === void 0) return { ok: false, message: "A new width point needs a position t." };
4966
+ if (profile.length >= VECTOR_WIDTH_PROFILE_MAX_POINTS) return { ok: false, message: "The profile has too many width points." };
4967
+ ({ points, index } = insertWidthPoint(profile.length ? profile : null, t, { left: scale(left), right: scale(right) }, { step: true }));
4968
+ } else {
4969
+ const p = profile[existing];
4970
+ if (!p) return { ok: false, message: "There is no such width point." };
4971
+ const sides2 = { left: scale(left) ?? p.left, right: scale(right) ?? p.right };
4972
+ const tt = t === void 0 ? p.t : Math.min(Math.max(t, 0), 1);
4973
+ if (Math.abs(tt - p.t) <= VECTOR_WIDTH_SAME_T) {
4974
+ points = setWidthPoint(profile, existing, sides2);
4975
+ index = existing;
4976
+ } else {
4977
+ const rest = removeWidthPoints(profile, [existing]);
4978
+ if (rest.length) ({ points, index } = insertWidthPoint(rest, tt, sides2, { step: true }));
4979
+ else {
4980
+ points = [{ t: tt, ...sides2 }];
4981
+ index = 0;
4982
+ }
4983
+ }
4984
+ }
4985
+ const subpath = this.widthEdit?.nodeId === node.id ? this.widthEdit.subpath : this.widthPoints.find((r) => r.nodeId === node.id)?.subpath ?? 0;
4986
+ const edit = this.widthEdit;
4987
+ this.change("Width Point", () => this.replaceNode(node.id, withStyle(node, { strokeWidthProfile: points })));
4988
+ this.widthPoints = [{ nodeId: node.id, index, subpath }];
4989
+ if (edit?.nodeId === node.id && (edit.index === existing || existing === null)) this.widthEdit = this.widthEditFor(node.id, subpath, index, null);
4990
+ this.notify();
4850
4991
  return { ok: true };
4851
4992
  }
4993
+ /**
4994
+ * Opens the width-point editor (what a double-click with the width tool does) on a width
4995
+ * point, or on a spot of a stroke (`t` without `index`: applying it adds a point there).
4996
+ * Returns false when the path has no visible stroke.
4997
+ */
4998
+ openWidthPointEdit(target) {
4999
+ const edit = this.widthEditFor(target.nodeId, target.subpath ?? 0, target.index ?? null, target.t ?? null);
5000
+ if (!edit) return false;
5001
+ this.widthEdit = edit;
5002
+ this.widthPoints = edit.index === null ? [] : [{ nodeId: edit.nodeId, index: edit.index, subpath: edit.subpath }];
5003
+ this.notify();
5004
+ return true;
5005
+ }
5006
+ /** Closes the width-point editor without changing anything. */
5007
+ closeWidthPointEdit() {
5008
+ if (!this.widthEdit) return;
5009
+ this.widthEdit = null;
5010
+ this.notify();
5011
+ }
5012
+ /** Applies `values` to the width point being edited (see `setWidthPoint`) and closes the editor. */
5013
+ commitWidthPointEdit(values) {
5014
+ const edit = this.widthEdit;
5015
+ if (!edit) return { ok: false, message: "No width point is being edited." };
5016
+ const r = this.setWidthPoint({ nodeId: edit.nodeId, index: edit.index }, edit.index === null ? { t: edit.t, ...values } : values);
5017
+ if (r.ok) this.closeWidthPointEdit();
5018
+ return r;
5019
+ }
5020
+ /** The editor's view of point `index` (or of the stroke at `t` when index is null) of `nodeId`. */
5021
+ widthEditFor(nodeId, subpath, index, t) {
5022
+ const node = this.getNode(nodeId);
5023
+ if (node?.type !== "path") return null;
5024
+ const g = vectorStrokeWidthGeometry(node);
5025
+ if (!g) return null;
5026
+ const profile = sanitizeWidthProfile(node.strokeWidthProfile);
5027
+ const p = index === null ? null : profile[index];
5028
+ if (index !== null && !p) return null;
5029
+ const tt = p ? p.t : Math.min(Math.max(t ?? 0, 0), 1);
5030
+ const sides2 = p ?? widthProfileAt(profile.length ? profile : null, tt);
5031
+ const sp = Math.min(Math.max(0, subpath), Math.max(0, node.subpaths.length - 1));
5032
+ const at2 = strokePositionPoint(g.line, sp, tt);
5033
+ const round = (v2) => Math.round(v2 * 1e6) / 1e6;
5034
+ const q = at2 ? applyMatrix(this.worldOf(nodeId), { x: at2.point[0], y: at2.point[1] }) : { x: 0, y: 0 };
5035
+ const point = { x: round(q.x), y: round(q.y) };
5036
+ return { nodeId, subpath: sp, index, t: round(tt), left: round(sides2.left * g.unit), right: round(sides2.right * g.unit), point };
5037
+ }
4852
5038
  /**
4853
5039
  * Sets the live corner radius (and optionally the kind) of anchors: `refs`, else the selected
4854
5040
  * anchors (direct tool), else every anchor of the `ids` / selected paths. Radius 0 makes the
@@ -5426,20 +5612,23 @@ var VectorEditorController = class {
5426
5612
  * Shape Builder gesture along `points` (document units; one point is a click) over the faces
5427
5613
  * the selected (or given) paths make. Merges the faces touched into one shape styled like the
5428
5614
  * top-most shape over the first face (the current fill for a face only outlines enclose);
5429
- * with `erase` deletes them, and the pieces of unfilled open paths within `tolerance`. Every
5430
- * shape covering a touched face loses that area. One undo step.
5615
+ * with `erase` deletes them, the pieces of unfilled open paths and the edges of other outlines
5616
+ * (cut where paths meet) within `tolerance`: a deleted edge opens a closed subpath there,
5617
+ * splits an open one or takes a subpath it runs all the way round, and edges bounding a face
5618
+ * deleted in the same gesture go with the face. Every shape covering a touched face loses
5619
+ * that area; paths keep their own transforms. One undo step.
5431
5620
  */
5432
5621
  shapeBuilder(points, options = {}) {
5433
- const none = (message) => ({ ok: false, message, merged: null, faces: 0, lines: 0 });
5622
+ const none = (message) => ({ ok: false, message, merged: null, faces: 0, lines: 0, edges: 0 });
5434
5623
  if (!points.length || !points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) return none("Give the points of the gesture.");
5435
5624
  const m = this.shapeBuilderMap(options.ids);
5436
5625
  if (!m) return none("Select paths that make faces.");
5437
5626
  const erase = !!options.erase;
5438
5627
  const tol = erase ? options.tolerance ?? this.builderLineTolerance() : null;
5439
- const touched = { faces: [], lines: [] };
5628
+ const touched = { faces: [], lines: [], edges: [] };
5440
5629
  const size = m.map.faces.reduce((s, f) => Math.max(s, f.bounds.maxX - f.bounds.minX, f.bounds.maxY - f.bounds.minY), 1);
5441
5630
  for (const p of samplePolyline(points, Math.max(size / 2e3, 0.25 / this.viewport.zoom))) addBuilderHit(touched, vectorShapeBuilderHit(m, p, tol));
5442
- if (!touched.faces.length && !touched.lines.length) return none("There is no face under the pointer.");
5631
+ if (!touched.faces.length && !touched.lines.length && !touched.edges.length) return none("There is no face under the pointer.");
5443
5632
  return this.applyShapeBuilder(m, touched, erase);
5444
5633
  }
5445
5634
  applyShapeBuilder(m, touched, erase) {
@@ -5455,6 +5644,7 @@ var VectorEditorController = class {
5455
5644
  const r = plan.replace.get(i);
5456
5645
  if (r === void 0) list.push(node);
5457
5646
  else if (r?.kind === "fill") list.push({ ...node, fillRule: "nonzero", subpaths: bezierToSubpaths(toLocal(r.path)) });
5647
+ else if (r?.kind === "outline") list.push({ ...node, subpaths: bezierToSubpaths(toLocal(r.path)) });
5458
5648
  else if (r?.kind === "pieces") {
5459
5649
  r.paths.forEach(
5460
5650
  (path, k) => list.push({ ...node, id: k === 0 ? node.id : this.newId(), subpaths: bezierToSubpaths(toLocal(path)).map((sp) => ({ ...sp, closed: false })) })
@@ -5485,7 +5675,7 @@ var VectorEditorController = class {
5485
5675
  this.editingId = null;
5486
5676
  });
5487
5677
  this.builderCache = null;
5488
- return { ok: true, merged: mergedId, faces: touched.faces.length, lines: touched.lines.length };
5678
+ return { ok: true, merged: mergedId, faces: touched.faces.length, lines: touched.lines.length, edges: erase ? vectorBuilderErasedEdges(m, touched).length : 0 };
5489
5679
  }
5490
5680
  /** The paint bucket's map of the visible outlines, cached per document and gap. */
5491
5681
  fillMap(gap) {
@@ -5542,7 +5732,9 @@ var VectorEditorController = class {
5542
5732
  const key = hit ? `${alt ? "erase" : "merge"}:${JSON.stringify(hit)}` : null;
5543
5733
  if (key === this.builderHoverKey) return false;
5544
5734
  this.builderHoverKey = key;
5545
- this.builderPreview = hit && m ? this.builderPreviewOf(m, "face" in hit ? { faces: [hit.face], lines: [] } : { faces: [], lines: [hit.line] }, alt, null) : null;
5735
+ const touched = { faces: [], lines: [], edges: [] };
5736
+ addBuilderHit(touched, hit);
5737
+ this.builderPreview = hit && m ? this.builderPreviewOf(m, touched, alt, null) : null;
5546
5738
  return true;
5547
5739
  }
5548
5740
  builderPreviewOf(m, touched, erase, trail) {
@@ -5550,7 +5742,8 @@ var VectorEditorController = class {
5550
5742
  return {
5551
5743
  mode: erase ? "erase" : "merge",
5552
5744
  faces: touched.faces.map((f) => faces[f].path),
5553
- lines: touched.lines.map((k) => m.pieces[k].path),
5745
+ // Edges bounding a face touched too go with the face, so they are not shown apart.
5746
+ lines: [...touched.lines.map((k) => m.pieces[k].path), ...erase ? vectorBuilderErasedEdges(m, touched).map((k) => m.edges[k].path) : []],
5554
5747
  trail
5555
5748
  };
5556
5749
  }
@@ -5566,7 +5759,7 @@ var VectorEditorController = class {
5566
5759
  const m = this.selectedRoots().length ? this.shapeBuilderMap() : null;
5567
5760
  if (!m) return;
5568
5761
  const erase = !!input.alt;
5569
- const touched = { faces: [], lines: [] };
5762
+ const touched = { faces: [], lines: [], edges: [] };
5570
5763
  addBuilderHit(touched, vectorShapeBuilderHit(m, p, erase ? this.builderLineTolerance() : null));
5571
5764
  this.drag = { kind: "builder", erase, points: [p], touched, map: m };
5572
5765
  this.interaction = { type: "builder", mode: erase ? "erase" : "merge", points: [p] };
@@ -5586,7 +5779,7 @@ var VectorEditorController = class {
5586
5779
  upBuilder(drag) {
5587
5780
  this.builderPreview = null;
5588
5781
  this.builderHoverKey = null;
5589
- if (!drag.touched.faces.length && !drag.touched.lines.length) {
5782
+ if (!drag.touched.faces.length && !drag.touched.lines.length && !drag.touched.edges.length) {
5590
5783
  this.notify();
5591
5784
  return;
5592
5785
  }
@@ -6250,13 +6443,19 @@ var VectorEditorController = class {
6250
6443
  }
6251
6444
  }
6252
6445
  widthView() {
6253
- return { document: this.doc, selection: this.selection, tool: this.tool, hover: this.hover, interaction: this.interaction, selectedWidthPoint: this.widthPoint };
6446
+ return { document: this.doc, selection: this.selection, tool: this.tool, hover: this.hover, interaction: this.interaction, selectedWidthPoints: this.widthPoints };
6254
6447
  }
6255
6448
  /** The width point centre or side end under `screen` (width tool). */
6256
6449
  hitWidthHandle(screen) {
6450
+ const shown = this.hitWidthHandleIn(screen, this.widthView());
6451
+ if (shown) return shown;
6452
+ const spot = this.strokeSpotAt(this.screenToDocument(screen));
6453
+ return spot && spot.nodeId !== this.hover?.nodeId ? this.hitWidthHandleIn(screen, { ...this.widthView(), hover: { nodeId: spot.nodeId } }) : null;
6454
+ }
6455
+ hitWidthHandleIn(screen, view) {
6257
6456
  let best = null;
6258
6457
  let bestD = Infinity;
6259
- for (const h of getVectorWidthHandles(this.widthView())) {
6458
+ for (const h of getVectorWidthHandles(view)) {
6260
6459
  for (const [part, q] of [["center", h], ["left", h.left], ["right", h.right]]) {
6261
6460
  const s = this.documentToScreen(q);
6262
6461
  const d = Math.hypot(s.x - screen.x, s.y - screen.y);
@@ -6309,11 +6508,10 @@ var VectorEditorController = class {
6309
6508
  const nodeId = hit?.ref.nodeId ?? spot?.nodeId;
6310
6509
  const node = nodeId ? this.getNode(nodeId) : void 0;
6311
6510
  const g = node?.type === "path" ? vectorStrokeWidthGeometry(node) : null;
6511
+ if (this.widthEdit) this.widthEdit = null;
6312
6512
  if (!node || node.type !== "path" || !g) {
6313
- if (this.widthPoint) {
6314
- this.widthPoint = null;
6315
- this.notify();
6316
- }
6513
+ if (!input.shift) this.widthPoints = [];
6514
+ this.notify();
6317
6515
  return;
6318
6516
  }
6319
6517
  const before = this.snapshot();
@@ -6322,6 +6520,8 @@ var VectorEditorController = class {
6322
6520
  let ref;
6323
6521
  let mode = "width";
6324
6522
  let side = "both";
6523
+ let group = [];
6524
+ let shift = false;
6325
6525
  let t;
6326
6526
  let subpath;
6327
6527
  if (hit) {
@@ -6329,8 +6529,20 @@ var VectorEditorController = class {
6329
6529
  ref = { nodeId: node.id, index: hit.ref.index, subpath: hit.ref.subpath };
6330
6530
  t = profile[ref.index]?.t ?? 0;
6331
6531
  subpath = hit.ref.subpath;
6332
- if (hit.ref.part === "center") mode = "move";
6532
+ if (hit.ref.part === "center") mode = input.alt && profile.length < VECTOR_WIDTH_PROFILE_MAX_POINTS ? "copy" : "move";
6333
6533
  else if (input.alt) side = hit.ref.part;
6534
+ const same = this.widthPoints.length > 0 && this.widthPoints[0].nodeId === node.id;
6535
+ const selected = same && this.widthPoints.some((r) => r.index === ref.index);
6536
+ if (input.shift && mode !== "copy") {
6537
+ if (selected) {
6538
+ this.widthPoints = this.widthPoints.filter((r) => r.index !== ref.index);
6539
+ this.notify();
6540
+ return;
6541
+ }
6542
+ this.widthPoints = same ? [...this.widthPoints, ref] : [ref];
6543
+ shift = true;
6544
+ } else if (mode === "copy" || !selected) this.widthPoints = [ref];
6545
+ if (mode !== "copy") group = this.widthPoints.filter((r) => r.index !== ref.index).map((r) => r.index);
6334
6546
  } else {
6335
6547
  const near2 = nearestStrokePosition(g.line, [local.x, local.y]);
6336
6548
  const inserted = insertWidthPoint(strokeWidthProfileOf(node), near2.t);
@@ -6339,17 +6551,21 @@ var VectorEditorController = class {
6339
6551
  t = near2.t;
6340
6552
  ref = { nodeId: node.id, index: inserted.index, subpath };
6341
6553
  if (input.alt) side = near2.side;
6554
+ this.widthPoints = [ref];
6342
6555
  }
6343
6556
  const at2 = strokePositionPoint(g.line, subpath, t);
6344
6557
  if (!at2) return;
6345
6558
  const base = { ...this.doc, nodes: mapTree(this.doc.nodes, (n) => n.id === node.id ? { ...n, strokeWidthProfile: profile } : n) };
6346
6559
  this.doc = base;
6347
- this.widthPoint = { nodeId: node.id, index: ref.index, subpath };
6348
6560
  this.drag = {
6349
6561
  kind: "width",
6350
6562
  ref,
6351
6563
  mode,
6352
6564
  side,
6565
+ source: ref.index,
6566
+ group,
6567
+ shift,
6568
+ renumber: null,
6353
6569
  origin: { x: at2.point[0], y: at2.point[1] },
6354
6570
  normal: { x: at2.tangent[1], y: -at2.tangent[0] },
6355
6571
  unit: g.unit,
@@ -6364,6 +6580,31 @@ var VectorEditorController = class {
6364
6580
  this.interaction = { type: "width", ref, mode, width: (w.left + w.right) * g.unit };
6365
6581
  this.notify(!hit);
6366
6582
  }
6583
+ /**
6584
+ * A width-tool slide to `t` on the dragged point's subpath lands exactly on another width
6585
+ * point (not `skip`) within the pick distance on screen: dropping a point onto another makes
6586
+ * a discontinuous point (two points at one `t`, the width steps there).
6587
+ */
6588
+ snapWidthT(drag, node, line2, t, skip) {
6589
+ const m = this.worldOf(node.id, drag.base);
6590
+ const at2 = strokePositionPoint(line2, drag.ref.subpath, t);
6591
+ if (!at2) return t;
6592
+ const here = this.documentToScreen(applyMatrix(m, { x: at2.point[0], y: at2.point[1] }));
6593
+ let best = t;
6594
+ let bestD = this.hitRadius;
6595
+ drag.profile.forEach((w, i) => {
6596
+ if (i === skip || drag.group.includes(i)) return;
6597
+ const q = strokePositionPoint(line2, drag.ref.subpath, w.t);
6598
+ if (!q) return;
6599
+ const s = this.documentToScreen(applyMatrix(m, { x: q.point[0], y: q.point[1] }));
6600
+ const d = Math.hypot(s.x - here.x, s.y - here.y);
6601
+ if (d <= bestD) {
6602
+ best = w.t;
6603
+ bestD = d;
6604
+ }
6605
+ });
6606
+ return best;
6607
+ }
6367
6608
  /** The nearest outline point of any visible, unlocked path within the pick distance of document point `p`. */
6368
6609
  nearestOutline(p) {
6369
6610
  let best = null;
@@ -6848,19 +7089,35 @@ var VectorEditorController = class {
6848
7089
  drag.moved = true;
6849
7090
  const node = findVectorNode(drag.base.nodes, drag.ref.nodeId);
6850
7091
  const local = this.toLocal(node.id, p, drag.base);
7092
+ const src = drag.profile[drag.source];
6851
7093
  let profile;
6852
- if (drag.mode === "move") {
7094
+ let index = drag.source;
7095
+ if (drag.mode !== "width") {
6853
7096
  const g = vectorStrokeWidthGeometry(node);
6854
7097
  const near2 = g ? nearestStrokePosition(g.line, [local.x, local.y], drag.ref.subpath) : null;
6855
- if (!near2) return;
6856
- profile = setWidthPoint(drag.profile, drag.ref.index, { t: near2.t });
7098
+ if (!g || !near2) return;
7099
+ const t = drag.group.length ? near2.t : this.snapWidthT(drag, node, g.line, near2.t, drag.source);
7100
+ if (drag.mode === "copy") {
7101
+ ({ points: profile, index } = insertWidthPoint(drag.profile, t, { left: src.left, right: src.right }, { step: true }));
7102
+ } else if (drag.group.length) {
7103
+ const shifted = shiftWidthPoints(drag.profile, [drag.source, ...drag.group], t - src.t);
7104
+ profile = shifted.points;
7105
+ drag.renumber = shifted.renumber;
7106
+ index = shifted.renumber.get(drag.source) ?? drag.source;
7107
+ } else profile = setWidthPoint(drag.profile, drag.source, { t });
6857
7108
  } else {
6858
7109
  const off = ((local.x - drag.origin.x) * drag.normal.x + (local.y - drag.origin.y) * drag.normal.y) / drag.unit;
6859
7110
  const patch = drag.side === "both" ? { left: Math.abs(off), right: Math.abs(off) } : drag.side === "left" ? { left: Math.max(0, off) } : { right: Math.max(0, -off) };
6860
- profile = setWidthPoint(drag.profile, drag.ref.index, patch);
7111
+ profile = setWidthPoint(drag.profile, drag.source, patch);
7112
+ if (drag.group.length) {
7113
+ const dl = profile[drag.source].left - src.left;
7114
+ const dr = profile[drag.source].right - src.right;
7115
+ for (const i of drag.group) profile = setWidthPoint(profile, i, { left: Math.max(0, drag.profile[i].left + dl), right: Math.max(0, drag.profile[i].right + dr) });
7116
+ }
6861
7117
  }
6862
7118
  this.doc = { ...drag.base, nodes: mapTree(drag.base.nodes, (n) => n.id === node.id ? { ...n, strokeWidthProfile: profile } : n) };
6863
- const w = profile[drag.ref.index];
7119
+ drag.ref = { ...drag.ref, index };
7120
+ const w = profile[index];
6864
7121
  this.interaction = { type: "width", ref: drag.ref, mode: drag.mode, width: (w.left + w.right) * drag.unit };
6865
7122
  this.notify(true);
6866
7123
  return;
@@ -7006,12 +7263,23 @@ var VectorEditorController = class {
7006
7263
  return;
7007
7264
  }
7008
7265
  case "width": {
7009
- if (drag.moved) return this.commit(drag.mode === "move" ? "Move Width Point" : "Width Point", drag.before);
7266
+ if (drag.moved) {
7267
+ const several = drag.group.length > 0;
7268
+ if (drag.mode === "copy") this.widthPoints = [drag.ref];
7269
+ else if (drag.mode === "move" && several) {
7270
+ this.widthPoints = [drag.source, ...drag.group].map((i) => ({ ...drag.ref, index: drag.renumber?.get(i) ?? i }));
7271
+ } else if (!several) this.widthPoints = [drag.ref];
7272
+ const label = drag.mode === "copy" ? "Copy Width Point" : drag.mode === "move" ? several ? "Move Width Points" : "Move Width Point" : several ? "Width Points" : "Width Point";
7273
+ return this.commit(label, drag.before);
7274
+ }
7010
7275
  if (drag.created) {
7011
7276
  this.doc = drag.before.doc;
7012
- this.widthPoint = null;
7277
+ this.widthPoints = [];
7013
7278
  this.notify(true);
7014
- } else this.notify();
7279
+ } else {
7280
+ if (!drag.shift && drag.group.length) this.widthPoints = [drag.ref];
7281
+ this.notify();
7282
+ }
7015
7283
  return;
7016
7284
  }
7017
7285
  case "builder":
@@ -7100,7 +7368,10 @@ var VectorEditorController = class {
7100
7368
  }
7101
7369
  this.restore(drag.before);
7102
7370
  }
7103
- /** Double click: select tool enters path editing, direct tool toggles an anchor or adds one. */
7371
+ /**
7372
+ * Double click: select tool enters path editing, direct tool toggles an anchor or adds one,
7373
+ * width tool opens the width-point editor (`widthPointEdit`).
7374
+ */
7104
7375
  doubleClick(screen) {
7105
7376
  const p = this.screenToDocument(screen);
7106
7377
  if (this.tool === "select") {
@@ -7128,6 +7399,16 @@ var VectorEditorController = class {
7128
7399
  }
7129
7400
  return;
7130
7401
  }
7402
+ if (this.tool === "width") {
7403
+ const hit = this.hitWidthHandle(screen);
7404
+ if (hit) {
7405
+ this.openWidthPointEdit(hit.ref);
7406
+ return;
7407
+ }
7408
+ const spot = this.strokeSpotAt(p);
7409
+ if (spot) this.openWidthPointEdit({ nodeId: spot.nodeId, subpath: spot.subpath, t: spot.t });
7410
+ return;
7411
+ }
7131
7412
  if (this.tool === "pen") this.finishPath();
7132
7413
  }
7133
7414
  // -------------------------------------------------------------------------
@@ -7188,8 +7469,9 @@ var VectorEditorController = class {
7188
7469
  switch (key) {
7189
7470
  case "Escape":
7190
7471
  if (this.drag) this.cancelDrag();
7191
- else if (this.tool === "width" && this.widthPoint) {
7192
- this.widthPoint = null;
7472
+ else if (this.widthEdit) this.closeWidthPointEdit();
7473
+ else if (this.tool === "width" && this.widthPoints.length) {
7474
+ this.widthPoints = [];
7193
7475
  this.notify();
7194
7476
  } else if (this.pen) this.finishPath();
7195
7477
  else if (this.tool === "direct" && this.anchors.length) {
@@ -7212,7 +7494,7 @@ var VectorEditorController = class {
7212
7494
  return false;
7213
7495
  case "Delete":
7214
7496
  case "Backspace":
7215
- if (this.tool === "width" && this.widthPoint) return this.removeWidthPoint(this.widthPoint).ok;
7497
+ if (this.tool === "width" && this.widthPoints.length) return this.removeWidthPoints().ok;
7216
7498
  if (this.pen) {
7217
7499
  const node = this.getNode(this.pen.nodeId);
7218
7500
  const sp = node.subpaths[this.pen.subpath];
@@ -7323,6 +7605,7 @@ function pencilPressureProfile(points, pressures) {
7323
7605
  // src/vector/render.ts
7324
7606
  var fmt = (n) => Math.round(n * 100) / 100;
7325
7607
  var fmtMatrix = (m) => `matrix(${m.map((n) => Math.round(n * 1e6) / 1e6).join(" ")})`;
7608
+ var VECTOR_BUILDER_HATCH_GAP = 6;
7326
7609
  function renderNode(node, defs, idPrefix) {
7327
7610
  if (node.visible === false) return null;
7328
7611
  if (node.type === "group") {
@@ -7496,14 +7779,22 @@ function getVectorOverlayRenderData(state) {
7496
7779
  });
7497
7780
  const spot = state.tool === "width" && !it ? state.hover?.strokeSpot : null;
7498
7781
  const widthSpot = spot ? { ...sc(spot.point), left: sc(spot.left), right: sc(spot.right) } : null;
7499
- if (it?.type === "width") status = it.mode === "move" ? "Move width point" : `W ${fmt(it.width)}`;
7782
+ if (it?.type === "width") status = it.mode === "move" ? "Move width point" : it.mode === "copy" ? "Copy width point" : `W ${fmt(it.width)}`;
7500
7783
  let builder = null;
7501
7784
  if (state.builder) {
7502
7785
  const v2 = state.viewport;
7503
7786
  const view = [v2.zoom, 0, 0, v2.zoom, v2.x, v2.y];
7504
- const d = (path) => bezierPathToSvg(transformBezierPath(path, view), { precision: 2 });
7787
+ const svg = (path) => bezierPathToSvg(path, { precision: 2 });
7788
+ const screen = state.builder.faces.map((f) => transformBezierPath(f, view));
7789
+ const hatch = state.builder.mode !== "fill";
7505
7790
  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;
7506
- builder = { mode: state.builder.mode, faces: state.builder.faces.map(d), lines: state.builder.lines.map(d), trail };
7791
+ builder = {
7792
+ mode: state.builder.mode,
7793
+ faces: screen.map(svg),
7794
+ hatches: screen.map((f) => hatch ? svg(hatchPath(f, { gap: VECTOR_BUILDER_HATCH_GAP })) : ""),
7795
+ lines: state.builder.lines.map((l) => svg(transformBezierPath(l, view))),
7796
+ trail
7797
+ };
7507
7798
  if (state.builder.mode !== "fill" && it?.type === "builder") {
7508
7799
  const n = state.builder.faces.length;
7509
7800
  status = state.builder.mode === "erase" ? `Delete ${n} ${n === 1 ? "face" : "faces"}` : `Merge ${n} ${n === 1 ? "face" : "faces"}`;
@@ -7530,7 +7821,9 @@ var VECTOR_OVERLAY_THEME = {
7530
7821
  builderFill: "rgba(14, 165, 233, 0.22)",
7531
7822
  /** Shape Builder faces and line pieces an Alt gesture deletes. */
7532
7823
  builderErase: "#ef4444",
7533
- builderEraseFill: "rgba(239, 68, 68, 0.22)"
7824
+ builderEraseFill: "rgba(239, 68, 68, 0.22)",
7825
+ /** Opacity of the hatch lines over Shape Builder faces (drawn in the face's outline colour). */
7826
+ builderHatchOpacity: 0.6
7534
7827
  };
7535
7828
  function vectorDiamondPath(x, y, r = VECTOR_OVERLAY_THEME.widthPointSize) {
7536
7829
  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`;
@@ -7905,6 +8198,31 @@ function createVectorCommands(controller, options = {}) {
7905
8198
  return { ok: true, points: node?.type === "path" ? node.strokeWidthProfile ?? [] : [] };
7906
8199
  }
7907
8200
  },
8201
+ {
8202
+ ...base,
8203
+ id: `${prefix}setWidthPoint`,
8204
+ label: "Width Point\u2026",
8205
+ keywords: ["width point", "variable width", "stroke width at", "taper", "profile"],
8206
+ description: "Sets one width point of a path's stroke profile numerically: `index` names an existing point (see `vector.setWidthProfile`'s result), leave it out to add one at `t`. `left` / `right` are side widths in document units (how far the stroke reaches on each side), `t` the position along each subpath (0\u20131); a point moved onto another's `t` makes a step. Returns the profile.",
8207
+ params: {
8208
+ type: "object",
8209
+ properties: {
8210
+ id: { type: "string", description: "The path." },
8211
+ index: { type: "integer", minimum: 0 },
8212
+ t: { type: "number", minimum: 0, maximum: 1 },
8213
+ left: { type: "number", minimum: 0 },
8214
+ right: { type: "number", minimum: 0 }
8215
+ },
8216
+ required: ["id"]
8217
+ },
8218
+ undoable: true,
8219
+ run: (_ctx, params) => {
8220
+ if (!params?.id) fail("Pass the path `id`.");
8221
+ check(controller.setWidthPoint({ nodeId: params.id, index: params.index ?? null }, { t: params.t, left: params.left, right: params.right }));
8222
+ const node = controller.getNode(params.id);
8223
+ return { ok: true, points: node?.type === "path" ? node.strokeWidthProfile ?? [] : [] };
8224
+ }
8225
+ },
7908
8226
  {
7909
8227
  ...base,
7910
8228
  id: `${prefix}outlineStroke`,
@@ -8006,7 +8324,7 @@ function createVectorCommands(controller, options = {}) {
8006
8324
  id: `${prefix}shapeBuilder`,
8007
8325
  label: "Shape Builder",
8008
8326
  keywords: ["merge faces", "build shape", "combine regions", "delete region"],
8009
- 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 and the pieces of unfilled open paths within `tolerance`. Shapes covering those faces lose that area.",
8327
+ 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.",
8010
8328
  params: {
8011
8329
  type: "object",
8012
8330
  properties: { points: POINTS, erase: { type: "boolean" }, tolerance: { type: "number", minimum: 0 }, ids: IDS },
@@ -8016,7 +8334,7 @@ function createVectorCommands(controller, options = {}) {
8016
8334
  run: (_ctx, params) => {
8017
8335
  const result = controller.shapeBuilder(params.points.map(([x, y]) => ({ x, y })), { ids: ids(params), erase: params.erase, tolerance: params.tolerance });
8018
8336
  check(result);
8019
- return { merged: result.merged, faces: result.faces, lines: result.lines };
8337
+ return { merged: result.merged, faces: result.faces, lines: result.lines, edges: result.edges };
8020
8338
  }
8021
8339
  },
8022
8340
  {
@@ -8331,6 +8649,7 @@ export {
8331
8649
  LazyMouse,
8332
8650
  VECTOR_ARROWHEADS,
8333
8651
  VECTOR_ARROWHEAD_LABELS,
8652
+ VECTOR_BUILDER_HATCH_GAP,
8334
8653
  VECTOR_CLIPBOARD_TYPE,
8335
8654
  VECTOR_CONSTRUCTION_ANGLES,
8336
8655
  VECTOR_CORNER_KINDS,
@@ -8481,6 +8800,7 @@ export {
8481
8800
  unionBounds,
8482
8801
  variableStrokeOutline,
8483
8802
  variableStrokeRings,
8803
+ vectorBuilderErasedEdges,
8484
8804
  vectorCornerAt,
8485
8805
  vectorCorners,
8486
8806
  vectorCornersAttribute,