@pixerate/editor 0.194.0 → 0.196.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.
@@ -2497,6 +2497,312 @@ function pathToSelection(components, width, height, options = {}) {
2497
2497
  return feather > 0 && mask.bounds ? modifySelection(mask, "feather", feather) : mask;
2498
2498
  }
2499
2499
 
2500
+ // src/image-editor/selection/magnetic.ts
2501
+ var BLOCK = 64;
2502
+ var W_ZERO = 0.3;
2503
+ var W_GRADIENT = 0.4;
2504
+ var W_DIRECTION = 0.3;
2505
+ var EdgeMap = class {
2506
+ width;
2507
+ height;
2508
+ contrast;
2509
+ luma;
2510
+ blocks = /* @__PURE__ */ new Map();
2511
+ constructor(pixels, options = {}) {
2512
+ const { width, height, data } = pixels;
2513
+ if (!(width >= 1 && height >= 1) || data.length < width * height * 4) throw new Error("Invalid pixel buffer for edge detection.");
2514
+ this.width = width;
2515
+ this.height = height;
2516
+ this.contrast = Math.min(1, Math.max(0, options.contrast ?? 0.1));
2517
+ const luma2 = new Uint8Array(width * height);
2518
+ for (let i = 0, p = 0; i < luma2.length; i++, p += 4) {
2519
+ const a = data[p + 3] / 255;
2520
+ const y = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
2521
+ luma2[i] = Math.round(y * a + 255 * (1 - a));
2522
+ }
2523
+ this.luma = luma2;
2524
+ }
2525
+ /** Edge strength at (x, y) in whole pixels, 0..1 (0 outside the image or below the contrast). */
2526
+ strength(x, y) {
2527
+ if (x < 0 || y < 0 || x >= this.width || y >= this.height) return 0;
2528
+ const b = this.block(x, y);
2529
+ return b.strength[y % BLOCK * BLOCK + x % BLOCK];
2530
+ }
2531
+ block(x, y) {
2532
+ const bx = Math.floor(x / BLOCK);
2533
+ const by = Math.floor(y / BLOCK);
2534
+ const key = by * 65536 + bx;
2535
+ let b = this.blocks.get(key);
2536
+ if (!b) {
2537
+ b = this.computeBlock(bx * BLOCK, by * BLOCK);
2538
+ this.blocks.set(key, b);
2539
+ }
2540
+ return b;
2541
+ }
2542
+ computeBlock(x0, y0) {
2543
+ const { width: W, height: H, luma: luma2 } = this;
2544
+ const M = 2;
2545
+ const S = BLOCK + 2 * M;
2546
+ const smooth = new Float32Array(S * S);
2547
+ const at = (x, y) => luma2[Math.min(H - 1, Math.max(0, y)) * W + Math.min(W - 1, Math.max(0, x))];
2548
+ for (let j = 0; j < S; j++) {
2549
+ const y = y0 - M + j;
2550
+ for (let i = 0; i < S; i++) {
2551
+ const x = x0 - M + i;
2552
+ const v = at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1) + 2 * at(x - 1, y) + 4 * at(x, y) + 2 * at(x + 1, y) + at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1);
2553
+ smooth[j * S + i] = v / (16 * 255);
2554
+ }
2555
+ }
2556
+ const lap = new Float32Array(S * S);
2557
+ for (let j = 1; j < S - 1; j++) {
2558
+ for (let i = 1; i < S - 1; i++) {
2559
+ const k = j * S + i;
2560
+ lap[k] = smooth[k - 1] + smooth[k + 1] + smooth[k - S] + smooth[k + S] - 4 * smooth[k];
2561
+ }
2562
+ }
2563
+ const n = BLOCK * BLOCK;
2564
+ const strength = new Float32Array(n);
2565
+ const dirX = new Float32Array(n);
2566
+ const dirY = new Float32Array(n);
2567
+ const zero = new Uint8Array(n);
2568
+ const c = this.contrast;
2569
+ for (let j = 0; j < BLOCK; j++) {
2570
+ if (y0 + j >= H) break;
2571
+ for (let i = 0; i < BLOCK; i++) {
2572
+ if (x0 + i >= W) break;
2573
+ const k = (j + M) * S + (i + M);
2574
+ const gx = (smooth[k + 1] - smooth[k - 1]) / 2;
2575
+ const gy = (smooth[k + S] - smooth[k - S]) / 2;
2576
+ const g = Math.hypot(gx, gy);
2577
+ const e = Math.min(1, 2 * g);
2578
+ if (e < c || g === 0) continue;
2579
+ const o = j * BLOCK + i;
2580
+ strength[o] = e;
2581
+ dirX[o] = gy / g;
2582
+ dirY[o] = -gx / g;
2583
+ const l = lap[k];
2584
+ for (const d of [1, -1, S, -S]) {
2585
+ const m = lap[k + d];
2586
+ if (l > 0 && m < 0 || l < 0 && m > 0 || l === 0) {
2587
+ if (Math.abs(l) <= Math.abs(m)) {
2588
+ zero[o] = 1;
2589
+ break;
2590
+ }
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ return { strength, dirX, dirY, zero };
2596
+ }
2597
+ /** Features of the pixel at (x, y), which must be inside the image. */
2598
+ features(x, y) {
2599
+ const b = this.block(x, y);
2600
+ const o = y % BLOCK * BLOCK + x % BLOCK;
2601
+ return { strength: b.strength[o], dirX: b.dirX[o], dirY: b.dirY[o], zero: b.zero[o] };
2602
+ }
2603
+ };
2604
+ var clampPixel = (v, max) => Math.min(max - 1, Math.max(0, Math.floor(v)));
2605
+ function snapToEdge(map, point, width = 10) {
2606
+ const cx = clampPixel(point.x, map.width);
2607
+ const cy = clampPixel(point.y, map.height);
2608
+ const r = Math.max(0, Math.round(width));
2609
+ let best = 0;
2610
+ let bestD = Infinity;
2611
+ let bx = cx;
2612
+ let by = cy;
2613
+ for (let y = Math.max(0, cy - r); y <= Math.min(map.height - 1, cy + r); y++) {
2614
+ for (let x = Math.max(0, cx - r); x <= Math.min(map.width - 1, cx + r); x++) {
2615
+ const d = (x - cx) ** 2 + (y - cy) ** 2;
2616
+ if (d > r * r) continue;
2617
+ const s = map.strength(x, y);
2618
+ if (s > best + 0.02 || s > 0 && Math.abs(s - best) <= 0.02 && d < bestD) {
2619
+ if (s > best) best = s;
2620
+ bestD = d;
2621
+ bx = x;
2622
+ by = y;
2623
+ }
2624
+ }
2625
+ }
2626
+ return best > 0 ? { x: bx + 0.5, y: by + 0.5 } : point;
2627
+ }
2628
+ var Heap = class {
2629
+ ids = new Int32Array(256);
2630
+ keys = new Float64Array(256);
2631
+ size = 0;
2632
+ push(id, key) {
2633
+ if (this.size === this.ids.length) {
2634
+ const ids = new Int32Array(this.size * 2);
2635
+ ids.set(this.ids);
2636
+ const keys = new Float64Array(this.size * 2);
2637
+ keys.set(this.keys);
2638
+ this.ids = ids;
2639
+ this.keys = keys;
2640
+ }
2641
+ let i = this.size++;
2642
+ while (i > 0) {
2643
+ const p = i - 1 >> 1;
2644
+ if (this.keys[p] <= key) break;
2645
+ this.ids[i] = this.ids[p];
2646
+ this.keys[i] = this.keys[p];
2647
+ i = p;
2648
+ }
2649
+ this.ids[i] = id;
2650
+ this.keys[i] = key;
2651
+ }
2652
+ /** Removes the cheapest entry; read it from `topId` / `topKey` first. */
2653
+ pop() {
2654
+ const n = --this.size;
2655
+ const id = this.ids[n];
2656
+ const key = this.keys[n];
2657
+ let i = 0;
2658
+ for (; ; ) {
2659
+ let c = 2 * i + 1;
2660
+ if (c >= n) break;
2661
+ if (c + 1 < n && this.keys[c + 1] < this.keys[c]) c++;
2662
+ if (this.keys[c] >= key) break;
2663
+ this.ids[i] = this.ids[c];
2664
+ this.keys[i] = this.keys[c];
2665
+ i = c;
2666
+ }
2667
+ this.ids[i] = id;
2668
+ this.keys[i] = key;
2669
+ }
2670
+ get topId() {
2671
+ return this.ids[0];
2672
+ }
2673
+ get topKey() {
2674
+ return this.keys[0];
2675
+ }
2676
+ };
2677
+ var NEIGHBOURS = [
2678
+ [1, 0],
2679
+ [-1, 0],
2680
+ [0, 1],
2681
+ [0, -1],
2682
+ [1, 1],
2683
+ [1, -1],
2684
+ [-1, 1],
2685
+ [-1, -1]
2686
+ ];
2687
+ function liveWire(map, from, to, band) {
2688
+ const ax = clampPixel(from.x, map.width);
2689
+ const ay = clampPixel(from.y, map.height);
2690
+ const bx = clampPixel(to.x, map.width);
2691
+ const by = clampPixel(to.y, map.height);
2692
+ if (ax === bx && ay === by) return [{ x: ax + 0.5, y: ay + 0.5 }];
2693
+ const len = Math.hypot(bx - ax, by - ay);
2694
+ const half = Math.max(2, band ?? Math.max(16, len / 4));
2695
+ const rx0 = Math.max(0, Math.floor(Math.min(ax, bx) - half));
2696
+ const ry0 = Math.max(0, Math.floor(Math.min(ay, by) - half));
2697
+ const rx1 = Math.min(map.width - 1, Math.ceil(Math.max(ax, bx) + half));
2698
+ const ry1 = Math.min(map.height - 1, Math.ceil(Math.max(ay, by) + half));
2699
+ const w = rx1 - rx0 + 1;
2700
+ const h = ry1 - ry0 + 1;
2701
+ const n = w * h;
2702
+ const strength = new Float32Array(n);
2703
+ const dirX = new Float32Array(n);
2704
+ const dirY = new Float32Array(n);
2705
+ const zero = new Uint8Array(n);
2706
+ const inside = new Uint8Array(n);
2707
+ let maxStrength = 0;
2708
+ const ux = (bx - ax) / len;
2709
+ const uy = (by - ay) / len;
2710
+ for (let y = 0; y < h; y++) {
2711
+ for (let x = 0; x < w; x++) {
2712
+ const px2 = rx0 + x - ax;
2713
+ const py = ry0 + y - ay;
2714
+ const t = Math.min(len, Math.max(0, px2 * ux + py * uy));
2715
+ if (Math.hypot(px2 - t * ux, py - t * uy) > half) continue;
2716
+ const i = y * w + x;
2717
+ inside[i] = 1;
2718
+ const f = map.features(rx0 + x, ry0 + y);
2719
+ strength[i] = f.strength;
2720
+ dirX[i] = f.dirX;
2721
+ dirY[i] = f.dirY;
2722
+ zero[i] = f.zero;
2723
+ if (f.strength > maxStrength) maxStrength = f.strength;
2724
+ }
2725
+ }
2726
+ const scale = maxStrength > 0 ? 1 / maxStrength : 0;
2727
+ const dist = new Float64Array(n).fill(Infinity);
2728
+ const prev = new Int32Array(n).fill(-1);
2729
+ const done = new Uint8Array(n);
2730
+ const start = (ay - ry0) * w + (ax - rx0);
2731
+ const goal = (by - ry0) * w + (bx - rx0);
2732
+ dist[start] = 0;
2733
+ const heap = new Heap();
2734
+ heap.push(start, 0);
2735
+ while (heap.size) {
2736
+ const p = heap.topId;
2737
+ const d = heap.topKey;
2738
+ heap.pop();
2739
+ if (done[p] || d > dist[p]) continue;
2740
+ done[p] = 1;
2741
+ if (p === goal) break;
2742
+ const x = p % w;
2743
+ const y = (p - x) / w;
2744
+ for (const [dx, dy] of NEIGHBOURS) {
2745
+ const qx = x + dx;
2746
+ const qy = y + dy;
2747
+ if (qx < 0 || qy < 0 || qx >= w || qy >= h) continue;
2748
+ const q = qy * w + qx;
2749
+ if (!inside[q] || done[q]) continue;
2750
+ const step = dx && dy ? Math.SQRT2 : 1;
2751
+ const lx = dx / step;
2752
+ const ly = dy / step;
2753
+ const dp = Math.min(1, Math.abs(dirX[p] * lx + dirY[p] * ly));
2754
+ const dq = Math.min(1, Math.abs(dirX[q] * lx + dirY[q] * ly));
2755
+ const fD = (Math.acos(dp) + Math.acos(dq)) / Math.PI;
2756
+ const fG = 1 - strength[q] * scale;
2757
+ const fZ = 1 - zero[q];
2758
+ const cost = (W_ZERO * fZ + W_GRADIENT * fG + W_DIRECTION * fD + 0.01) * step;
2759
+ const nd = d + cost;
2760
+ if (nd < dist[q]) {
2761
+ dist[q] = nd;
2762
+ prev[q] = p;
2763
+ heap.push(q, nd);
2764
+ }
2765
+ }
2766
+ }
2767
+ if (prev[goal] < 0) return [{ x: ax + 0.5, y: ay + 0.5 }, { x: bx + 0.5, y: by + 0.5 }];
2768
+ const path = [];
2769
+ for (let p = goal; p >= 0; p = prev[p]) {
2770
+ const x = p % w;
2771
+ path.push({ x: rx0 + x + 0.5, y: ry0 + (p - x) / w + 0.5 });
2772
+ }
2773
+ return path.reverse();
2774
+ }
2775
+ function simplifyPixelPath(points) {
2776
+ if (points.length < 3) return points.slice();
2777
+ const out = [points[0]];
2778
+ for (let i = 1; i < points.length - 1; i++) {
2779
+ const a = out[out.length - 1];
2780
+ const b = points[i];
2781
+ const c = points[i + 1];
2782
+ const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
2783
+ if (Math.abs(cross) > 1e-9) out.push(b);
2784
+ }
2785
+ out.push(points[points.length - 1]);
2786
+ return out;
2787
+ }
2788
+ function snapPolygonToEdges(pixels, points, options = {}) {
2789
+ const map = pixels instanceof EdgeMap ? pixels : new EdgeMap(pixels, options);
2790
+ const width = options.width ?? 10;
2791
+ const snapped = points.map((p) => snapToEdge(map, p, width));
2792
+ const out = [];
2793
+ for (let i = 0; i < snapped.length; i++) {
2794
+ const a = snapped[i];
2795
+ const b = snapped[(i + 1) % snapped.length];
2796
+ const seg = liveWire(map, a, b, Math.max(2 * width, 16, Math.hypot(b.x - a.x, b.y - a.y) / 4));
2797
+ out.push(...seg.slice(0, -1));
2798
+ }
2799
+ return simplifyPixelPath(out);
2800
+ }
2801
+ function magneticAnchorSpacing(frequency = 57) {
2802
+ const f = Math.min(100, Math.max(0, frequency));
2803
+ return 8 + (100 - f);
2804
+ }
2805
+
2500
2806
  // src/image-editor/layers/shape-type-ui.ts
2501
2807
  var DEFAULT_SHAPE_TOOL_OPTIONS = {
2502
2808
  kind: "rect",
@@ -8432,6 +8738,15 @@ var ImageEditorController = class {
8432
8738
  const { width, height } = this.naturalSize;
8433
8739
  return this.shapeCommand(polygonSelection(width, height, points, options), options);
8434
8740
  }
8741
+ /**
8742
+ * Magnetic Lasso for a given outline: each point snaps to the strongest edge within `width` px
8743
+ * and consecutive points (the last back to the first) are joined along the edges. Returns the
8744
+ * snapped outline in natural pixels, for {@link selectPolygon}.
8745
+ */
8746
+ async snapToEdges(points, options = {}) {
8747
+ const pixels = await this.requirePixels(options);
8748
+ return snapPolygonToEdges(pixels, points, options);
8749
+ }
8435
8750
  /**
8436
8751
  * Supplies the pixels the selection tools sample (natural size RGBA), e.g. decoded in a worker
8437
8752
  * or in a test; `null` goes back to reading the source image. Without a canvas (Node, workers)
@@ -11440,6 +11755,7 @@ var SELECTION_TOOLS = [
11440
11755
  { id: "column", label: "Single Column" },
11441
11756
  { id: "lasso", label: "Lasso" },
11442
11757
  { id: "polygon", label: "Polygonal Lasso" },
11758
+ { id: "magnetic", label: "Magnetic Lasso" },
11443
11759
  { id: "range", label: "Color Range" },
11444
11760
  { id: "type", label: "Type Mask" },
11445
11761
  { id: "type-vertical", label: "Vertical Type Mask" }
@@ -11474,6 +11790,9 @@ var DEFAULT_SELECTION_TOOL_OPTIONS = {
11474
11790
  fuzziness: 40,
11475
11791
  localized: 0,
11476
11792
  rangeInvert: false,
11793
+ magneticWidth: 10,
11794
+ magneticContrast: 10,
11795
+ magneticFrequency: 57,
11477
11796
  typeText: "Type",
11478
11797
  typeSize: 72,
11479
11798
  typeFamily: "sans-serif"
@@ -11836,11 +12155,20 @@ function createImageSelectionTools(controller) {
11836
12155
  const selectPolygonTool = {
11837
12156
  name: "select_polygon",
11838
12157
  title: "Select a polygon (lasso)",
11839
- description: "Lasso: selects the polygon through points (natural image pixels, in order along the outline, closed automatically), combined by mode. To cut out a subject, follow its contour closely (more points give a tighter edge), then refine_selection for hair or foliage. One undoable step. Returns the selection.",
12158
+ description: "Lasso: selects the polygon through points (natural image pixels, in order along the outline, closed automatically), combined by mode. To cut out a subject, follow its contour closely (more points give a tighter edge), then refine_selection for hair or foliage. With snapToEdges (the Magnetic Lasso), each point snaps to the strongest edge within width px and the outline between points follows the edges of the image, so a rough outline a few dozen px per point is enough on a subject with a clear edge. One undoable step. Returns the selection (with snapToEdges, also outlinePoints: how many vertices the snapped outline has).",
11840
12159
  inputSchema: {
11841
12160
  type: "object",
11842
12161
  properties: {
11843
12162
  points: { type: "array", minItems: 3, items: pointSchema, description: "At least 3 vertices along the outline, in order, in natural image pixels." },
12163
+ snapToEdges: {
12164
+ type: "object",
12165
+ description: "Magnetic Lasso: snap the outline to the edges of the image (the composite as painted) instead of joining the points with straight lines. Omit for a plain polygon.",
12166
+ properties: {
12167
+ width: { type: "number", minimum: 1, maximum: 256, description: "Detection width in px: each point snaps to the strongest edge this close (default 10). Keep points within it of the edge." },
12168
+ contrast: { type: "number", minimum: 0.01, maximum: 1, description: "Ignore edges with less contrast than this, 0.01..1 (default 0.1). Raise it on a noisy or textured background; lower it for a faint edge." }
12169
+ },
12170
+ additionalProperties: false
12171
+ },
11844
12172
  mode: modeSchema,
11845
12173
  feather: featherSchema,
11846
12174
  antiAlias: antiAliasSchema
@@ -11849,13 +12177,19 @@ function createImageSelectionTools(controller) {
11849
12177
  additionalProperties: false
11850
12178
  },
11851
12179
  annotations: writeAnnotations("Select a polygon (lasso)", true),
11852
- handler: (args) => {
12180
+ handler: async (args) => {
11853
12181
  const list = Array.isArray(args.points) ? args.points.map((p) => ({ x: num2(p?.x, NaN), y: num2(p?.y, NaN) })) : [];
11854
12182
  if (list.length < 3 || !list.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) {
11855
12183
  throw new Error("points needs at least 3 { x, y } vertices in natural image pixels.");
11856
12184
  }
11857
- controller.selectPolygon(list, shapeOptions(args));
11858
- return summary(controller);
12185
+ const snap = args.snapToEdges;
12186
+ if (!snap) {
12187
+ controller.selectPolygon(list, shapeOptions(args));
12188
+ return summary(controller);
12189
+ }
12190
+ const outline = await controller.snapToEdges(list, { width: snap.width, contrast: snap.contrast });
12191
+ controller.selectPolygon(outline, shapeOptions(args));
12192
+ return { ...summary(controller), outlinePoints: outline.length };
11859
12193
  }
11860
12194
  };
11861
12195
  const magicWandTool = {
@@ -13792,15 +14126,22 @@ var SelectionGesture = class {
13792
14126
  polygon = null;
13793
14127
  pressedAt = null;
13794
14128
  freehand = false;
14129
+ magnetic = null;
13795
14130
  /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
13796
14131
  closeRadius = 8;
13797
14132
  /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
13798
14133
  error = null;
13799
14134
  get active() {
13800
- return this.start !== null || this.polygon !== null;
14135
+ return this.start !== null || this.polygon !== null || this.magnetic !== null;
14136
+ }
14137
+ /** The anchors of a Magnetic Lasso being drawn (empty otherwise). */
14138
+ get magneticAnchors() {
14139
+ const m = this.magnetic;
14140
+ return m ? m.anchors.map((i) => m.fixed[i]) : [];
13801
14141
  }
13802
14142
  /** The outline of the shape being drawn, or `null`. */
13803
14143
  get draftPath() {
14144
+ if (this.magnetic) return [...this.magnetic.fixed, ...this.magnetic.live.slice(1)];
13804
14145
  if (this.polygon) return this.current ? [...this.polygon, this.current] : this.polygon.slice();
13805
14146
  if (!this.start || !this.current) return null;
13806
14147
  const { tool } = this.getOptions();
@@ -13824,7 +14165,8 @@ var SelectionGesture = class {
13824
14165
  async begin(point, modifiers = {}) {
13825
14166
  const options = this.getOptions();
13826
14167
  this.error = null;
13827
- if (this.polygon && options.tool !== "polygon") this.cancel();
14168
+ if (this.polygon && options.tool !== "polygon" || this.magnetic && options.tool !== "magnetic") this.cancel();
14169
+ if (options.tool === "magnetic") return this.magneticClick(point, modifiers, options);
13828
14170
  if (options.tool === "polygon") {
13829
14171
  this.pressedAt = point;
13830
14172
  this.freehand = false;
@@ -13919,6 +14261,11 @@ var SelectionGesture = class {
13919
14261
  }
13920
14262
  /** Pointer move; returns true when the draft changed (re-render). */
13921
14263
  move(point, modifiers = {}) {
14264
+ if (this.magnetic) {
14265
+ this.current = point;
14266
+ this.followEdges(point);
14267
+ return true;
14268
+ }
13922
14269
  if (this.polygon) {
13923
14270
  const last = this.polygon[this.polygon.length - 1];
13924
14271
  if (this.pressedAt && (this.freehand || Math.hypot(point.x - this.pressedAt.x, point.y - this.pressedAt.y) >= 3)) {
@@ -13941,6 +14288,7 @@ var SelectionGesture = class {
13941
14288
  }
13942
14289
  /** Pointer up: commits the shape. Returns true when the selection changed. */
13943
14290
  end(point, modifiers) {
14291
+ if (this.magnetic) return false;
13944
14292
  if (this.polygon) {
13945
14293
  if (point && this.freehand) this.move(point);
13946
14294
  this.pressedAt = null;
@@ -13968,7 +14316,12 @@ var SelectionGesture = class {
13968
14316
  }
13969
14317
  /** Closes a polygonal lasso and selects it. Returns true when the selection changed. */
13970
14318
  close() {
13971
- const polygon = this.polygon;
14319
+ let polygon = this.polygon;
14320
+ const m = this.magnetic;
14321
+ if (m) {
14322
+ const tail = m.edges && m.fixed.length > 1 ? liveWire(m.edges, m.fixed[m.fixed.length - 1], m.fixed[0], this.corridor(m.fixed[m.fixed.length - 1], m.fixed[0])) : [];
14323
+ polygon = simplifyPixelPath([...m.fixed, ...tail.slice(1, -1)]);
14324
+ }
13972
14325
  if (!polygon) return false;
13973
14326
  const options = this.getOptions();
13974
14327
  this.cancel();
@@ -13984,21 +14337,99 @@ var SelectionGesture = class {
13984
14337
  * removes the last corner. Returns true when the key was used (the host prevents its default).
13985
14338
  */
13986
14339
  key(key) {
13987
- if (!this.polygon) return false;
14340
+ if (!this.polygon && !this.magnetic) return false;
13988
14341
  if (key === "Enter") this.close();
13989
14342
  else if (key === "Escape") this.cancel();
13990
14343
  else if (key === "Backspace" || key === "Delete") {
13991
- this.polygon.pop();
13992
- if (!this.polygon.length) this.cancel();
14344
+ const m = this.magnetic;
14345
+ if (m) {
14346
+ m.anchors.pop();
14347
+ if (!m.anchors.length) this.cancel();
14348
+ else {
14349
+ m.fixed.length = m.anchors[m.anchors.length - 1] + 1;
14350
+ m.live = [];
14351
+ if (this.current) this.followEdges(this.current, false);
14352
+ }
14353
+ } else {
14354
+ this.polygon.pop();
14355
+ if (!this.polygon.length) this.cancel();
14356
+ }
13993
14357
  } else return false;
13994
14358
  return true;
13995
14359
  }
14360
+ /** Corridor half-width for a live-wire between two points. */
14361
+ corridor(a, b) {
14362
+ return Math.max(2 * (this.getOptions().magneticWidth ?? 10), 16, Math.hypot(b.x - a.x, b.y - a.y) / 4);
14363
+ }
14364
+ async magneticClick(point, modifiers, options) {
14365
+ const m = this.magnetic;
14366
+ if (!m) {
14367
+ this.modifiers = modifiers;
14368
+ const draft = { fixed: [point], anchors: [0], live: [], edges: null };
14369
+ this.magnetic = draft;
14370
+ this.current = point;
14371
+ try {
14372
+ const pixels = await this.controller.getSamplePixels({ sampleMerged: options.sampleMerged, sample: options.sample });
14373
+ if (!pixels) throw new Error("The image pixels are not available to find edges in.");
14374
+ if (this.magnetic !== draft) return;
14375
+ draft.edges = new EdgeMap(pixels, { contrast: (options.magneticContrast ?? 10) / 100 });
14376
+ draft.fixed = [snapToEdge(draft.edges, point, options.magneticWidth ?? 10)];
14377
+ if (this.current) this.followEdges(this.current);
14378
+ } catch (err) {
14379
+ this.error = err instanceof Error ? err.message : String(err);
14380
+ if (this.magnetic === draft) this.cancel();
14381
+ }
14382
+ return;
14383
+ }
14384
+ const first = m.fixed[0];
14385
+ if (m.anchors.length >= 2 && Math.hypot(point.x - first.x, point.y - first.y) <= this.closeRadius) {
14386
+ this.close();
14387
+ return;
14388
+ }
14389
+ this.current = point;
14390
+ this.followEdges(point);
14391
+ if (m.live.length > 1) {
14392
+ m.fixed.push(...m.live.slice(1));
14393
+ m.anchors.push(m.fixed.length - 1);
14394
+ m.live = [];
14395
+ }
14396
+ if ((modifiers.detail ?? 1) >= 2) this.close();
14397
+ }
14398
+ /** Updates the live path to `point`, fixing anchors along it every `frequency` px (with `place`). */
14399
+ followEdges(point, place = true) {
14400
+ const m = this.magnetic;
14401
+ const last = () => m.fixed[m.fixed.length - 1];
14402
+ if (!m.edges) {
14403
+ m.live = [last(), point];
14404
+ return;
14405
+ }
14406
+ const options = this.getOptions();
14407
+ const target = snapToEdge(m.edges, point, options.magneticWidth ?? 10);
14408
+ const spacing = magneticAnchorSpacing(options.magneticFrequency ?? 57);
14409
+ for (let guard = 0; guard < 64; guard++) {
14410
+ const path = liveWire(m.edges, last(), target, this.corridor(last(), target));
14411
+ let length = 0;
14412
+ let k = 0;
14413
+ for (k = 1; k < path.length; k++) {
14414
+ length += Math.hypot(path[k].x - path[k - 1].x, path[k].y - path[k - 1].y);
14415
+ if (length >= spacing) break;
14416
+ }
14417
+ if (!place || k >= path.length - 1) {
14418
+ m.live = path;
14419
+ return;
14420
+ }
14421
+ m.fixed.push(...path.slice(1, k + 1));
14422
+ m.anchors.push(m.fixed.length - 1);
14423
+ }
14424
+ m.live = [last(), target];
14425
+ }
13996
14426
  /** Abandons a drag (or a polygonal lasso) without changing the selection. */
13997
14427
  cancel() {
13998
14428
  this.start = null;
13999
14429
  this.current = null;
14000
14430
  this.points = [];
14001
14431
  this.polygon = null;
14432
+ this.magnetic = null;
14002
14433
  this.pressedAt = null;
14003
14434
  this.freehand = false;
14004
14435
  }
@@ -18532,6 +18963,7 @@ export {
18532
18963
  EMPTY_MIXER_LOAD,
18533
18964
  EX,
18534
18965
  EY,
18966
+ EdgeMap,
18535
18967
  FLAG_ALIVE,
18536
18968
  FLAG_BOTTOM,
18537
18969
  FLAG_LEFT,
@@ -19129,12 +19561,14 @@ export {
19129
19561
  liveComponent,
19130
19562
  liveShapeForDrag,
19131
19563
  liveShapeSubpaths,
19564
+ liveWire,
19132
19565
  loadImageEditorDocument,
19133
19566
  loadMixer,
19134
19567
  loadOpenTypeFont,
19135
19568
  loadWetKernel,
19136
19569
  luma,
19137
19570
  magicWand,
19571
+ magneticAnchorSpacing,
19138
19572
  mapCropHandle,
19139
19573
  mapLayer,
19140
19574
  mapSubpaths,
@@ -19386,13 +19820,16 @@ export {
19386
19820
  shapeOutline,
19387
19821
  shapeRenderClip,
19388
19822
  shiftCoverage,
19823
+ simplifyPixelPath,
19389
19824
  skinToneRange,
19390
19825
  smartFilterRows,
19391
19826
  smartObjectLayers,
19392
19827
  smoothCoverage,
19393
19828
  smoothPoints,
19394
19829
  smudgeEffect,
19830
+ snapPolygonToEdges,
19395
19831
  snapTo45,
19832
+ snapToEdge,
19396
19833
  solidPaint,
19397
19834
  solidSurface,
19398
19835
  solveMembrane,
@@ -23,6 +23,10 @@ export declare function snapTo45(from: ImagePoint, point: ImagePoint): ImagePoin
23
23
  * adds a free-hand stretch; a double-click, a click on the first corner or Enter (`key`)
24
24
  * closes it, Backspace removes the last corner and Escape cancels. It stays `active` between
25
25
  * clicks, so feed it pointer moves without a button too.
26
+ * - Magnetic lasso: a click sets the first anchor, then the outline follows the edges from the
27
+ * last anchor to the pointer (snapped to the strongest edge within the width) and places
28
+ * anchors as it goes (frequency); a click adds one, and closing and the keys are as the
29
+ * polygonal lasso's. The first click reads the pixels (async).
26
30
  * - Single row / column: a click selects a 1 px line across the image.
27
31
  *
28
32
  * Shift adds, Alt subtracts, both intersect (overriding the options bar mode). While dragging,
@@ -39,12 +43,15 @@ export declare class SelectionGesture {
39
43
  private polygon;
40
44
  private pressedAt;
41
45
  private freehand;
46
+ private magnetic;
42
47
  /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
43
48
  closeRadius: number;
44
49
  /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
45
50
  error: string | null;
46
51
  constructor(controller: ImageEditorController, getOptions: () => SelectionToolOptions);
47
52
  get active(): boolean;
53
+ /** The anchors of a Magnetic Lasso being drawn (empty otherwise). */
54
+ get magneticAnchors(): ImagePoint[];
48
55
  /** The outline of the shape being drawn, or `null`. */
49
56
  get draftPath(): ImagePoint[] | null;
50
57
  /** Pointer down. Resolves once a wand click has been applied. */
@@ -60,6 +67,11 @@ export declare class SelectionGesture {
60
67
  * removes the last corner. Returns true when the key was used (the host prevents its default).
61
68
  */
62
69
  key(key: string): boolean;
70
+ /** Corridor half-width for a live-wire between two points. */
71
+ private corridor;
72
+ private magneticClick;
73
+ /** Updates the live path to `point`, fixing anchors along it every `frequency` px (with `place`). */
74
+ private followEdges;
63
75
  /** Abandons a drag (or a polygonal lasso) without changing the selection. */
64
76
  cancel(): void;
65
77
  }
@@ -23,6 +23,10 @@ export declare function snapTo45(from: ImagePoint, point: ImagePoint): ImagePoin
23
23
  * adds a free-hand stretch; a double-click, a click on the first corner or Enter (`key`)
24
24
  * closes it, Backspace removes the last corner and Escape cancels. It stays `active` between
25
25
  * clicks, so feed it pointer moves without a button too.
26
+ * - Magnetic lasso: a click sets the first anchor, then the outline follows the edges from the
27
+ * last anchor to the pointer (snapped to the strongest edge within the width) and places
28
+ * anchors as it goes (frequency); a click adds one, and closing and the keys are as the
29
+ * polygonal lasso's. The first click reads the pixels (async).
26
30
  * - Single row / column: a click selects a 1 px line across the image.
27
31
  *
28
32
  * Shift adds, Alt subtracts, both intersect (overriding the options bar mode). While dragging,
@@ -39,12 +43,15 @@ export declare class SelectionGesture {
39
43
  private polygon;
40
44
  private pressedAt;
41
45
  private freehand;
46
+ private magnetic;
42
47
  /** A click this close to the first corner (natural pixels) closes a polygonal lasso. */
43
48
  closeRadius: number;
44
49
  /** The last error (e.g. pixels unavailable for the wand), for the UI to show. */
45
50
  error: string | null;
46
51
  constructor(controller: ImageEditorController, getOptions: () => SelectionToolOptions);
47
52
  get active(): boolean;
53
+ /** The anchors of a Magnetic Lasso being drawn (empty otherwise). */
54
+ get magneticAnchors(): ImagePoint[];
48
55
  /** The outline of the shape being drawn, or `null`. */
49
56
  get draftPath(): ImagePoint[] | null;
50
57
  /** Pointer down. Resolves once a wand click has been applied. */
@@ -60,6 +67,11 @@ export declare class SelectionGesture {
60
67
  * removes the last corner. Returns true when the key was used (the host prevents its default).
61
68
  */
62
69
  key(key: string): boolean;
70
+ /** Corridor half-width for a live-wire between two points. */
71
+ private corridor;
72
+ private magneticClick;
73
+ /** Updates the live path to `point`, fixing anchors along it every `frequency` px (with `place`). */
74
+ private followEdges;
63
75
  /** Abandons a drag (or a polygonal lasso) without changing the selection. */
64
76
  cancel(): void;
65
77
  }
@@ -12,3 +12,4 @@ export * from './stroke.cjs';
12
12
  export * from './channels.cjs';
13
13
  export * from './mask-files.cjs';
14
14
  export * from './path.cjs';
15
+ export * from './magnetic.cjs';
@@ -12,3 +12,4 @@ export * from './stroke.js';
12
12
  export * from './channels.js';
13
13
  export * from './mask-files.js';
14
14
  export * from './path.js';
15
+ export * from './magnetic.js';