@pixerate/editor 0.194.0 → 0.195.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.
- package/dist/image-editor/controller.d.cts +7 -0
- package/dist/image-editor/controller.d.ts +7 -0
- package/dist/image-editor/index.cjs +573 -136
- package/dist/image-editor/index.js +447 -10
- package/dist/image-editor/selection/gesture.d.cts +12 -0
- package/dist/image-editor/selection/gesture.d.ts +12 -0
- package/dist/image-editor/selection/index.d.cts +1 -0
- package/dist/image-editor/selection/index.d.ts +1 -0
- package/dist/image-editor/selection/magnetic.d.cts +60 -0
- package/dist/image-editor/selection/magnetic.d.ts +60 -0
- package/dist/image-editor/selection/tool-options.d.cts +7 -1
- package/dist/image-editor/selection/tool-options.d.ts +7 -1
- package/package.json +1 -1
- package/skills/pixerate-image-editor/SKILL.md +1 -1
- package/skills/pixerate-image-selection-masking/SKILL.md +7 -2
|
@@ -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
|
-
|
|
11858
|
-
|
|
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
|
-
|
|
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.
|
|
13992
|
-
if (
|
|
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
|
}
|