@pixerate/editor 0.246.0 → 0.249.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.
Files changed (105) hide show
  1. package/README.md +1 -0
  2. package/dist/animation-editor/index.cjs +64 -61
  3. package/dist/animation-editor/index.js +8 -5
  4. package/dist/{brushstrokeRender-ZDHMXJAE.cjs → brushstrokeRender-GCKK7V2I.cjs} +14 -11
  5. package/dist/{brushstrokeRender-AKUI3SEJ.js → brushstrokeRender-T3JZST44.js} +7 -4
  6. package/dist/canvas/index.cjs +8 -8
  7. package/dist/canvas/index.js +1 -1
  8. package/dist/{chunk-UVPD55UZ.cjs → chunk-2SMMZDMG.cjs} +35 -35
  9. package/dist/chunk-2VJAVXLI.js +192 -0
  10. package/dist/{chunk-OLNIPTB3.cjs → chunk-2VX55NFM.cjs} +1 -320
  11. package/dist/chunk-3K6HWEXZ.cjs +92 -0
  12. package/dist/{chunk-UOOK7HTX.js → chunk-3PPJRMIG.js} +1 -1
  13. package/dist/chunk-4D6KP5BV.js +92 -0
  14. package/dist/chunk-5RH3VXAK.js +269 -0
  15. package/dist/{chunk-LDPLEZ5T.js → chunk-6BIDVPED.js} +1 -1
  16. package/dist/{chunk-2ONK3ERD.cjs → chunk-75K3W4D6.cjs} +20 -20
  17. package/dist/chunk-BOHDJJ2O.cjs +192 -0
  18. package/dist/{chunk-2PBZBRP5.js → chunk-D5KGAO4Z.js} +10 -92
  19. package/dist/{chunk-DQWPMGH2.js → chunk-DOIU7VJG.js} +1 -1
  20. package/dist/{chunk-HB4ICNNA.cjs → chunk-DPYAO5U6.cjs} +42 -42
  21. package/dist/chunk-EJLRBS5Q.js +301 -0
  22. package/dist/{chunk-Q2UYUWAZ.cjs → chunk-H4AOUUBE.cjs} +679 -164
  23. package/dist/{chunk-PQ3H2AAN.cjs → chunk-I7NJPHWV.cjs} +122 -204
  24. package/dist/{chunk-FK5LTDOO.cjs → chunk-IOFVM3L3.cjs} +7 -7
  25. package/dist/{chunk-42TWUPNJ.js → chunk-IRSTHLQQ.js} +1 -320
  26. package/dist/{chunk-VDAWA546.js → chunk-J7T5QZ45.js} +614 -99
  27. package/dist/{chunk-VMROIWLL.cjs → chunk-KBYB7OWB.cjs} +3 -3
  28. package/dist/{chunk-5MFDD7SP.cjs → chunk-KYX7NZ5V.cjs} +1 -1
  29. package/dist/chunk-LJRCU3BJ.cjs +301 -0
  30. package/dist/{chunk-XTWXBQD4.cjs → chunk-LP2MCSBE.cjs} +593 -236
  31. package/dist/{chunk-A23PSOBB.js → chunk-O75V4ZS3.js} +1 -1
  32. package/dist/chunk-OTTT5N3K.js +322 -0
  33. package/dist/{chunk-N6WUB3Y5.js → chunk-PZP2SZIZ.js} +2 -2
  34. package/dist/{chunk-4PMCMWQS.js → chunk-UBRJOIR5.js} +367 -10
  35. package/dist/chunk-W5M3J7PV.cjs +322 -0
  36. package/dist/{chunk-4P6H35HU.js → chunk-WA44NKPF.js} +2 -2
  37. package/dist/{chunk-CEPKXLBS.cjs → chunk-WLJZZHUZ.cjs} +23 -23
  38. package/dist/{chunk-P3NIPIKS.js → chunk-WQCNC4DK.js} +1 -1
  39. package/dist/chunk-ZEYMKDYS.cjs +269 -0
  40. package/dist/image-editor/effect-tools.d.cts +37 -4
  41. package/dist/image-editor/effect-tools.d.ts +37 -4
  42. package/dist/image-editor/index.cjs +1340 -1828
  43. package/dist/image-editor/index.d.cts +1 -1
  44. package/dist/image-editor/index.d.ts +1 -1
  45. package/dist/image-editor/index.js +298 -786
  46. package/dist/paint/index.cjs +8 -5
  47. package/dist/paint/index.js +13 -10
  48. package/dist/paint-editor/controller.d.cts +71 -2
  49. package/dist/paint-editor/controller.d.ts +71 -2
  50. package/dist/paint-editor/index.cjs +901 -589
  51. package/dist/paint-editor/index.d.cts +3 -0
  52. package/dist/paint-editor/index.d.ts +3 -0
  53. package/dist/paint-editor/index.js +365 -53
  54. package/dist/paint-editor/procedural.d.cts +82 -5
  55. package/dist/paint-editor/procedural.d.ts +82 -5
  56. package/dist/paint-editor/sticker-art.d.cts +55 -0
  57. package/dist/paint-editor/sticker-art.d.ts +55 -0
  58. package/dist/paint-editor/stickers.d.cts +30 -17
  59. package/dist/paint-editor/stickers.d.ts +30 -17
  60. package/dist/paint-editor/stroke.d.cts +4 -0
  61. package/dist/paint-editor/stroke.d.ts +4 -0
  62. package/dist/paint-editor/tools.d.cts +1 -1
  63. package/dist/paint-editor/tools.d.ts +1 -1
  64. package/dist/paint-editor/ui.d.cts +25 -4
  65. package/dist/paint-editor/ui.d.ts +25 -4
  66. package/dist/{image-editor/layers/effects-ui.d.cts → raster/effects/ui.d.cts} +32 -5
  67. package/dist/{image-editor/layers/effects-ui.d.ts → raster/effects/ui.d.ts} +32 -5
  68. package/dist/raster/index.cjs +17 -3
  69. package/dist/raster/index.d.cts +1 -0
  70. package/dist/raster/index.d.ts +1 -0
  71. package/dist/raster/index.js +23 -9
  72. package/dist/stickers/browser.d.cts +48 -0
  73. package/dist/stickers/browser.d.ts +48 -0
  74. package/dist/stickers/combine.d.cts +10 -0
  75. package/dist/stickers/combine.d.ts +10 -0
  76. package/dist/stickers/conformance.d.cts +18 -0
  77. package/dist/stickers/conformance.d.ts +18 -0
  78. package/dist/stickers/editor.d.cts +14 -0
  79. package/dist/stickers/editor.d.ts +14 -0
  80. package/dist/stickers/errors.d.cts +6 -0
  81. package/dist/stickers/errors.d.ts +6 -0
  82. package/dist/stickers/fake.d.cts +16 -0
  83. package/dist/stickers/fake.d.ts +16 -0
  84. package/dist/stickers/helpers.d.cts +51 -0
  85. package/dist/stickers/helpers.d.ts +51 -0
  86. package/dist/stickers/index.cjs +331 -0
  87. package/dist/stickers/index.d.cts +16 -0
  88. package/dist/stickers/index.d.ts +16 -0
  89. package/dist/stickers/index.js +331 -0
  90. package/dist/stickers/stamps.d.cts +13 -0
  91. package/dist/stickers/stamps.d.ts +13 -0
  92. package/dist/stickers/tools.d.cts +25 -0
  93. package/dist/stickers/tools.d.ts +25 -0
  94. package/dist/stickers/types.d.cts +123 -0
  95. package/dist/stickers/types.d.ts +123 -0
  96. package/dist/vector/index.cjs +978 -975
  97. package/dist/vector/index.js +9 -6
  98. package/package.json +11 -1
  99. package/skills/README.md +1 -0
  100. package/skills/pixerate-paint-editor/SKILL.md +26 -32
  101. package/skills/pixerate-paint-editor/references/gradient-paint.md +23 -0
  102. package/skills/pixerate-paint-editor/references/layer-effects.md +28 -0
  103. package/skills/pixerate-paint-editor/references/light-paint.md +10 -2
  104. package/skills/pixerate-paint-editor/references/particle-brushes.md +66 -0
  105. package/skills/pixerate-stickers/SKILL.md +45 -0
@@ -1,3 +1,12 @@
1
+ import {
2
+ STAMP_KINDS,
3
+ StickerError,
4
+ describeStickerRef,
5
+ editorStampOf,
6
+ normalizeStickerRef,
7
+ stampCoverage,
8
+ stickerIdFor
9
+ } from "./chunk-5RH3VXAK.js";
1
10
  import {
2
11
  CoverageMap,
3
12
  DEFAULT_BLENDER,
@@ -11,16 +20,22 @@ import {
11
20
  TileMedium,
12
21
  dabRect,
13
22
  grainCatch,
14
- hash2,
15
23
  normalizeBlender,
16
24
  normalizeBrush,
17
25
  normalizeMixer,
18
26
  normalizeSmudge,
19
- rand01,
20
- randSigned,
21
27
  rasterizeDab,
22
28
  readPaintSource
23
- } from "./chunk-2PBZBRP5.js";
29
+ } from "./chunk-D5KGAO4Z.js";
30
+ import {
31
+ hash2,
32
+ rand01,
33
+ randSigned
34
+ } from "./chunk-4D6KP5BV.js";
35
+ import {
36
+ decodePng,
37
+ parseDataUrl
38
+ } from "./chunk-OTTT5N3K.js";
24
39
  import {
25
40
  TILE,
26
41
  TiledSurface,
@@ -586,14 +601,129 @@ function toStrokePoint(p) {
586
601
  return { x: p.x, y: p.y, pressure: pressureOf(p), ...typeof p.t === "number" ? { t: p.t } : {} };
587
602
  }
588
603
 
604
+ // src/paint-editor/sticker-art.ts
605
+ var STICKER_ART_SIZE = 256;
606
+ var defaultStickerRasterizer = async (asset, size) => {
607
+ if (asset.kind === "image") {
608
+ const parsed = parseDataUrl(asset.url);
609
+ if (parsed && parsed.mime === "image/png") {
610
+ const png = decodePng(parsed.bytes);
611
+ return { width: png.width, height: png.height, data: new Uint8ClampedArray(png.data) };
612
+ }
613
+ }
614
+ const g = globalThis;
615
+ if (!g.createImageBitmap || !g.OffscreenCanvas) {
616
+ throw new StickerError("unsupported_format", `This environment can't draw ${asset.kind === "svg" ? "SVG" : "non-PNG"} sticker art; pass rasterizeSticker in the paint editor's options (or use a provider that offers png).`);
617
+ }
618
+ const blob = asset.kind === "svg" ? new Blob([asset.svg], { type: "image/svg+xml" }) : await (await g.fetch(asset.url)).blob();
619
+ const bitmap = await g.createImageBitmap(blob);
620
+ const scale = size / Math.max(bitmap.width, bitmap.height, 1);
621
+ const w = Math.max(1, Math.round(bitmap.width * scale));
622
+ const h = Math.max(1, Math.round(bitmap.height * scale));
623
+ const canvas = new g.OffscreenCanvas(w, h);
624
+ const ctx = canvas.getContext("2d");
625
+ ctx.drawImage(bitmap, 0, 0, w, h);
626
+ const img = ctx.getImageData(0, 0, w, h);
627
+ return { width: w, height: h, data: img.data };
628
+ };
629
+ var StickerArtCache = class {
630
+ entries = /* @__PURE__ */ new Map();
631
+ /** The art for `ref` when it has loaded (editor stamps never need any). */
632
+ get = (ref) => {
633
+ const e = this.entries.get(ref);
634
+ return e?.status === "ready" ? e.art : void 0;
635
+ };
636
+ /** `ready`, `loading`, `failed` (with the reason) or `none` (never asked for). Editor stamps are always `ready`. */
637
+ status(ref) {
638
+ if (editorStampOf(ref)) return { status: "ready" };
639
+ const e = this.entries.get(ref);
640
+ if (!e) return { status: "none" };
641
+ return e.status === "failed" ? { status: "failed", error: e.error } : { status: e.status };
642
+ }
643
+ /**
644
+ * Loads and rasterises `ref` from `provider` once (later calls share the load). Unknown refs
645
+ * reject with the closest known ones; failures are kept so they are reported, and retried on
646
+ * the next call.
647
+ */
648
+ ensure(provider, ref, rasterize) {
649
+ if (editorStampOf(ref)) return Promise.resolve(null);
650
+ const e = this.entries.get(ref);
651
+ if (e?.status === "ready") return Promise.resolve(e.art);
652
+ if (e?.status === "loading") return e.promise;
653
+ const promise = (async () => {
654
+ const d = await describeStickerRef(provider, ref);
655
+ const id = stickerIdFor(provider, ref);
656
+ const max = provider.capabilities.maxSize ?? STICKER_ART_SIZE;
657
+ const size = Math.min(STICKER_ART_SIZE, max);
658
+ const asset = await provider.load(id, d.formats.includes("png") ? { format: "png", size } : { format: d.formats[0] ?? "svg", size });
659
+ const px = await rasterize(asset, size);
660
+ if (!(px.width > 0 && px.height > 0) || px.data.length !== px.width * px.height * 4) throw new StickerError("load_failed", `The art for "${ref}" came back empty or the wrong size.`);
661
+ return { ...px, tintable: d.tintable };
662
+ })();
663
+ this.entries.set(ref, { status: "loading", promise });
664
+ return promise.then(
665
+ (art) => {
666
+ this.entries.set(ref, { status: "ready", art });
667
+ return art;
668
+ },
669
+ (err) => {
670
+ const message = err instanceof Error ? err.message : String(err);
671
+ this.entries.set(ref, { status: "failed", error: message });
672
+ throw err;
673
+ }
674
+ );
675
+ }
676
+ /** Forgets every load (a new provider). */
677
+ clear() {
678
+ this.entries.clear();
679
+ }
680
+ };
681
+ function sampleArt(art, x, y, out) {
682
+ const { width: w, height: h, data } = art;
683
+ const fx = x - 0.5;
684
+ const fy = y - 0.5;
685
+ const x0 = Math.floor(fx);
686
+ const y0 = Math.floor(fy);
687
+ const tx = fx - x0;
688
+ const ty = fy - y0;
689
+ let r = 0;
690
+ let g = 0;
691
+ let b = 0;
692
+ let a = 0;
693
+ for (let k = 0; k < 4; k++) {
694
+ const xx = x0 + (k & 1);
695
+ const yy = y0 + (k >> 1);
696
+ if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
697
+ const wgt = (k & 1 ? tx : 1 - tx) * (k >> 1 ? ty : 1 - ty);
698
+ const i = (yy * w + xx) * 4;
699
+ const al = data[i + 3] * wgt;
700
+ r += data[i] * al;
701
+ g += data[i + 1] * al;
702
+ b += data[i + 2] * al;
703
+ a += al;
704
+ }
705
+ out[0] = a > 0 ? r / a / 255 : 0;
706
+ out[1] = a > 0 ? g / a / 255 : 0;
707
+ out[2] = a > 0 ? b / a / 255 : 0;
708
+ out[3] = a / 255;
709
+ return out;
710
+ }
711
+
589
712
  // src/paint-editor/stickers.ts
590
- var STAMP_KINDS = ["dot", "star", "heart", "leaf", "flower", "confetti", "splat", "sparkle", "ring"];
591
- var DEFAULT_STICKER = { stamps: ["star"], density: 0.4, sizeJitter: 0.4, rotationJitter: 1, scatter: 0.5, colorJitter: 0.2 };
713
+ var MAX_STICKER_STAMPS = 16;
714
+ var DEFAULT_STICKER = { stamps: ["editor:star"], density: 0.4, sizeJitter: 0.4, rotationJitter: 1, scatter: 0.5, colorJitter: 0.2 };
592
715
  var unit2 = (v, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d;
593
716
  function normalizeStickerSettings(raw = {}, base = DEFAULT_STICKER) {
594
- const stamps = Array.isArray(raw.stamps) ? raw.stamps.filter((s) => STAMP_KINDS.includes(s)) : base.stamps;
717
+ const refs = (list) => list.flatMap((s) => {
718
+ try {
719
+ return [normalizeStickerRef(s)];
720
+ } catch {
721
+ return [];
722
+ }
723
+ });
724
+ const stamps = Array.isArray(raw.stamps) ? refs(raw.stamps) : refs(base.stamps);
595
725
  return {
596
- stamps: stamps.length ? [...new Set(stamps)] : [...base.stamps],
726
+ stamps: stamps.length ? [...new Set(stamps)].slice(0, MAX_STICKER_STAMPS) : refs(base.stamps.length ? base.stamps : DEFAULT_STICKER.stamps),
597
727
  density: unit2(raw.density, base.density),
598
728
  sizeJitter: unit2(raw.sizeJitter, base.sizeJitter),
599
729
  rotationJitter: unit2(raw.rotationJitter, base.rotationJitter),
@@ -601,59 +731,6 @@ function normalizeStickerSettings(raw = {}, base = DEFAULT_STICKER) {
601
731
  colorJitter: unit2(raw.colorJitter, base.colorJitter)
602
732
  };
603
733
  }
604
- function stampCoverage(kind, u, v, t) {
605
- const r = Math.hypot(u, v);
606
- const a = Math.atan2(v, u);
607
- const edge = (d) => Math.min(1, Math.max(0, d * 24 + 0.5));
608
- switch (kind) {
609
- case "dot":
610
- return edge(0.95 - r);
611
- case "ring":
612
- return edge(Math.min(0.95 - r, r - 0.6));
613
- case "star": {
614
- const f = (a + Math.PI / 2) * 5 / (2 * Math.PI) % 1;
615
- const notch = Math.min(f < 0 ? f + 1 : f, 1 - (f < 0 ? f + 1 : f)) * 2;
616
- return edge(0.95 - 0.55 * notch - r);
617
- }
618
- case "sparkle": {
619
- const q = Math.pow(Math.abs(u) / 0.95, 0.6) + Math.pow(Math.abs(v) / 0.95, 0.6);
620
- return edge((1 - q) * 0.4);
621
- }
622
- case "heart": {
623
- const x = u * 1.1;
624
- const y = -v * 1.1 + 0.15;
625
- const q = x * x + y * y - 0.55;
626
- return edge(-(q * q * q - x * x * y * y * y) * 3);
627
- }
628
- case "leaf": {
629
- const w = 0.42 * Math.sqrt(Math.max(0, 1 - u * u));
630
- const inside = edge(w - Math.abs(v + 0.15 * u * u));
631
- const rib = Math.abs(v + 0.15 * u * u) < 0.03 && Math.abs(u) < 0.85 ? 0.65 : 1;
632
- return inside * rib;
633
- }
634
- case "flower": {
635
- const petals = 0.6 + 0.35 * Math.abs(Math.cos(2.5 * a));
636
- const body = edge(petals - r);
637
- return r < 0.22 ? Math.max(body, 1) * 0.85 : body;
638
- }
639
- case "confetti": {
640
- return edge(Math.min(0.9 - Math.abs(u), 0.35 - Math.abs(v)));
641
- }
642
- case "splat": {
643
- let rr = 0.55;
644
- for (let k = 1; k <= 3; k++) rr += 0.08 * Math.sin(a * (k + 2) + hash2(k, 1, t) * 6.28) / k;
645
- let c = edge(rr - r);
646
- for (let k = 0; k < 4; k++) {
647
- const ang = hash2(k, 7, t) * Math.PI * 2;
648
- const dist = 0.72 + 0.2 * hash2(k, 9, t);
649
- const dx = u - Math.cos(ang) * dist;
650
- const dy = v - Math.sin(ang) * dist;
651
- c = Math.max(c, edge(0.08 + 0.06 * hash2(k, 11, t) - Math.hypot(dx, dy)));
652
- }
653
- return c;
654
- }
655
- }
656
- }
657
734
  function hexRgb(hex) {
658
735
  const n = parseInt(hex.slice(1, 7), 16);
659
736
  return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
@@ -664,7 +741,22 @@ function stickerRect(s) {
664
741
  const y0 = Math.floor(s.y - r);
665
742
  return { x: x0, y: y0, width: Math.ceil(s.x + r) - x0, height: Math.ceil(s.y + r) - y0 };
666
743
  }
667
- function drawSticker(medium, s) {
744
+ function stickerStamp(s) {
745
+ if (s.ref) {
746
+ try {
747
+ return editorStampOf(s.ref);
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
752
+ return s.stamp && STAMP_KINDS.includes(s.stamp) ? s.stamp : null;
753
+ }
754
+ function drawSticker(medium, s, art) {
755
+ const kind = stickerStamp(s);
756
+ if (!kind) {
757
+ const a2 = art?.(s.ref);
758
+ return a2 ? drawArtSticker(medium, s, a2) : stickerRect(s);
759
+ }
668
760
  const rect = stickerRect(s);
669
761
  const n = rect.width * rect.height;
670
762
  const cov = new Float32Array(n);
@@ -682,7 +774,7 @@ function drawSticker(medium, s) {
682
774
  const v = (-dx * sa + dy * ca) / rad;
683
775
  if (u < -1.05 || u > 1.05 || v < -1.05 || v > 1.05) continue;
684
776
  let c = 0;
685
- for (let k = 0; k < 4; k++) c += stampCoverage(s.stamp, u + ((k & 1) - 0.5) / rad / 2, v + ((k >> 1) - 0.5) / rad / 2, s.seed);
777
+ for (let k = 0; k < 4; k++) c += stampCoverage(kind, u + ((k & 1) - 0.5) / rad / 2, v + ((k >> 1) - 0.5) / rad / 2, s.seed);
686
778
  c /= 4;
687
779
  if (c <= 0) continue;
688
780
  cov[p] = c;
@@ -695,6 +787,35 @@ function drawSticker(medium, s) {
695
787
  medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
696
788
  return rect;
697
789
  }
790
+ function drawArtSticker(medium, s, art) {
791
+ const rect = stickerRect(s);
792
+ const n = rect.width * rect.height;
793
+ const cov = new Float32Array(n);
794
+ const field = new Float32Array(n * 3);
795
+ const a = s.rotation * Math.PI / 180;
796
+ const ca = Math.cos(a);
797
+ const sa = Math.sin(a);
798
+ const scale = Math.max(art.width, art.height) / s.size;
799
+ const [r, g, b] = hexRgb(s.color);
800
+ const px = [0, 0, 0, 0];
801
+ for (let yy = 0, p = 0; yy < rect.height; yy++) {
802
+ for (let xx = 0; xx < rect.width; xx++, p++) {
803
+ const dx = rect.x + xx + 0.5 - s.x;
804
+ const dy = rect.y + yy + 0.5 - s.y;
805
+ const ax = (dx * ca + dy * sa) * scale + art.width / 2;
806
+ const ay = (-dx * sa + dy * ca) * scale + art.height / 2;
807
+ if (ax < -1 || ay < -1 || ax > art.width + 1 || ay > art.height + 1) continue;
808
+ sampleArt(art, ax, ay, px);
809
+ if (!(px[3] > 0)) continue;
810
+ cov[p] = px[3];
811
+ field[p * 3] = art.tintable ? r : px[0];
812
+ field[p * 3 + 1] = art.tintable ? g : px[1];
813
+ field[p * 3 + 2] = art.tintable ? b : px[2];
814
+ }
815
+ }
816
+ medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
817
+ return rect;
818
+ }
698
819
  function jitterColor(hex, amount, seed, i) {
699
820
  const [r, g, b] = hexRgb(hex);
700
821
  const lch = oklabToOklch(rgbToOklab({ r, g, b, a: 1 }));
@@ -730,6 +851,8 @@ var StickerSpray = class {
730
851
  init;
731
852
  dabCount = 0;
732
853
  stickers = [];
854
+ /** Refs the spray picked but could not draw because their art had not loaded. */
855
+ skipped = /* @__PURE__ */ new Set();
733
856
  smoother;
734
857
  walkers;
735
858
  copies;
@@ -776,9 +899,17 @@ var StickerSpray = class {
776
899
  this.next++;
777
900
  return;
778
901
  }
902
+ const ref = st.stamps[Math.floor(rand01(seed, i, 57) * st.stamps.length)] ?? "editor:dot";
903
+ const stamp = stickerStamp({ ref });
904
+ if (!stamp && !this.init.art?.(ref)) {
905
+ this.skipped.add(ref);
906
+ this.next++;
907
+ return;
908
+ }
779
909
  const sticker = {
780
910
  id: `st${this.next++}`,
781
- stamp: st.stamps[Math.floor(rand01(seed, i, 57) * st.stamps.length)] ?? "dot",
911
+ ref,
912
+ ...stamp ? { stamp } : {},
782
913
  x: Math.round((d.x + Math.cos(a) * dist) * 100) / 100,
783
914
  y: Math.round((d.y + Math.sin(a) * dist) * 100) / 100,
784
915
  size: Math.max(2, Math.round(size * 10) / 10),
@@ -788,7 +919,7 @@ var StickerSpray = class {
788
919
  seed: Math.floor(rand01(seed, i, 65) * 1e9)
789
920
  };
790
921
  this.stickers.push(sticker);
791
- rects.push(drawSticker(this.medium, sticker));
922
+ rects.push(drawSticker(this.medium, sticker, this.init.art));
792
923
  this.dabCount++;
793
924
  }
794
925
  sampler;
@@ -807,16 +938,24 @@ var StickerSpray = class {
807
938
  return { color: "#" + s.rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")).join(""), alpha: s.alpha };
808
939
  }
809
940
  };
810
- function renderStickers(stickers, target) {
941
+ function renderStickers(stickers, target, art) {
811
942
  const medium = new TileMedium(target, {});
812
- for (const s of stickers) drawSticker(medium, s);
943
+ for (const s of stickers) drawSticker(medium, s, art);
813
944
  }
814
945
  function normalizeStickerUpdate(u) {
815
946
  const out = {};
947
+ if (u.ref !== void 0 && u.stamp !== void 0) throw new Error("Give ref or stamp, not both (stamp is the older name for an editor stamp).");
816
948
  if (u.stamp !== void 0) {
817
- if (!STAMP_KINDS.includes(u.stamp)) throw new Error(`stamp ${JSON.stringify(u.stamp)} is not a stamp; use ${STAMP_KINDS.join(", ")}.`);
949
+ if (!STAMP_KINDS.includes(u.stamp)) throw new Error(`stamp ${JSON.stringify(u.stamp)} is not a stamp; use ${STAMP_KINDS.join(", ")}, or ref for any sticker (find_stickers).`);
950
+ out.ref = `editor:${u.stamp}`;
818
951
  out.stamp = u.stamp;
819
952
  }
953
+ if (u.ref !== void 0) {
954
+ out.ref = normalizeStickerRef(u.ref);
955
+ const stamp = stickerStamp({ ref: out.ref });
956
+ if (stamp) out.stamp = stamp;
957
+ else out.stamp = void 0;
958
+ }
820
959
  for (const k of ["x", "y"]) if (u[k] !== void 0) {
821
960
  if (!Number.isFinite(u[k])) throw new Error(`${k} must be a number of pixels.`);
822
961
  out[k] = u[k];
@@ -839,7 +978,7 @@ function normalizeStickerUpdate(u) {
839
978
  }
840
979
  return out;
841
980
  }
842
- function stickerSurface(stickers, base, dirty) {
981
+ function stickerSurface(stickers, base, dirty, art) {
843
982
  const only = base && dirty ? new Set(dirty.flatMap((r) => tilesInRect(r).map(({ tx, ty }) => tileKey(tx, ty)))) : null;
844
983
  const live = /* @__PURE__ */ new Map();
845
984
  let scratch = null;
@@ -857,7 +996,7 @@ function stickerSurface(stickers, base, dirty) {
857
996
  }
858
997
  };
859
998
  const reaches = (s) => !only || tilesInRect(stickerRect(s)).some(({ tx, ty }) => only.has(tileKey(tx, ty)));
860
- renderStickers(stickers.filter(reaches), target);
999
+ renderStickers(stickers.filter(reaches), target, art);
861
1000
  const tiles = /* @__PURE__ */ new Map();
862
1001
  if (only && base) {
863
1002
  for (const [k, t] of base.tiles) if (!only.has(k)) tiles.set(k, t);
@@ -868,7 +1007,7 @@ function stickerSurface(stickers, base, dirty) {
868
1007
  }
869
1008
  return new TiledSurface(4, tiles, 0);
870
1009
  }
871
- function stickerAt(stickers, x, y) {
1010
+ function stickerAt(stickers, x, y, art) {
872
1011
  for (let i = stickers.length - 1; i >= 0; i--) {
873
1012
  const s = stickers[i];
874
1013
  const rad = s.size / 2;
@@ -878,7 +1017,16 @@ function stickerAt(stickers, x, y) {
878
1017
  const u = (dx * Math.cos(a) + dy * Math.sin(a)) / rad;
879
1018
  const v = (-dx * Math.sin(a) + dy * Math.cos(a)) / rad;
880
1019
  if (Math.abs(u) > 1.05 || Math.abs(v) > 1.05) continue;
881
- if (s.size < 16 || stampCoverage(s.stamp, u, v, s.seed) > 0.25) return s;
1020
+ if (s.size < 16) return s;
1021
+ const kind = stickerStamp(s);
1022
+ if (kind) {
1023
+ if (stampCoverage(kind, u, v, s.seed) > 0.25) return s;
1024
+ continue;
1025
+ }
1026
+ const pic = art?.(s.ref);
1027
+ if (!pic) return s;
1028
+ const scale = Math.max(pic.width, pic.height) / 2;
1029
+ if (sampleArt(pic, u * scale + pic.width / 2, v * scale + pic.height / 2, [0, 0, 0, 0])[3] > 0.25) return s;
882
1030
  }
883
1031
  return null;
884
1032
  }
@@ -964,9 +1112,56 @@ var MarkStroke = class {
964
1112
  const rect = dabRect(dab, hardness);
965
1113
  const n = rect.width * rect.height;
966
1114
  if (n <= 0) return;
1115
+ const vals = this.scratch(n);
1116
+ rasterizeDab(dab, hardness, rect, vals);
1117
+ this.lay(rect, vals, dab.opacity, pressure, x, y, rects);
1118
+ }
1119
+ /**
1120
+ * Stamps a textured mark: `kind` from the sticker stamps over a square of half-size `r`, turned
1121
+ * `rotation` radians clockwise, with flow `alpha` towards `opacity`. Small marks are supersampled.
1122
+ */
1123
+ stamp(x, y, r, kind, rotation, seed, alpha, opacity, pressure, rects) {
1124
+ if (!(r > 0.05) || !(alpha > 0)) return;
1125
+ const reach = Math.max(0.5, r) * Math.SQRT2 + 1;
1126
+ const rect = { x: Math.floor(x - reach), y: Math.floor(y - reach), width: 0, height: 0 };
1127
+ rect.width = Math.ceil(x + reach) - rect.x;
1128
+ rect.height = Math.ceil(y + reach) - rect.y;
1129
+ const n = rect.width * rect.height;
1130
+ const vals = this.scratch(n);
1131
+ const ss = r < 10 ? 3 : 1;
1132
+ const c = Math.cos(rotation);
1133
+ const s = Math.sin(rotation);
1134
+ const inv = 1 / Math.max(0.5, r);
1135
+ const a = Math.min(1, alpha);
1136
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1137
+ for (let xx = 0; xx < rect.width; xx++, k++) {
1138
+ let sum = 0;
1139
+ for (let sy = 0; sy < ss; sy++) {
1140
+ for (let sx = 0; sx < ss; sx++) {
1141
+ const dx = rect.x + xx + (sx + 0.5) / ss - x;
1142
+ const dy = rect.y + yy + (sy + 0.5) / ss - y;
1143
+ sum += stampCoverage(kind, (dx * c + dy * s) * inv, (dy * c - dx * s) * inv, seed);
1144
+ }
1145
+ }
1146
+ vals[k] = sum / (ss * ss) * a;
1147
+ }
1148
+ }
1149
+ this.lay(rect, vals, Math.min(1, opacity), pressure, x, y, rects);
1150
+ }
1151
+ /** A scratch buffer of `n` values (reused; valid until the next mark). */
1152
+ scratch(n) {
967
1153
  if (this.buf.length < n) this.buf = new Float32Array(n);
968
1154
  const vals = this.buf.subarray(0, n);
969
- rasterizeDab(dab, hardness, rect, vals);
1155
+ vals.fill(0);
1156
+ return vals;
1157
+ }
1158
+ /**
1159
+ * Lays coverage `vals` over `rect` towards `opacity`: paper grain, then effect paint or the
1160
+ * colour of a mark at (x, y) (gradient paint), into the stroke's coverage.
1161
+ */
1162
+ lay(rect, vals, opacity, pressure, x, y, rects) {
1163
+ const n = rect.width * rect.height;
1164
+ if (n <= 0) return;
970
1165
  const grain = this.options.grain;
971
1166
  if (this.depth > 0 && grain) {
972
1167
  for (let yy = 0, k = 0; yy < rect.height; yy++)
@@ -976,11 +1171,11 @@ var MarkStroke = class {
976
1171
  if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
977
1172
  const field = this.field.subarray(0, n * 3);
978
1173
  sampleEffectField(this.options.effect, rect, vals, field, this.pen);
979
- this.coverage.accumulate(rect, vals, dab.opacity, null, field);
1174
+ this.coverage.accumulate(rect, vals, opacity, null, field);
980
1175
  } else {
981
1176
  const color = this.markColor(x, y);
982
1177
  if (color && color.alpha < 1) for (let k = 0; k < n; k++) vals[k] *= color.alpha;
983
- this.coverage.accumulate(rect, vals, dab.opacity, color ? color.rgb : null);
1178
+ this.coverage.accumulate(rect, vals, opacity, color ? color.rgb : null);
984
1179
  }
985
1180
  rects.push(rect);
986
1181
  this.dabCount++;
@@ -993,14 +1188,41 @@ var MarkStroke = class {
993
1188
  for (let i = 1; i <= n; i++) this.mark(x0 + (x1 - x0) * i / n, y0 + (y1 - y0) * i / n, r, alpha, opacity, pressure, rects);
994
1189
  }
995
1190
  };
996
- var PARTICLE_KINDS = ["string", "spring", "orbit", "flow"];
997
- var PARTICLE_RENDERS = ["dot", "line", "sequence"];
998
- var DEFAULT_PARTICLE = { kind: "spring", render: "line", count: 12, size: 0.08, spread: 0.5, stiffness: 0.4, damping: 0.25, speed: 0.5, gravity: 0, life: 0.5, interval: 0.3 };
1191
+ var PARTICLE_KINDS = ["string", "spring", "orbit", "flow", "elastic", "fuzzy"];
1192
+ var PARTICLE_RENDERS = ["dot", "line", "sequence", "ribbon"];
1193
+ var RIBBON_PAIRS = ["adjacent", "first-last"];
1194
+ var DOT_TEXTURE_ORDERS = ["sequence", "random"];
1195
+ var DOT_ROTATIONS = ["follow", "random", "fixed"];
1196
+ var MAX_DOT_TEXTURES = 4;
1197
+ var DEFAULT_PARTICLE = {
1198
+ kind: "spring",
1199
+ render: "line",
1200
+ count: 12,
1201
+ size: 0.08,
1202
+ spread: 0.5,
1203
+ stiffness: 0.4,
1204
+ damping: 0.25,
1205
+ speed: 0.5,
1206
+ gravity: 0,
1207
+ life: 0.5,
1208
+ interval: 0.3,
1209
+ ribbonFill: 0.5,
1210
+ ribbonPairs: "adjacent",
1211
+ waveAmplitude: 0,
1212
+ waveLength: 60,
1213
+ waveSpeed: 0,
1214
+ wavePhaseJitter: 0,
1215
+ dotTextures: [],
1216
+ dotTextureOrder: "sequence",
1217
+ dotRotation: "random"
1218
+ };
999
1219
  var unit3 = (v, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d;
1220
+ var oneOf = (v, all, d) => all.includes(v) ? v : d;
1000
1221
  function normalizeParticle(raw = {}, base = DEFAULT_PARTICLE) {
1222
+ const textures = Array.isArray(raw.dotTextures) ? raw.dotTextures.filter((t) => STAMP_KINDS.includes(t)).slice(0, MAX_DOT_TEXTURES) : [...base.dotTextures ?? []];
1001
1223
  return {
1002
- kind: PARTICLE_KINDS.includes(raw.kind) ? raw.kind : base.kind,
1003
- render: PARTICLE_RENDERS.includes(raw.render) ? raw.render : base.render,
1224
+ kind: oneOf(raw.kind, PARTICLE_KINDS, base.kind),
1225
+ render: oneOf(raw.render, PARTICLE_RENDERS, base.render),
1004
1226
  count: typeof raw.count === "number" && Number.isFinite(raw.count) ? Math.min(64, Math.max(1, Math.round(raw.count))) : base.count,
1005
1227
  size: unit3(raw.size, base.size),
1006
1228
  spread: unit3(raw.spread, base.spread),
@@ -1009,9 +1231,61 @@ function normalizeParticle(raw = {}, base = DEFAULT_PARTICLE) {
1009
1231
  speed: unit3(raw.speed, base.speed),
1010
1232
  gravity: typeof raw.gravity === "number" && Number.isFinite(raw.gravity) ? Math.min(1, Math.max(-1, raw.gravity)) : base.gravity,
1011
1233
  life: unit3(raw.life, base.life),
1012
- interval: unit3(raw.interval, base.interval)
1234
+ interval: unit3(raw.interval, base.interval),
1235
+ ribbonFill: unit3(raw.ribbonFill, base.ribbonFill ?? DEFAULT_PARTICLE.ribbonFill),
1236
+ ribbonPairs: oneOf(raw.ribbonPairs, RIBBON_PAIRS, base.ribbonPairs ?? DEFAULT_PARTICLE.ribbonPairs),
1237
+ waveAmplitude: unit3(raw.waveAmplitude, base.waveAmplitude ?? 0),
1238
+ waveLength: typeof raw.waveLength === "number" && Number.isFinite(raw.waveLength) ? Math.min(2e3, Math.max(4, raw.waveLength)) : base.waveLength ?? DEFAULT_PARTICLE.waveLength,
1239
+ waveSpeed: unit3(raw.waveSpeed, base.waveSpeed ?? 0),
1240
+ wavePhaseJitter: unit3(raw.wavePhaseJitter, base.wavePhaseJitter ?? 0),
1241
+ dotTextures: textures,
1242
+ dotTextureOrder: oneOf(raw.dotTextureOrder, DOT_TEXTURE_ORDERS, base.dotTextureOrder ?? DEFAULT_PARTICLE.dotTextureOrder),
1243
+ dotRotation: oneOf(raw.dotRotation, DOT_ROTATIONS, base.dotRotation ?? DEFAULT_PARTICLE.dotRotation)
1013
1244
  };
1014
1245
  }
1246
+ var FUZZY_AMPLITUDE = 0.12;
1247
+ var NOMINAL_MS_PER_PX = 2;
1248
+ var RIBBON_SUB = 4;
1249
+ function chainDamp(damp, k) {
1250
+ return Math.min(damp, 1 / (1 + 0.8 * Math.sqrt(Math.max(0, k))) ** 2);
1251
+ }
1252
+ function addPolygonArea(pts, rect, out, weight = 1) {
1253
+ const n = pts.length >> 1;
1254
+ if (n < 3) return;
1255
+ let y0 = Infinity;
1256
+ let y1 = -Infinity;
1257
+ for (let i = 1; i < pts.length; i += 2) {
1258
+ if (pts[i] < y0) y0 = pts[i];
1259
+ if (pts[i] > y1) y1 = pts[i];
1260
+ }
1261
+ const r0 = Math.max(0, Math.floor(y0) - rect.y);
1262
+ const r1 = Math.min(rect.height - 1, Math.floor(y1) - rect.y);
1263
+ const xs = [];
1264
+ const w = weight / RIBBON_SUB;
1265
+ for (let row = r0; row <= r1; row++) {
1266
+ const base = row * rect.width;
1267
+ for (let sub = 0; sub < RIBBON_SUB; sub++) {
1268
+ const y = rect.y + row + (sub + 0.5) / RIBBON_SUB;
1269
+ xs.length = 0;
1270
+ for (let i = 0, j = n - 1; i < n; j = i++) {
1271
+ const yi = pts[i * 2 + 1];
1272
+ const yj = pts[j * 2 + 1];
1273
+ if (yi <= y !== yj <= y) xs.push(pts[i * 2] + (y - yi) / (yj - yi) * (pts[j * 2] - pts[i * 2]));
1274
+ }
1275
+ if (xs.length < 2) continue;
1276
+ xs.sort((a, b) => a - b);
1277
+ for (let k = 0; k + 1 < xs.length; k += 2) {
1278
+ const a = Math.max(rect.x, xs[k]);
1279
+ const b = Math.min(rect.x + rect.width, xs[k + 1]);
1280
+ if (!(b > a)) continue;
1281
+ for (let px = Math.floor(a); px < b; px++) {
1282
+ const cover = Math.min(b, px + 1) - Math.max(a, px);
1283
+ if (cover > 0) out[base + px - rect.x] += cover * w;
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+ }
1015
1289
  function curl(x, y, seed) {
1016
1290
  const n = (u, v) => {
1017
1291
  const x0 = Math.floor(u);
@@ -1032,12 +1306,51 @@ function curl(x, y, seed) {
1032
1306
  }
1033
1307
  var ParticleStroke = class extends MarkStroke {
1034
1308
  sets;
1309
+ /** Ribbon pairs per copy: particles in their order around the pen (non-flow kinds). */
1310
+ orders;
1035
1311
  s;
1036
1312
  spawn = 0;
1313
+ /** Textured dots drawn so far, per copy (sequence order, random picks). */
1314
+ dots;
1315
+ /** Ribbon fill: the area each pixel has had so far this stroke (telescoped into the coverage). */
1316
+ filled = /* @__PURE__ */ new Map();
1317
+ pts = [];
1037
1318
  constructor(brush, options) {
1038
1319
  super(brush, options);
1039
1320
  this.s = brush.particle;
1040
1321
  this.sets = this.copies.map(() => []);
1322
+ this.orders = this.copies.map(() => []);
1323
+ this.dots = this.copies.map(() => 0);
1324
+ }
1325
+ /** Strand kinds: a chain of masses behind each particle. */
1326
+ get strands() {
1327
+ return this.s.kind === "string" || this.s.kind === "elastic" || this.s.kind === "fuzzy";
1328
+ }
1329
+ newParticle(x, y, ox, oy, k, phase, i, copy) {
1330
+ const s = this.s;
1331
+ const fuzzy = s.kind === "fuzzy";
1332
+ const jitter = fuzzy ? 1 : s.wavePhaseJitter;
1333
+ return {
1334
+ x,
1335
+ y,
1336
+ vx: 0,
1337
+ vy: 0,
1338
+ ox,
1339
+ oy,
1340
+ k,
1341
+ phase,
1342
+ age: 0,
1343
+ run: 0,
1344
+ bead: 0,
1345
+ alive: true,
1346
+ wavePhase: jitter > 0 ? jitter * Math.PI * 2 * rand01(this.seed, i, 43 + copy) : 0,
1347
+ waveScale: fuzzy ? 0.6 + 0.8 * rand01(this.seed, i, 47 + copy) : 1,
1348
+ dx: x,
1349
+ dy: y,
1350
+ px: NaN,
1351
+ py: NaN,
1352
+ fade: 1
1353
+ };
1041
1354
  }
1042
1355
  init(copy, d) {
1043
1356
  const s = this.s;
@@ -1048,18 +1361,28 @@ var ParticleStroke = class extends MarkStroke {
1048
1361
  const a = rand01(this.seed, i, 5 + copy) * Math.PI * 2;
1049
1362
  const ox = Math.cos(a) * r;
1050
1363
  const oy = Math.sin(a) * r;
1051
- const p = { x: d.x + ox, y: d.y + oy, vx: 0, vy: 0, ox, oy, k: 0.6 + 0.8 * rand01(this.seed, i, 7), phase: a, age: 0, run: 0, bead: 0, alive: true };
1052
- if (s.kind === "string") p.strand = Array.from({ length: 6 }, () => ({ x: d.x + ox, y: d.y + oy, vx: 0, vy: 0 }));
1364
+ const p = this.newParticle(d.x + ox, d.y + oy, ox, oy, 0.6 + 0.8 * rand01(this.seed, i, 7), a, i, copy);
1365
+ if (this.strands) p.strand = Array.from({ length: 6 }, () => ({ x: d.x + ox, y: d.y + oy, vx: 0, vy: 0 }));
1053
1366
  if (s.kind === "orbit") {
1054
1367
  p.ox = Math.max(1, r + size * 0.15);
1055
1368
  p.oy = 0;
1056
1369
  }
1057
1370
  list.push(p);
1058
1371
  }
1372
+ this.orders[copy] = list.map((_, i) => i).sort((i, j) => list[i].phase - list[j].phase || i - j);
1373
+ }
1374
+ /** The sideways wave of particle `p` at pen dab `d` (px along the normal to the pen's direction). */
1375
+ wave(p, d, size) {
1376
+ const s = this.s;
1377
+ const amp = s.kind === "fuzzy" ? Math.max(FUZZY_AMPLITUDE, s.waveAmplitude) : s.waveAmplitude;
1378
+ if (!(amp > 0)) return 0;
1379
+ const ms = d.time ?? d.travel * NOMINAL_MS_PER_PX;
1380
+ const phase = 2 * Math.PI * d.travel / (s.waveLength * p.waveScale) - s.waveSpeed * 0.025 * ms + p.wavePhase;
1381
+ return amp * size * Math.sin(phase);
1059
1382
  }
1060
1383
  advance(copy, d, rects) {
1061
1384
  const s = this.s;
1062
- const list = this.sets[copy];
1385
+ let list = this.sets[copy];
1063
1386
  if (d.index === 0 && !list.length) this.init(copy, d);
1064
1387
  const size = Math.max(1, d.radius * 2);
1065
1388
  const markR = Math.max(0.5, size * s.size / 2);
@@ -1068,6 +1391,8 @@ var ParticleStroke = class extends MarkStroke {
1068
1391
  const g = s.gravity * 0.15;
1069
1392
  const op = d.opacity;
1070
1393
  const al = Math.max(0.05, d.alpha);
1394
+ const nx = Math.sin(d.direction);
1395
+ const ny = Math.cos(d.direction);
1071
1396
  if (s.kind === "flow") {
1072
1397
  this.spawn += s.count / 8;
1073
1398
  while (this.spawn >= 1) {
@@ -1075,15 +1400,13 @@ var ParticleStroke = class extends MarkStroke {
1075
1400
  const i = this.dabCount + list.length * 31 + Math.floor(d.index);
1076
1401
  const a = rand01(this.seed, i, 11 + copy) * Math.PI * 2;
1077
1402
  const r = rand01(this.seed, i, 13 + copy) * s.spread * size * 0.5;
1078
- list.push({ x: d.x + Math.cos(a) * r, y: d.y + Math.sin(a) * r, vx: 0, vy: 0, ox: 0, oy: 0, k: 1, phase: a, age: 0, run: 0, bead: 0, alive: true });
1403
+ list.push(this.newParticle(d.x + Math.cos(a) * r, d.y + Math.sin(a) * r, 0, 0, 1, a, i, copy));
1079
1404
  }
1080
1405
  }
1081
1406
  const lifeSteps = 8 + 120 * s.life;
1082
1407
  for (let i = 0; i < list.length; i++) {
1083
1408
  const p = list[i];
1084
1409
  if (!p.alive) continue;
1085
- const px = p.x;
1086
- const py = p.y;
1087
1410
  switch (s.kind) {
1088
1411
  case "spring": {
1089
1412
  p.vx = (p.vx + (d.x + p.ox - p.x) * k * p.k) * damp;
@@ -1092,13 +1415,38 @@ var ParticleStroke = class extends MarkStroke {
1092
1415
  p.y += p.vy;
1093
1416
  break;
1094
1417
  }
1095
- case "string": {
1418
+ case "string":
1419
+ case "fuzzy": {
1096
1420
  const strand = p.strand;
1421
+ const dm = s.kind === "fuzzy" ? chainDamp(damp, k * p.k) : damp;
1097
1422
  let ax = d.x + p.ox;
1098
1423
  let ay = d.y + p.oy;
1099
1424
  for (const m of strand) {
1100
- m.vx = (m.vx + (ax - m.x) * k * p.k + 0) * damp;
1101
- m.vy = (m.vy + (ay - m.y) * k * p.k + g) * damp;
1425
+ m.vx = (m.vx + (ax - m.x) * k * p.k + 0) * dm;
1426
+ m.vy = (m.vy + (ay - m.y) * k * p.k + g) * dm;
1427
+ m.x += m.vx;
1428
+ m.y += m.vy;
1429
+ ax = m.x;
1430
+ ay = m.y;
1431
+ }
1432
+ p.x = strand[strand.length - 1].x;
1433
+ p.y = strand[strand.length - 1].y;
1434
+ break;
1435
+ }
1436
+ case "elastic": {
1437
+ const strand = p.strand;
1438
+ const rest = size * 0.12;
1439
+ const ke = Math.min(0.9, (0.1 + 0.6 * s.stiffness) * p.k);
1440
+ const dm = chainDamp(damp, ke);
1441
+ let ax = d.x + p.ox;
1442
+ let ay = d.y + p.oy;
1443
+ for (const m of strand) {
1444
+ const ex = ax - m.x;
1445
+ const ey = ay - m.y;
1446
+ const len = Math.hypot(ex, ey);
1447
+ const pull = len > rest ? (len - rest) / len : 0;
1448
+ m.vx = (m.vx + ex * pull * ke) * dm;
1449
+ m.vy = (m.vy + ey * pull * ke + g) * dm;
1102
1450
  m.x += m.vx;
1103
1451
  m.y += m.vy;
1104
1452
  ax = m.x;
@@ -1127,11 +1475,21 @@ var ParticleStroke = class extends MarkStroke {
1127
1475
  break;
1128
1476
  }
1129
1477
  }
1478
+ const w = this.wave(p, d, size);
1479
+ const qx = w ? p.x + nx * w : p.x;
1480
+ const qy = w ? p.y + ny * w : p.y;
1481
+ const px = p.dx;
1482
+ const py = p.dy;
1483
+ p.px = p.dx;
1484
+ p.py = p.dy;
1485
+ p.dx = qx;
1486
+ p.dy = qy;
1130
1487
  const fade = s.kind === "flow" ? 1 - p.age / lifeSteps : 1;
1131
- const moved = Math.hypot(p.x - px, p.y - py);
1488
+ p.fade = fade;
1489
+ const moved = Math.hypot(qx - px, qy - py);
1132
1490
  if (d.index === 0 && s.kind !== "flow") continue;
1133
- if (s.render === "dot") this.mark(p.x, p.y, markR * fade, al * fade, op, d.pressure, rects);
1134
- else if (s.render === "line") this.segment(px, py, p.x, p.y, markR * fade, al * fade, op, d.pressure, rects);
1491
+ if (s.render === "dot") this.dot(copy, qx, qy, qx - px, qy - py, markR * fade, al * fade, op, d, rects);
1492
+ else if (s.render === "line" || s.render === "ribbon") this.segment(px, py, qx, qy, markR * fade, al * fade, op, d.pressure, rects);
1135
1493
  else {
1136
1494
  p.run += moved;
1137
1495
  const gap = Math.max(markR * 2, size * (0.05 + s.interval));
@@ -1139,13 +1497,101 @@ var ParticleStroke = class extends MarkStroke {
1139
1497
  p.run -= gap;
1140
1498
  p.bead++;
1141
1499
  const t = moved > 0 ? 1 - p.run / Math.max(moved, 1e-6) : 1;
1142
- const bx = px + (p.x - px) * Math.min(1, Math.max(0, t));
1143
- const by = py + (p.y - py) * Math.min(1, Math.max(0, t));
1144
- this.mark(bx, by, markR * (0.7 + 0.6 * (p.bead % 3 / 2)) * fade, al * fade, op, d.pressure, rects);
1500
+ const bx = px + (qx - px) * Math.min(1, Math.max(0, t));
1501
+ const by = py + (qy - py) * Math.min(1, Math.max(0, t));
1502
+ this.dot(copy, bx, by, qx - px, qy - py, markR * (0.7 + 0.6 * (p.bead % 3 / 2)) * fade, al * fade, op, d, rects);
1145
1503
  }
1146
1504
  }
1147
1505
  }
1148
- if (s.kind === "flow" && list.length > 512) this.sets[copy] = list.filter((p) => p.alive);
1506
+ if (s.render === "ribbon" && !(d.index === 0 && s.kind !== "flow")) this.ribbon(copy, list, op, al, d, rects);
1507
+ if (s.kind === "flow" && list.length > 512) {
1508
+ list = list.filter((p) => p.alive);
1509
+ this.sets[copy] = list;
1510
+ }
1511
+ }
1512
+ /** A dot (or bead): round, or a textured stamp turned by the particle's motion (mx, my), at random or to the tip angle. */
1513
+ dot(copy, x, y, mx, my, r, alpha, opacity, d, rects) {
1514
+ const s = this.s;
1515
+ const textures = s.dotTextures;
1516
+ if (!textures.length) return this.mark(x, y, r, alpha, opacity, d.pressure, rects);
1517
+ const n = this.dots[copy]++;
1518
+ const pick = s.dotTextureOrder === "random" ? Math.min(textures.length - 1, Math.floor(rand01(this.seed, n, 31 + copy) * textures.length)) : n % textures.length;
1519
+ const rotation = s.dotRotation === "random" ? rand01(this.seed, n, 37 + copy) * Math.PI * 2 : s.dotRotation === "follow" ? mx || my ? Math.atan2(my, mx) : -d.direction : -this.brush.tip.angle * Math.PI / 180;
1520
+ this.stamp(x, y, r, textures[pick], rotation, this.seed + n * 2654435761 >>> 0, alpha, opacity, d.pressure, rects);
1521
+ }
1522
+ /**
1523
+ * Ribbon fill for one step: the band between each pair of joined particles, from where they
1524
+ * were drawn last step to where they are drawn now. Bands of one step, and of consecutive steps,
1525
+ * share edges exactly, so the fill has no seams; it is telescoped into the coverage
1526
+ * (`telescope`), so it does not build up at the joins.
1527
+ */
1528
+ ribbon(copy, list, opacity, alpha, d, rects) {
1529
+ const s = this.s;
1530
+ if (!(s.ribbonFill > 0)) return;
1531
+ const live = (p) => !!p && p.alive && !Number.isNaN(p.px);
1532
+ let order;
1533
+ if (s.kind === "flow") order = list.filter(live);
1534
+ else order = this.orders[copy].map((i) => list[i]).filter(live);
1535
+ if (order.length < 2) return;
1536
+ const pairs = [];
1537
+ if (s.ribbonPairs === "first-last") pairs.push([order[0], order[order.length - 1]]);
1538
+ else for (let i = 0; i + 1 < order.length; i++) pairs.push([order[i], order[i + 1]]);
1539
+ const pts = this.pts;
1540
+ const ceil = Math.min(1, opacity * s.ribbonFill);
1541
+ for (const [a, b] of pairs) {
1542
+ pts.length = 0;
1543
+ pts.push(a.px, a.py, a.dx, a.dy, b.dx, b.dy, b.px, b.py);
1544
+ let x0 = Infinity;
1545
+ let y0 = Infinity;
1546
+ let x1 = -Infinity;
1547
+ let y1 = -Infinity;
1548
+ for (let i = 0; i < 8; i += 2) {
1549
+ x0 = Math.min(x0, pts[i]);
1550
+ x1 = Math.max(x1, pts[i]);
1551
+ y0 = Math.min(y0, pts[i + 1]);
1552
+ y1 = Math.max(y1, pts[i + 1]);
1553
+ }
1554
+ const rect = { x: Math.floor(x0), y: Math.floor(y0), width: Math.floor(x1) - Math.floor(x0) + 1, height: Math.floor(y1) - Math.floor(y0) + 1 };
1555
+ const area = this.scratch(rect.width * rect.height);
1556
+ addPolygonArea(pts, rect, area, Math.min(a.fade, b.fade) * alpha);
1557
+ if (this.telescope(rect, area)) this.lay(rect, area, ceil, d.pressure, (a.dx + b.dx) / 2, (a.dy + b.dy) / 2, rects);
1558
+ }
1559
+ }
1560
+ /**
1561
+ * Turns the area newly filled over `rect` into coverage to lay towards the fill's ceiling: the
1562
+ * share of what was left unfilled, so after any number of bands (in any grouping) a pixel's
1563
+ * coverage is the ceiling times the area filled there. False when nothing was added.
1564
+ */
1565
+ telescope(rect, area) {
1566
+ let any = false;
1567
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1568
+ const gy = rect.y + yy;
1569
+ const ty = Math.floor(gy / TILE);
1570
+ let t;
1571
+ let tx = NaN;
1572
+ for (let xx = 0; xx < rect.width; xx++, k++) {
1573
+ const add = area[k];
1574
+ if (!(add > 0)) continue;
1575
+ const gx = rect.x + xx;
1576
+ const ntx = Math.floor(gx / TILE);
1577
+ if (ntx !== tx || !t) {
1578
+ tx = ntx;
1579
+ const key = tileKey(tx, ty);
1580
+ t = this.filled.get(key);
1581
+ if (!t) {
1582
+ t = new Float32Array(TILE * TILE);
1583
+ this.filled.set(key, t);
1584
+ }
1585
+ }
1586
+ const ti = (gy - ty * TILE) * TILE + (gx - tx * TILE);
1587
+ const before = t[ti];
1588
+ const after = Math.min(1, before + add);
1589
+ t[ti] = after;
1590
+ area[k] = before < 1 ? (after - before) / (1 - before) : 0;
1591
+ any = true;
1592
+ }
1593
+ }
1594
+ return any;
1149
1595
  }
1150
1596
  };
1151
1597
  var DEFAULT_BRISTLE = { count: 32, stiffness: 0.5, splay: 0.5, thickness: 0.06, load: 0.6, jitter: 0.4 };
@@ -1897,6 +2343,66 @@ var BUILTIN_BRUSHES = [
1897
2343
  particle: { kind: "flow", render: "dot", count: 8, size: 0.06, spread: 0.7, speed: 0.9, life: 0.3 },
1898
2344
  light: { amount: 1 }
1899
2345
  }),
2346
+ builtin({
2347
+ // Silk of light: strands dragged behind the pen on slow waves, the bands between them filled.
2348
+ id: "light-ribbon",
2349
+ name: "Light ribbon",
2350
+ category: "light",
2351
+ engine: "particle",
2352
+ tip: { size: 80, hardness: 0.8, opacity: 0.9, flow: 1, smoothing: 0.35, pressureSize: false, pressureOpacity: false },
2353
+ particle: { kind: "string", render: "ribbon", count: 5, size: 0.015, spread: 1, stiffness: 0.05, damping: 0.3, ribbonFill: 0.45, waveAmplitude: 0.25, waveLength: 220, wavePhaseJitter: 0.25 },
2354
+ light: { amount: 0.8 }
2355
+ }),
2356
+ builtin({
2357
+ // Flames: short-lived bands thrown off the pen, rising and curling in the flow.
2358
+ id: "flame-ribbon",
2359
+ name: "Flame ribbon",
2360
+ category: "light",
2361
+ engine: "particle",
2362
+ tip: { size: 50, hardness: 0.6, opacity: 0.85, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
2363
+ particle: { kind: "flow", render: "ribbon", count: 4, size: 0.012, spread: 0.5, speed: 0.6, gravity: -0.6, life: 0.2, ribbonFill: 0.5 },
2364
+ light: { amount: 1 }
2365
+ }),
2366
+ builtin({
2367
+ // A neon tube that lags and snaps back, with a slow shimmer.
2368
+ id: "neon-strand",
2369
+ name: "Neon strand",
2370
+ category: "light",
2371
+ engine: "particle",
2372
+ tip: { size: 40, hardness: 0.9, opacity: 1, flow: 1, smoothing: 0.4, pressureSize: false, pressureOpacity: false },
2373
+ particle: { kind: "elastic", render: "line", count: 3, size: 0.05, spread: 0.25, stiffness: 0.6, damping: 0.3, waveAmplitude: 0.06, waveLength: 120 },
2374
+ light: { amount: 1 }
2375
+ }),
2376
+ builtin({
2377
+ // Smoke lit from within: thin bands thrown into a slow, rising flow.
2378
+ id: "smoke-ribbon",
2379
+ name: "Smoke ribbon",
2380
+ category: "light",
2381
+ engine: "particle",
2382
+ tip: { size: 70, hardness: 0.3, opacity: 0.45, flow: 1, smoothing: 0.3, pressureSize: true, pressureOpacity: false },
2383
+ particle: { kind: "flow", render: "ribbon", count: 3, size: 5e-3, spread: 0.5, speed: 0.15, gravity: -0.15, life: 0.35, ribbonFill: 0.25, waveAmplitude: 0.1, waveLength: 140, wavePhaseJitter: 1 },
2384
+ light: { amount: 0.3 }
2385
+ }),
2386
+ builtin({
2387
+ // Out-of-focus lights: soft discs and rings spaced along springy paths.
2388
+ id: "bokeh-dots",
2389
+ name: "Bokeh dots",
2390
+ category: "light",
2391
+ engine: "particle",
2392
+ tip: { size: 80, hardness: 0.7, opacity: 0.6, flow: 1, smoothing: 0.3, pressureSize: true, pressureOpacity: false },
2393
+ particle: { kind: "spring", render: "sequence", count: 3, size: 0.3, spread: 1, stiffness: 0.15, damping: 0.4, interval: 1, dotTextures: ["dot", "dot", "ring"], dotTextureOrder: "random" },
2394
+ light: { amount: 0.9 }
2395
+ }),
2396
+ builtin({
2397
+ // Sparks: twinkles and stars thrown into the flow, spaced along it.
2398
+ id: "twinkle-sparks",
2399
+ name: "Twinkle sparks",
2400
+ category: "light",
2401
+ engine: "particle",
2402
+ tip: { size: 50, hardness: 0.8, opacity: 1, flow: 1, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
2403
+ particle: { kind: "flow", render: "sequence", count: 3, size: 0.25, spread: 0.8, speed: 0.9, life: 0.35, interval: 0.6, dotTextures: ["sparkle", "sparkle", "star"], dotTextureOrder: "random", dotRotation: "random" },
2404
+ light: { amount: 1 }
2405
+ }),
1900
2406
  builtin({
1901
2407
  id: "glow-wash",
1902
2408
  name: "Glow wash",
@@ -2067,11 +2573,15 @@ export {
2067
2573
  DryStroke,
2068
2574
  mixerTip,
2069
2575
  toStrokePoint,
2070
- STAMP_KINDS,
2576
+ STICKER_ART_SIZE,
2577
+ defaultStickerRasterizer,
2578
+ StickerArtCache,
2579
+ sampleArt,
2580
+ MAX_STICKER_STAMPS,
2071
2581
  DEFAULT_STICKER,
2072
2582
  normalizeStickerSettings,
2073
- stampCoverage,
2074
2583
  stickerRect,
2584
+ stickerStamp,
2075
2585
  drawSticker,
2076
2586
  StickerSpray,
2077
2587
  renderStickers,
@@ -2080,8 +2590,13 @@ export {
2080
2590
  stickerAt,
2081
2591
  PARTICLE_KINDS,
2082
2592
  PARTICLE_RENDERS,
2593
+ RIBBON_PAIRS,
2594
+ DOT_TEXTURE_ORDERS,
2595
+ DOT_ROTATIONS,
2596
+ MAX_DOT_TEXTURES,
2083
2597
  DEFAULT_PARTICLE,
2084
2598
  normalizeParticle,
2599
+ addPolygonArea,
2085
2600
  ParticleStroke,
2086
2601
  DEFAULT_BRISTLE,
2087
2602
  normalizeBristle,