@pixerate/editor 0.38.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.
@@ -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"];
@@ -3815,29 +3817,48 @@ function vectorShapeBuilderMap(entries) {
3815
3817
  });
3816
3818
  const map = PlanarMap.build(inputs);
3817
3819
  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) };
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) };
3821
3824
  });
3822
- return { entries: [...entries], map, lines, pieces };
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
+ };
3823
3842
  }
3824
- function vectorShapeBuilderHit(m, p, lineTolerance = null) {
3843
+ function pieceNear(pieces, p, tol) {
3825
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) {
3826
3855
  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 };
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 };
3839
3860
  }
3840
- const face = m.map.faceAt(point);
3861
+ const face = m.map.faceAt([p.x, p.y]);
3841
3862
  return face >= 0 ? { face } : null;
3842
3863
  }
3843
3864
  function samplePolyline(points, step) {
@@ -3855,7 +3876,7 @@ function samplePolyline(points, step) {
3855
3876
  }
3856
3877
  function addBuilderHit(touched, hit) {
3857
3878
  if (!hit) return false;
3858
- const [list, i] = "face" in hit ? [touched.faces, hit.face] : [touched.lines, hit.line];
3879
+ const [list, i] = "face" in hit ? [touched.faces, hit.face] : "line" in hit ? [touched.lines, hit.line] : [touched.edges, hit.edge];
3859
3880
  if (list.includes(i)) return false;
3860
3881
  list.push(i);
3861
3882
  return true;
@@ -3868,12 +3889,18 @@ function sides(m, piece) {
3868
3889
  const off = Math.max(1e-6, Math.min(0.05, 0.05 * chord));
3869
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])];
3870
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
+ }
3871
3897
  function planShapeBuilder(m, touched, erase) {
3872
3898
  const replace = /* @__PURE__ */ new Map();
3873
3899
  const picked = new Set(touched.faces);
3874
3900
  const faces = m.map.faces;
3875
3901
  const sourcesOf = faces.map((_, i) => m.map.faceSources(i));
3876
- m.entries.forEach((_, i) => {
3902
+ const cuts = erase ? vectorBuilderErasedEdges(m, touched).map((k) => m.edges[k]) : [];
3903
+ m.entries.forEach((entry, i) => {
3877
3904
  if (m.lines[i]) {
3878
3905
  const own = m.pieces.flatMap((piece, k) => piece.source === i ? [k] : []);
3879
3906
  const keep = own.filter((k) => !touched.lines.includes(k) && !sides(m, m.pieces[k]).every((f) => f >= 0 && picked.has(f)));
@@ -3881,10 +3908,24 @@ function planShapeBuilder(m, touched, erase) {
3881
3908
  replace.set(i, keep.length ? { kind: "pieces", paths: keep.map((k) => m.pieces[k].path) } : null);
3882
3909
  return;
3883
3910
  }
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);
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);
3888
3929
  });
3889
3930
  let merged = null;
3890
3931
  if (!erase && touched.faces.length) {
@@ -5426,20 +5467,23 @@ var VectorEditorController = class {
5426
5467
  * Shape Builder gesture along `points` (document units; one point is a click) over the faces
5427
5468
  * the selected (or given) paths make. Merges the faces touched into one shape styled like the
5428
5469
  * 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.
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.
5431
5475
  */
5432
5476
  shapeBuilder(points, options = {}) {
5433
- const none = (message) => ({ ok: false, message, merged: null, faces: 0, lines: 0 });
5477
+ const none = (message) => ({ ok: false, message, merged: null, faces: 0, lines: 0, edges: 0 });
5434
5478
  if (!points.length || !points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) return none("Give the points of the gesture.");
5435
5479
  const m = this.shapeBuilderMap(options.ids);
5436
5480
  if (!m) return none("Select paths that make faces.");
5437
5481
  const erase = !!options.erase;
5438
5482
  const tol = erase ? options.tolerance ?? this.builderLineTolerance() : null;
5439
- const touched = { faces: [], lines: [] };
5483
+ const touched = { faces: [], lines: [], edges: [] };
5440
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);
5441
5485
  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.");
5486
+ if (!touched.faces.length && !touched.lines.length && !touched.edges.length) return none("There is no face under the pointer.");
5443
5487
  return this.applyShapeBuilder(m, touched, erase);
5444
5488
  }
5445
5489
  applyShapeBuilder(m, touched, erase) {
@@ -5455,6 +5499,7 @@ var VectorEditorController = class {
5455
5499
  const r = plan.replace.get(i);
5456
5500
  if (r === void 0) list.push(node);
5457
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)) });
5458
5503
  else if (r?.kind === "pieces") {
5459
5504
  r.paths.forEach(
5460
5505
  (path, k) => list.push({ ...node, id: k === 0 ? node.id : this.newId(), subpaths: bezierToSubpaths(toLocal(path)).map((sp) => ({ ...sp, closed: false })) })
@@ -5485,7 +5530,7 @@ var VectorEditorController = class {
5485
5530
  this.editingId = null;
5486
5531
  });
5487
5532
  this.builderCache = null;
5488
- return { ok: true, merged: mergedId, faces: touched.faces.length, lines: touched.lines.length };
5533
+ return { ok: true, merged: mergedId, faces: touched.faces.length, lines: touched.lines.length, edges: erase ? vectorBuilderErasedEdges(m, touched).length : 0 };
5489
5534
  }
5490
5535
  /** The paint bucket's map of the visible outlines, cached per document and gap. */
5491
5536
  fillMap(gap) {
@@ -5542,7 +5587,9 @@ var VectorEditorController = class {
5542
5587
  const key = hit ? `${alt ? "erase" : "merge"}:${JSON.stringify(hit)}` : null;
5543
5588
  if (key === this.builderHoverKey) return false;
5544
5589
  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;
5590
+ const touched = { faces: [], lines: [], edges: [] };
5591
+ addBuilderHit(touched, hit);
5592
+ this.builderPreview = hit && m ? this.builderPreviewOf(m, touched, alt, null) : null;
5546
5593
  return true;
5547
5594
  }
5548
5595
  builderPreviewOf(m, touched, erase, trail) {
@@ -5550,7 +5597,8 @@ var VectorEditorController = class {
5550
5597
  return {
5551
5598
  mode: erase ? "erase" : "merge",
5552
5599
  faces: touched.faces.map((f) => faces[f].path),
5553
- lines: touched.lines.map((k) => m.pieces[k].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) : []],
5554
5602
  trail
5555
5603
  };
5556
5604
  }
@@ -5566,7 +5614,7 @@ var VectorEditorController = class {
5566
5614
  const m = this.selectedRoots().length ? this.shapeBuilderMap() : null;
5567
5615
  if (!m) return;
5568
5616
  const erase = !!input.alt;
5569
- const touched = { faces: [], lines: [] };
5617
+ const touched = { faces: [], lines: [], edges: [] };
5570
5618
  addBuilderHit(touched, vectorShapeBuilderHit(m, p, erase ? this.builderLineTolerance() : null));
5571
5619
  this.drag = { kind: "builder", erase, points: [p], touched, map: m };
5572
5620
  this.interaction = { type: "builder", mode: erase ? "erase" : "merge", points: [p] };
@@ -5586,7 +5634,7 @@ var VectorEditorController = class {
5586
5634
  upBuilder(drag) {
5587
5635
  this.builderPreview = null;
5588
5636
  this.builderHoverKey = null;
5589
- if (!drag.touched.faces.length && !drag.touched.lines.length) {
5637
+ if (!drag.touched.faces.length && !drag.touched.lines.length && !drag.touched.edges.length) {
5590
5638
  this.notify();
5591
5639
  return;
5592
5640
  }
@@ -7323,6 +7371,7 @@ function pencilPressureProfile(points, pressures) {
7323
7371
  // src/vector/render.ts
7324
7372
  var fmt = (n) => Math.round(n * 100) / 100;
7325
7373
  var fmtMatrix = (m) => `matrix(${m.map((n) => Math.round(n * 1e6) / 1e6).join(" ")})`;
7374
+ var VECTOR_BUILDER_HATCH_GAP = 6;
7326
7375
  function renderNode(node, defs, idPrefix) {
7327
7376
  if (node.visible === false) return null;
7328
7377
  if (node.type === "group") {
@@ -7501,9 +7550,17 @@ function getVectorOverlayRenderData(state) {
7501
7550
  if (state.builder) {
7502
7551
  const v2 = state.viewport;
7503
7552
  const view = [v2.zoom, 0, 0, v2.zoom, v2.x, v2.y];
7504
- const d = (path) => bezierPathToSvg(transformBezierPath(path, view), { precision: 2 });
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";
7505
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;
7506
- builder = { mode: state.builder.mode, faces: state.builder.faces.map(d), lines: state.builder.lines.map(d), trail };
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
+ };
7507
7564
  if (state.builder.mode !== "fill" && it?.type === "builder") {
7508
7565
  const n = state.builder.faces.length;
7509
7566
  status = state.builder.mode === "erase" ? `Delete ${n} ${n === 1 ? "face" : "faces"}` : `Merge ${n} ${n === 1 ? "face" : "faces"}`;
@@ -7530,7 +7587,9 @@ var VECTOR_OVERLAY_THEME = {
7530
7587
  builderFill: "rgba(14, 165, 233, 0.22)",
7531
7588
  /** Shape Builder faces and line pieces an Alt gesture deletes. */
7532
7589
  builderErase: "#ef4444",
7533
- builderEraseFill: "rgba(239, 68, 68, 0.22)"
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
7534
7593
  };
7535
7594
  function vectorDiamondPath(x, y, r = VECTOR_OVERLAY_THEME.widthPointSize) {
7536
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`;
@@ -8006,7 +8065,7 @@ function createVectorCommands(controller, options = {}) {
8006
8065
  id: `${prefix}shapeBuilder`,
8007
8066
  label: "Shape Builder",
8008
8067
  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.",
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.",
8010
8069
  params: {
8011
8070
  type: "object",
8012
8071
  properties: { points: POINTS, erase: { type: "boolean" }, tolerance: { type: "number", minimum: 0 }, ids: IDS },
@@ -8016,7 +8075,7 @@ function createVectorCommands(controller, options = {}) {
8016
8075
  run: (_ctx, params) => {
8017
8076
  const result = controller.shapeBuilder(params.points.map(([x, y]) => ({ x, y })), { ids: ids(params), erase: params.erase, tolerance: params.tolerance });
8018
8077
  check(result);
8019
- return { merged: result.merged, faces: result.faces, lines: result.lines };
8078
+ return { merged: result.merged, faces: result.faces, lines: result.lines, edges: result.edges };
8020
8079
  }
8021
8080
  },
8022
8081
  {
@@ -8331,6 +8390,7 @@ export {
8331
8390
  LazyMouse,
8332
8391
  VECTOR_ARROWHEADS,
8333
8392
  VECTOR_ARROWHEAD_LABELS,
8393
+ VECTOR_BUILDER_HATCH_GAP,
8334
8394
  VECTOR_CLIPBOARD_TYPE,
8335
8395
  VECTOR_CONSTRUCTION_ANGLES,
8336
8396
  VECTOR_CORNER_KINDS,
@@ -8481,6 +8541,7 @@ export {
8481
8541
  unionBounds,
8482
8542
  variableStrokeOutline,
8483
8543
  variableStrokeRings,
8544
+ vectorBuilderErasedEdges,
8484
8545
  vectorCornerAt,
8485
8546
  vectorCorners,
8486
8547
  vectorCornersAttribute,