@pixerate/editor 0.255.0 → 0.257.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 (129) hide show
  1. package/dist/agent/index.cjs +40 -38
  2. package/dist/agent/index.js +9 -7
  3. package/dist/animation/index.cjs +4 -2
  4. package/dist/animation/index.js +4 -2
  5. package/dist/animation-editor/index.cjs +63 -61
  6. package/dist/animation-editor/index.js +7 -5
  7. package/dist/brushes/colour.d.cts +11 -0
  8. package/dist/brushes/colour.d.ts +11 -0
  9. package/dist/brushes/index.cjs +51 -30
  10. package/dist/brushes/index.d.cts +1 -0
  11. package/dist/brushes/index.d.ts +1 -0
  12. package/dist/brushes/index.js +25 -4
  13. package/dist/brushes/resolve.d.cts +2 -0
  14. package/dist/brushes/resolve.d.ts +2 -0
  15. package/dist/brushes/schema.d.cts +1 -0
  16. package/dist/brushes/schema.d.ts +1 -0
  17. package/dist/brushes/tools.d.cts +7 -0
  18. package/dist/brushes/tools.d.ts +7 -0
  19. package/dist/brushes/types.d.cts +10 -2
  20. package/dist/brushes/types.d.ts +10 -2
  21. package/dist/brushes/ui.d.cts +14 -3
  22. package/dist/brushes/ui.d.ts +14 -3
  23. package/dist/{brushstrokeRender-HCOQUSWW.js → brushstrokeRender-JI4W6F2G.js} +2 -2
  24. package/dist/{brushstrokeRender-ZZOXI5O5.cjs → brushstrokeRender-MQ6PSDM5.cjs} +11 -11
  25. package/dist/canvas/index.cjs +20 -18
  26. package/dist/canvas/index.js +7 -5
  27. package/dist/chart/index.cjs +159 -157
  28. package/dist/chart/index.js +9 -7
  29. package/dist/{chunk-7272D3K2.js → chunk-2EUW2XZX.js} +27 -276
  30. package/dist/{chunk-L6Y7TSQ4.js → chunk-2VJ6OVJV.js} +1 -1
  31. package/dist/{chunk-6ZZKRH7R.js → chunk-47V3WXDG.js} +2 -2
  32. package/dist/{chunk-H7ET2ISK.cjs → chunk-4IMD2TLY.cjs} +52 -52
  33. package/dist/{chunk-VUKUTT2W.js → chunk-4M4E3N6X.js} +1 -1
  34. package/dist/{chunk-TBAVCLEQ.cjs → chunk-5Q5MLXAD.cjs} +23 -23
  35. package/dist/{chunk-MONXNVZK.js → chunk-6I23ZCGK.js} +5 -3
  36. package/dist/{chunk-GOBOCZZI.js → chunk-6NQPKNUZ.js} +1 -1
  37. package/dist/{chunk-WYGSR3IJ.js → chunk-AORZ5M5C.js} +1 -1
  38. package/dist/chunk-AVTPZO4Z.cjs +142 -0
  39. package/dist/{chunk-WKMUBATU.js → chunk-C2WEIEUB.js} +1 -1
  40. package/dist/{chunk-P3K2JCJA.js → chunk-D5BXSOSU.js} +3 -3
  41. package/dist/chunk-E2V5DA3W.js +36 -0
  42. package/dist/{chunk-WZOGDKCQ.js → chunk-F7CPSRLN.js} +0 -133
  43. package/dist/chunk-FJBQTZS4.js +17 -0
  44. package/dist/{chunk-SOSBOS5T.cjs → chunk-G23BTQA5.cjs} +22 -22
  45. package/dist/{chunk-EUIDTZNQ.cjs → chunk-I3Y2Q3PM.cjs} +59 -308
  46. package/dist/{chunk-YHUMGBZD.js → chunk-ICTPLVSR.js} +22 -4
  47. package/dist/{chunk-LRNUZKWV.cjs → chunk-IPUTIUGQ.cjs} +1 -1
  48. package/dist/{chunk-6GRUVGKU.js → chunk-ITURMC2M.js} +3 -3
  49. package/dist/{chunk-HYWRU5VX.js → chunk-IXQZHCOF.js} +1 -1
  50. package/dist/chunk-J5UQHWVL.js +142 -0
  51. package/dist/{chunk-YBAPT7NL.js → chunk-JC2SHA4A.js} +1 -23
  52. package/dist/chunk-LRTR4ASZ.cjs +17 -0
  53. package/dist/{chunk-HUAISCUX.cjs → chunk-M2HG5DJ2.cjs} +18 -18
  54. package/dist/{chunk-N76AMOKE.cjs → chunk-MDLEGVEN.cjs} +42 -42
  55. package/dist/chunk-MEGSQUQO.cjs +36 -0
  56. package/dist/chunk-MLLTWAXH.cjs +276 -0
  57. package/dist/{chunk-RHPSE2YR.js → chunk-MQ6MGIQM.js} +2 -2
  58. package/dist/{chunk-PGPFWC35.cjs → chunk-MQGQTKVW.cjs} +51 -33
  59. package/dist/{chunk-5YWWQ36H.cjs → chunk-O3ZVZSNJ.cjs} +9 -9
  60. package/dist/{chunk-GCTUOTCW.cjs → chunk-P3PZ63M3.cjs} +35 -35
  61. package/dist/{chunk-WRZ6C4TU.cjs → chunk-P6WYDBTH.cjs} +4 -4
  62. package/dist/{chunk-CJ6TBL2N.cjs → chunk-RHEMMGCN.cjs} +16 -16
  63. package/dist/{chunk-HIMGBZAM.cjs → chunk-RSIKDWOP.cjs} +201 -38
  64. package/dist/{chunk-SABJMH6M.cjs → chunk-RVGP2XLF.cjs} +2 -135
  65. package/dist/{chunk-JVRLGV3C.js → chunk-SMTUQWGY.js} +184 -21
  66. package/dist/{chunk-DMTEQ6SH.js → chunk-SSFMWJDK.js} +4 -4
  67. package/dist/{chunk-TFF2GTGN.cjs → chunk-SXQHWXEL.cjs} +2 -24
  68. package/dist/{chunk-IWEVV63O.cjs → chunk-UEYLANO3.cjs} +18 -16
  69. package/dist/chunk-VR6OGHU5.js +276 -0
  70. package/dist/{chunk-AF23QK5F.js → chunk-YQ4PQTBB.js} +1 -1
  71. package/dist/{chunk-B7V34GIH.cjs → chunk-YSGW7VV4.cjs} +88 -88
  72. package/dist/{chunk-3NS5C7FB.cjs → chunk-YYBW6DF2.cjs} +76 -76
  73. package/dist/{chunk-UFEDHACQ.js → chunk-Z7BX4TWO.js} +2 -2
  74. package/dist/{chunk-RUONPPMC.cjs → chunk-ZSE25IZA.cjs} +8 -8
  75. package/dist/color/index.cjs +7 -3
  76. package/dist/color/index.js +18 -14
  77. package/dist/export/index.cjs +8 -6
  78. package/dist/export/index.js +7 -5
  79. package/dist/extensions/index.cjs +11 -9
  80. package/dist/extensions/index.js +10 -8
  81. package/dist/gradient/index.cjs +33 -31
  82. package/dist/gradient/index.js +5 -3
  83. package/dist/image-editor/brush-presets.d.cts +4 -1
  84. package/dist/image-editor/brush-presets.d.ts +4 -1
  85. package/dist/image-editor/controller.d.cts +10 -5
  86. package/dist/image-editor/controller.d.ts +10 -5
  87. package/dist/image-editor/index.cjs +1090 -1073
  88. package/dist/image-editor/index.js +37 -20
  89. package/dist/index.cjs +17 -15
  90. package/dist/index.js +11 -9
  91. package/dist/paint-editor/brushes.d.cts +18 -1
  92. package/dist/paint-editor/brushes.d.ts +18 -1
  93. package/dist/paint-editor/collaboration-tools.d.cts +19 -0
  94. package/dist/paint-editor/collaboration-tools.d.ts +19 -0
  95. package/dist/paint-editor/collaboration.d.cts +180 -0
  96. package/dist/paint-editor/collaboration.d.ts +180 -0
  97. package/dist/paint-editor/controller.d.cts +83 -0
  98. package/dist/paint-editor/controller.d.ts +83 -0
  99. package/dist/paint-editor/engine.d.cts +7 -0
  100. package/dist/paint-editor/engine.d.ts +7 -0
  101. package/dist/paint-editor/index.cjs +2027 -1331
  102. package/dist/paint-editor/index.d.cts +2 -0
  103. package/dist/paint-editor/index.d.ts +2 -0
  104. package/dist/paint-editor/index.js +1671 -975
  105. package/dist/paint-editor/mark-settings.d.cts +1 -1
  106. package/dist/paint-editor/mark-settings.d.ts +1 -1
  107. package/dist/paint-editor/procedural.d.cts +4 -2
  108. package/dist/paint-editor/procedural.d.ts +4 -2
  109. package/dist/paint-editor/stamp-tip.d.cts +45 -0
  110. package/dist/paint-editor/stamp-tip.d.ts +45 -0
  111. package/dist/paint-editor/stroke.d.cts +2 -0
  112. package/dist/paint-editor/stroke.d.ts +2 -0
  113. package/dist/stickers/index.cjs +4 -2
  114. package/dist/stickers/index.js +6 -4
  115. package/dist/typography/index.cjs +3 -2
  116. package/dist/typography/index.js +2 -1
  117. package/dist/vector/index.cjs +971 -969
  118. package/dist/vector/index.js +8 -6
  119. package/dist/video-editor/index.cjs +4 -2
  120. package/dist/video-editor/index.js +4 -2
  121. package/package.json +1 -1
  122. package/skills/pixerate-brushes/SKILL.md +21 -11
  123. package/skills/pixerate-brushes-design/SKILL.md +6 -2
  124. package/skills/pixerate-brushes-design/evals/canvas-chalk.json +8 -8
  125. package/skills/pixerate-canvas/SKILL.md +3 -2
  126. package/skills/pixerate-image-editor/SKILL.md +1 -1
  127. package/skills/pixerate-paint-editor/SKILL.md +35 -47
  128. package/skills/pixerate-paint-editor/references/painting-alongside.md +19 -0
  129. package/skills/pixerate-vector/SKILL.md +1 -1
@@ -1,9 +1,7 @@
1
1
  import {
2
2
  StickerBrowser,
3
- createStickerTools,
4
- describeStickerRef,
5
3
  editorStickers
6
- } from "../chunk-WZOGDKCQ.js";
4
+ } from "../chunk-F7CPSRLN.js";
7
5
  import {
8
6
  BRUSH_PATCH_PROPERTIES,
9
7
  BRUSH_SLIDERS,
@@ -13,7 +11,11 @@ import {
13
11
  mergeBrushPatch,
14
12
  multiColorSliders,
15
13
  patchBrushValue
16
- } from "../chunk-YHUMGBZD.js";
14
+ } from "../chunk-ICTPLVSR.js";
15
+ import {
16
+ createStickerTools,
17
+ describeStickerRef
18
+ } from "../chunk-J5UQHWVL.js";
17
19
  import {
18
20
  toVideoFfmpeg
19
21
  } from "../chunk-MWUZCEFT.js";
@@ -24,9 +26,12 @@ import {
24
26
  resolveExternalAssets,
25
27
  serializeLayers,
26
28
  serializeLayersExternal
27
- } from "../chunk-DMTEQ6SH.js";
28
- import "../chunk-3CI7FRMK.js";
29
+ } from "../chunk-SSFMWJDK.js";
29
30
  import "../chunk-ZNA3IQZF.js";
31
+ import {
32
+ brushColourHex
33
+ } from "../chunk-FJBQTZS4.js";
34
+ import "../chunk-3CI7FRMK.js";
30
35
  import {
31
36
  LAYER_EFFECT_KINDS,
32
37
  LayerEffectError,
@@ -80,7 +85,7 @@ import {
80
85
  realPresenceClock,
81
86
  tweenPresenceCursor
82
87
  } from "../chunk-IVSWRVB4.js";
83
- import "../chunk-AF23QK5F.js";
88
+ import "../chunk-YQ4PQTBB.js";
84
89
  import {
85
90
  BRUSH_ID,
86
91
  BRUSH_PRESETS,
@@ -95,6 +100,7 @@ import {
95
100
  DEFAULT_LIGHT,
96
101
  DEFAULT_MULTI_COLOR,
97
102
  DEFAULT_PARTICLE,
103
+ DEFAULT_SPRAY,
98
104
  DEFAULT_STICKER,
99
105
  DEFAULT_WET_BRUSH,
100
106
  DOT_ROTATIONS,
@@ -134,7 +140,7 @@ import {
134
140
  parseBrushLibrary,
135
141
  resolveBrush,
136
142
  usesPaintExchange
137
- } from "../chunk-JVRLGV3C.js";
143
+ } from "../chunk-SMTUQWGY.js";
138
144
  import {
139
145
  STAMP_KINDS,
140
146
  StickersSlot,
@@ -273,8 +279,10 @@ import {
273
279
  colorHarmony,
274
280
  hueToWheel,
275
281
  wheelToHue
276
- } from "../chunk-7272D3K2.js";
277
- import "../chunk-YBAPT7NL.js";
282
+ } from "../chunk-2EUW2XZX.js";
283
+ import "../chunk-E2V5DA3W.js";
284
+ import "../chunk-VR6OGHU5.js";
285
+ import "../chunk-JC2SHA4A.js";
278
286
  import {
279
287
  linearToSrgb,
280
288
  oklabToOklch,
@@ -370,8 +378,8 @@ function normalizePaper(raw = {}, base) {
370
378
  ...readOffset(r.offset, b.offset)
371
379
  };
372
380
  }
373
- function hexToRgb8(hex) {
374
- const n = parseInt(hex.slice(1, 7), 16);
381
+ function hexToRgb8(hex2) {
382
+ const n = parseInt(hex2.slice(1, 7), 16);
375
383
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
376
384
  }
377
385
  function renderPaper(paper, rect, grain = new PaperGrain(paper), light) {
@@ -577,6 +585,75 @@ function effectDabField(img, rect, cov, centre2, mixed, field, steps = 0, dab =
577
585
  }
578
586
  }
579
587
 
588
+ // src/paint-editor/stamp-tip.ts
589
+ var samplers = /* @__PURE__ */ new WeakMap();
590
+ function stampSampler(ref2, art) {
591
+ let kind = null;
592
+ try {
593
+ kind = editorStampOf(ref2.includes(":") ? ref2 : `editor:${ref2}`);
594
+ } catch {
595
+ return null;
596
+ }
597
+ if (kind) {
598
+ const k = kind;
599
+ return (u, v, seed) => stampCoverage(k, u, v, seed);
600
+ }
601
+ const a = art?.(ref2);
602
+ if (!a || !(a.width > 0 && a.height > 0)) return null;
603
+ const hit = samplers.get(a);
604
+ if (hit) return hit;
605
+ const { width: w, height: h, data } = a;
606
+ const scale = Math.max(w, h) / 2;
607
+ const alpha = (x, y) => x < 0 || y < 0 || x >= w || y >= h ? 0 : data[(y * w + x) * 4 + 3] / 255;
608
+ const sampler = (u, v) => {
609
+ const x = u * scale + w / 2 - 0.5;
610
+ const y = v * scale + h / 2 - 0.5;
611
+ const x0 = Math.floor(x);
612
+ const y0 = Math.floor(y);
613
+ const fx = x - x0;
614
+ const fy = y - y0;
615
+ return (alpha(x0, y0) * (1 - fx) + alpha(x0 + 1, y0) * fx) * (1 - fy) + (alpha(x0, y0 + 1) * (1 - fx) + alpha(x0 + 1, y0 + 1) * fx) * fy;
616
+ };
617
+ samplers.set(a, sampler);
618
+ return sampler;
619
+ }
620
+ function stampDabRect(d) {
621
+ const r = Math.max(0.5, d.radius) * Math.SQRT2 + 1.5;
622
+ const x0 = Math.floor(d.x - r);
623
+ const y0 = Math.floor(d.y - r);
624
+ return { x: x0, y: y0, width: Math.ceil(d.x + r) - x0, height: Math.ceil(d.y + r) - y0 };
625
+ }
626
+ function rasterizeStampDab(d, sample, seed, rect, out) {
627
+ const w = rect.width;
628
+ const h = rect.height;
629
+ out.fill(0, 0, w * h);
630
+ const r = Math.max(0.5, d.radius);
631
+ const ro = Math.min(1, Math.max(0.05, d.roundness));
632
+ const sn = Math.sin(d.angle);
633
+ const cs = Math.cos(d.angle);
634
+ const ss = r < 6 ? 3 : 1;
635
+ for (let yy = 0; yy < h; yy++) {
636
+ for (let xx = 0; xx < w; xx++) {
637
+ let sum = 0;
638
+ for (let sy = 0; sy < ss; sy++) {
639
+ for (let sx = 0; sx < ss; sx++) {
640
+ const dx = rect.x + xx + (sx + 0.5) / ss - d.x;
641
+ const dy = rect.y + yy + (sy + 0.5) / ss - d.y;
642
+ const u = (dx * cs - dy * sn) / r;
643
+ const v = (dx * sn + dy * cs) / (r * ro);
644
+ if (u > -1.05 && u < 1.05 && v > -1.05 && v < 1.05) sum += sample(u, v, seed);
645
+ }
646
+ }
647
+ const val = sum / (ss * ss);
648
+ if (val > 0) out[yy * w + xx] = Math.min(1, val) * d.alpha;
649
+ }
650
+ }
651
+ }
652
+ function brushStickerRefs(brush) {
653
+ const refs = brush.engine === "sticker" ? [...brush.sticker.stamps] : brush.engine === "particle" ? [...brush.particle.dotTextures] : brush.engine === "dab" && brush.stamp ? [brush.stamp] : [];
654
+ return [...new Set(refs.filter((r) => r.includes(":") && !r.startsWith("editor:")))];
655
+ }
656
+
580
657
  // src/paint-editor/engine.ts
581
658
  function penOrientation(p) {
582
659
  if (typeof p.altitude === "number" && Number.isFinite(p.altitude)) {
@@ -795,6 +872,7 @@ var DryStroke = class {
795
872
  constructor(brush, options) {
796
873
  this.brush = brush;
797
874
  this.options = options;
875
+ this.stamp = brush.stamp ? stampSampler(brush.stamp, options.stampArt) : null;
798
876
  this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
799
877
  this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
800
878
  this.walkers = this.copies.map((c, i) => new PenDabWalker(brush, (options.seed ?? 0) + i * 7919 | 0, c.linear));
@@ -812,6 +890,8 @@ var DryStroke = class {
812
890
  field = new Float32Array(0);
813
891
  lastRaw = null;
814
892
  lastFed = null;
893
+ /** The stamp tip's shape, or `null` for the round computed tip. */
894
+ stamp;
815
895
  /** Feeds raw pen samples; returns the document rectangles the new dabs touched. */
816
896
  push(points) {
817
897
  const dabs = [];
@@ -834,34 +914,52 @@ var DryStroke = class {
834
914
  }
835
915
  paint(dabs) {
836
916
  const rects = [];
837
- const hardness = this.brush.tip.hardness;
838
- const grain = this.options.grain;
917
+ const spray = this.brush.spray;
918
+ const seed = this.options.seed ?? 0;
839
919
  for (const d of dabs) {
840
- const rect = dabRect(d, hardness);
841
- const n = rect.width * rect.height;
842
- if (this.buf.length < n) this.buf = new Float32Array(n);
843
- const vals = this.buf.subarray(0, n);
844
- rasterizeDab(d, hardness, rect, vals);
845
- if (this.depth > 0 && grain) {
846
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
847
- const y = rect.y + yy + 0.5;
848
- for (let xx = 0; xx < rect.width; xx++, k++) {
849
- if (vals[k] > 0) vals[k] *= grainCatch(grain.at(rect.x + xx + 0.5, y), this.depth, d.pressure);
850
- }
851
- }
920
+ if (!spray) {
921
+ this.mark(d, d, rects);
922
+ continue;
852
923
  }
853
- let field = null;
854
- if (this.options.effect) {
855
- if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
856
- field = this.field.subarray(0, n * 3);
857
- sampleEffectField(this.options.effect, rect, vals, field, d);
924
+ const count = 1 + Math.round(spray.density * 31);
925
+ const key = d.index * 131 + Math.round(d.travel * 8);
926
+ for (let i = 0; i < count; i++) {
927
+ const a = rand01(seed, key * 64 + i, 61) * Math.PI * 2;
928
+ const rr = Math.sqrt(rand01(seed, key * 64 + i, 67)) * d.radius;
929
+ const drop = { ...d, x: d.x + Math.cos(a) * rr, y: d.y + Math.sin(a) * rr, radius: Math.max(0.5, d.radius * spray.size * (0.6 + 0.8 * rand01(seed, key * 64 + i, 71))), roundness: 1, angle: rand01(seed, key * 64 + i, 73) * Math.PI * 2 };
930
+ this.mark(drop, d, rects);
858
931
  }
859
- this.coverage.accumulate(rect, vals, d.opacity, null, field);
860
- rects.push(rect);
861
- this.dabCount++;
862
932
  }
863
933
  return rects;
864
934
  }
935
+ /** Lays one mark (a dab, or a spray droplet of dab `d`): the round tip or the stamp, the paper grain, effect paint. */
936
+ mark(m, d, rects) {
937
+ const hardness = this.brush.tip.hardness;
938
+ const grain = this.options.grain;
939
+ const rect = this.stamp ? stampDabRect(m) : dabRect(m, hardness);
940
+ const n = rect.width * rect.height;
941
+ if (this.buf.length < n) this.buf = new Float32Array(n);
942
+ const vals = this.buf.subarray(0, n);
943
+ if (this.stamp) rasterizeStampDab(m, this.stamp, d.index, rect, vals);
944
+ else rasterizeDab(m, hardness, rect, vals);
945
+ if (this.depth > 0 && grain) {
946
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
947
+ const y = rect.y + yy + 0.5;
948
+ for (let xx = 0; xx < rect.width; xx++, k++) {
949
+ if (vals[k] > 0) vals[k] *= grainCatch(grain.at(rect.x + xx + 0.5, y), this.depth, d.pressure);
950
+ }
951
+ }
952
+ }
953
+ let field = null;
954
+ if (this.options.effect) {
955
+ if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
956
+ field = this.field.subarray(0, n * 3);
957
+ sampleEffectField(this.options.effect, rect, vals, field, d);
958
+ }
959
+ this.coverage.accumulate(rect, vals, m.opacity, null, field);
960
+ rects.push(rect);
961
+ this.dabCount++;
962
+ }
865
963
  };
866
964
  function mixerTip(brush) {
867
965
  return { ...brush.tip, smoothing: 0, pressureSize: brush.tip.pressureSize };
@@ -1820,984 +1918,986 @@ function lightFromJSON(raw) {
1820
1918
  return surfaceFromPixels({ width: img.width, height: img.height, data }, 1, r.rect.x, r.rect.y);
1821
1919
  }
1822
1920
 
1823
- // src/paint-editor/sticker-art.ts
1824
- var STICKER_ART_SIZE = 256;
1825
- var defaultStickerRasterizer = async (asset, size) => {
1826
- if (asset.kind === "image") {
1827
- const parsed = parseDataUrl(asset.url);
1828
- if (parsed && parsed.mime === "image/png") {
1829
- const png = decodePng(parsed.bytes);
1830
- return { width: png.width, height: png.height, data: new Uint8ClampedArray(png.data) };
1921
+ // src/paint-editor/procedural.ts
1922
+ var STEP_PX = 1.5;
1923
+ var MarkStroke = class {
1924
+ constructor(brush, options) {
1925
+ this.brush = brush;
1926
+ this.options = options;
1927
+ this.sampler = options.paint ? new PaintGradientSampler(options.paint, options.seed ?? 0) : null;
1928
+ this.tipTable = this.sampler?.acrossTip ? this.sampler.tipTable(TIP_GRADIENT_STEPS) : null;
1929
+ this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
1930
+ this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
1931
+ this.seed = options.seed ?? 0;
1932
+ const fine = { ...brush, tip: { ...brush.tip, spacing: Math.max(0.01, Math.min(0.25, STEP_PX / Math.max(1, brush.tip.size))) } };
1933
+ this.walkers = this.copies.map((c, i) => new PenDabWalker(fine, (options.seed ?? 0) + i * 7919 | 0, c.linear));
1934
+ this.depth = options.grain && !options.grain.flat ? Math.min(1, Math.max(0, options.paperDepth * brush.grain)) : 0;
1935
+ }
1936
+ brush;
1937
+ options;
1938
+ coverage = new CoverageMap();
1939
+ dabCount = 0;
1940
+ smoother;
1941
+ copies;
1942
+ walkers;
1943
+ depth;
1944
+ buf = new Float32Array(0);
1945
+ field = new Float32Array(0);
1946
+ lastRaw = null;
1947
+ lastFed = null;
1948
+ seed;
1949
+ sampler;
1950
+ tipTable;
1951
+ /** The pen dab the marks being made belong to, and its gradient colour. */
1952
+ pen = null;
1953
+ penColor = { rgb: [0, 0, 0], alpha: 1 };
1954
+ push(points) {
1955
+ const rects = [];
1956
+ for (const raw of points) {
1957
+ this.lastRaw = raw;
1958
+ const p = this.smoother.push(raw);
1959
+ this.lastFed = p;
1960
+ this.copies.forEach((c, i) => {
1961
+ const dabs = [];
1962
+ this.walkers[i].push(c.map(p), dabs);
1963
+ for (const d of dabs) this.step(i, d, rects);
1964
+ });
1831
1965
  }
1966
+ return rects;
1832
1967
  }
1833
- const g = globalThis;
1834
- if (!g.createImageBitmap || !g.OffscreenCanvas) {
1835
- 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).`);
1968
+ step(copy, d, rects) {
1969
+ this.pen = d;
1970
+ if (this.sampler && !this.tipTable) this.sampler.dab(d, this.penColor);
1971
+ this.advance(copy, d, rects);
1836
1972
  }
1837
- const blob = asset.kind === "svg" ? new Blob([asset.svg], { type: "image/svg+xml" }) : await (await g.fetch(asset.url)).blob();
1838
- const bitmap = await g.createImageBitmap(blob);
1839
- const scale = size / Math.max(bitmap.width, bitmap.height, 1);
1840
- const w = Math.max(1, Math.round(bitmap.width * scale));
1841
- const h = Math.max(1, Math.round(bitmap.height * scale));
1842
- const canvas = new g.OffscreenCanvas(w, h);
1843
- const ctx = canvas.getContext("2d");
1844
- ctx.drawImage(bitmap, 0, 0, w, h);
1845
- const img = ctx.getImageData(0, 0, w, h);
1846
- return { width: w, height: h, data: img.data };
1847
- };
1848
- var StickerArtCache = class {
1849
- entries = /* @__PURE__ */ new Map();
1850
- /** The art for `ref` when it has loaded (editor stamps never need any). */
1851
- get = (ref2) => {
1852
- const e = this.entries.get(ref2);
1853
- return e?.status === "ready" ? e.art : void 0;
1854
- };
1855
- /** `ready`, `loading`, `failed` (with the reason) or `none` (never asked for). Editor stamps are always `ready`. */
1856
- status(ref2) {
1857
- if (editorStampOf(ref2)) return { status: "ready" };
1858
- const e = this.entries.get(ref2);
1859
- if (!e) return { status: "none" };
1860
- return e.status === "failed" ? { status: "failed", error: e.error } : { status: e.status };
1973
+ /** The colour of a mark at (x, y): its pen dab's, or across the tip by its offset from the pen. */
1974
+ markColor(x, y) {
1975
+ if (!this.sampler || !this.pen) return null;
1976
+ if (!this.tipTable) return this.penColor;
1977
+ const d = this.pen;
1978
+ const u = ((x - d.x) * Math.sin(d.direction) + (y - d.y) * Math.cos(d.direction)) / Math.max(0.5, d.radius);
1979
+ const last = this.tipTable.length - 1;
1980
+ return this.tipTable[Math.min(last, Math.max(0, Math.round((u + 1) / 2 * last)))];
1981
+ }
1982
+ finish() {
1983
+ const rects = [];
1984
+ const raw = this.lastRaw;
1985
+ const fed = this.lastFed;
1986
+ this.copies.forEach((c, i) => {
1987
+ const dabs = [];
1988
+ if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.walkers[i].push(c.map({ ...raw, pressure: fed.pressure }), dabs);
1989
+ this.walkers[i].finish(dabs);
1990
+ for (const d of dabs) this.step(i, d, rects);
1991
+ });
1992
+ return rects;
1993
+ }
1994
+ /** Stamps one round mark of radius `r` at (x, y) with flow `alpha` towards `opacity`. */
1995
+ mark(x, y, r, alpha, opacity, pressure, rects) {
1996
+ if (!(r > 0.05) || !(alpha > 0)) return;
1997
+ const dab = { x, y, radius: Math.max(0.5, r), alpha: Math.min(1, alpha), opacity: Math.min(1, opacity), angle: 0, roundness: 1 };
1998
+ const hardness = this.brush.tip.hardness;
1999
+ const rect = dabRect(dab, hardness);
2000
+ const n = rect.width * rect.height;
2001
+ if (n <= 0) return;
2002
+ const vals = this.scratch(n);
2003
+ rasterizeDab(dab, hardness, rect, vals);
2004
+ this.lay(rect, vals, dab.opacity, pressure, x, y, rects);
1861
2005
  }
1862
2006
  /**
1863
- * Loads and rasterises `ref` from `provider` once (later calls share the load). Unknown refs
1864
- * reject with the closest known ones; failures are kept so they are reported, and retried on
1865
- * the next call.
2007
+ * Stamps a textured mark: `kind` from the sticker stamps over a square of half-size `r`, turned
2008
+ * `rotation` radians clockwise, with flow `alpha` towards `opacity`. Small marks are supersampled.
1866
2009
  */
1867
- ensure(provider, ref2, rasterize) {
1868
- if (editorStampOf(ref2)) return Promise.resolve(null);
1869
- const e = this.entries.get(ref2);
1870
- if (e?.status === "ready") return Promise.resolve(e.art);
1871
- if (e?.status === "loading") return e.promise;
1872
- const promise = (async () => {
1873
- const d = await describeStickerRef(provider, ref2);
1874
- const id = stickerIdFor(provider, ref2);
1875
- const max = provider.capabilities.maxSize ?? STICKER_ART_SIZE;
1876
- const size = Math.min(STICKER_ART_SIZE, max);
1877
- const asset = await provider.load(id, d.formats.includes("png") ? { format: "png", size } : { format: d.formats[0] ?? "svg", size });
1878
- const px = await rasterize(asset, size);
1879
- if (!(px.width > 0 && px.height > 0) || px.data.length !== px.width * px.height * 4) throw new StickerError("load_failed", `The art for "${ref2}" came back empty or the wrong size.`);
1880
- return { ...px, tintable: d.tintable };
1881
- })();
1882
- this.entries.set(ref2, { status: "loading", promise });
1883
- return promise.then(
1884
- (art) => {
1885
- this.entries.set(ref2, { status: "ready", art });
1886
- return art;
1887
- },
1888
- (err) => {
1889
- const message = err instanceof Error ? err.message : String(err);
1890
- this.entries.set(ref2, { status: "failed", error: message });
1891
- throw err;
2010
+ stamp(x, y, r, kind, rotation, seed, alpha, opacity, pressure, rects) {
2011
+ if (!(r > 0.05) || !(alpha > 0)) return;
2012
+ const sample = stampSampler(kind, this.options.stampArt);
2013
+ if (!sample) return this.mark(x, y, r, alpha, opacity, pressure, rects);
2014
+ const reach = Math.max(0.5, r) * Math.SQRT2 + 1;
2015
+ const rect = { x: Math.floor(x - reach), y: Math.floor(y - reach), width: 0, height: 0 };
2016
+ rect.width = Math.ceil(x + reach) - rect.x;
2017
+ rect.height = Math.ceil(y + reach) - rect.y;
2018
+ const n = rect.width * rect.height;
2019
+ const vals = this.scratch(n);
2020
+ const ss = r < 10 ? 3 : 1;
2021
+ const c = Math.cos(rotation);
2022
+ const s = Math.sin(rotation);
2023
+ const inv = 1 / Math.max(0.5, r);
2024
+ const a = Math.min(1, alpha);
2025
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
2026
+ for (let xx = 0; xx < rect.width; xx++, k++) {
2027
+ let sum = 0;
2028
+ for (let sy = 0; sy < ss; sy++) {
2029
+ for (let sx = 0; sx < ss; sx++) {
2030
+ const dx = rect.x + xx + (sx + 0.5) / ss - x;
2031
+ const dy = rect.y + yy + (sy + 0.5) / ss - y;
2032
+ sum += sample((dx * c + dy * s) * inv, (dy * c - dx * s) * inv, seed);
2033
+ }
2034
+ }
2035
+ vals[k] = sum / (ss * ss) * a;
1892
2036
  }
1893
- );
2037
+ }
2038
+ this.lay(rect, vals, Math.min(1, opacity), pressure, x, y, rects);
1894
2039
  }
1895
- /** Forgets every load (a new provider). */
1896
- clear() {
1897
- this.entries.clear();
2040
+ /** A scratch buffer of `n` values (reused; valid until the next mark). */
2041
+ scratch(n) {
2042
+ if (this.buf.length < n) this.buf = new Float32Array(n);
2043
+ const vals = this.buf.subarray(0, n);
2044
+ vals.fill(0);
2045
+ return vals;
2046
+ }
2047
+ /**
2048
+ * Lays coverage `vals` over `rect` towards `opacity`: paper grain, then effect paint or the
2049
+ * colour of a mark at (x, y) (gradient paint), into the stroke's coverage.
2050
+ */
2051
+ lay(rect, vals, opacity, pressure, x, y, rects) {
2052
+ const n = rect.width * rect.height;
2053
+ if (n <= 0) return;
2054
+ const grain = this.options.grain;
2055
+ if (this.depth > 0 && grain) {
2056
+ for (let yy = 0, k = 0; yy < rect.height; yy++)
2057
+ for (let xx = 0; xx < rect.width; xx++, k++) if (vals[k] > 0) vals[k] *= grainCatch(grain.at(rect.x + xx + 0.5, rect.y + yy + 0.5), this.depth, pressure);
2058
+ }
2059
+ if (this.options.effect) {
2060
+ if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
2061
+ const field = this.field.subarray(0, n * 3);
2062
+ sampleEffectField(this.options.effect, rect, vals, field, this.pen);
2063
+ this.coverage.accumulate(rect, vals, opacity, null, field);
2064
+ } else {
2065
+ const color = this.markColor(x, y);
2066
+ if (color && color.alpha < 1) for (let k = 0; k < n; k++) vals[k] *= color.alpha;
2067
+ this.coverage.accumulate(rect, vals, opacity, color ? color.rgb : null);
2068
+ }
2069
+ rects.push(rect);
2070
+ this.dabCount++;
2071
+ }
2072
+ /** Marks along a segment, spaced by a fraction of the radius. */
2073
+ segment(x0, y0, x1, y1, r, alpha, opacity, pressure, rects) {
2074
+ const len = Math.hypot(x1 - x0, y1 - y0);
2075
+ const step = Math.max(0.35, r * 0.5);
2076
+ const n = Math.max(1, Math.ceil(len / step));
2077
+ for (let i = 1; i <= n; i++) this.mark(x0 + (x1 - x0) * i / n, y0 + (y1 - y0) * i / n, r, alpha, opacity, pressure, rects);
1898
2078
  }
1899
2079
  };
1900
- function sampleArt(art, x, y, out) {
1901
- const { width: w, height: h, data } = art;
1902
- const fx = x - 0.5;
1903
- const fy = y - 0.5;
1904
- const x0 = Math.floor(fx);
1905
- const y0 = Math.floor(fy);
1906
- const tx = fx - x0;
1907
- const ty = fy - y0;
1908
- let r = 0;
1909
- let g = 0;
1910
- let b = 0;
1911
- let a = 0;
1912
- for (let k = 0; k < 4; k++) {
1913
- const xx = x0 + (k & 1);
1914
- const yy = y0 + (k >> 1);
1915
- if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
1916
- const wgt = (k & 1 ? tx : 1 - tx) * (k >> 1 ? ty : 1 - ty);
1917
- const i = (yy * w + xx) * 4;
1918
- const al = data[i + 3] * wgt;
1919
- r += data[i] * al;
1920
- g += data[i + 1] * al;
1921
- b += data[i + 2] * al;
1922
- a += al;
1923
- }
1924
- out[0] = a > 0 ? r / a / 255 : 0;
1925
- out[1] = a > 0 ? g / a / 255 : 0;
1926
- out[2] = a > 0 ? b / a / 255 : 0;
1927
- out[3] = a / 255;
1928
- return out;
1929
- }
1930
-
1931
- // src/paint-editor/stickers.ts
1932
- function hexRgb(hex) {
1933
- const n = parseInt(hex.slice(1, 7), 16);
1934
- return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
1935
- }
1936
- function stickerRect(s) {
1937
- const r = s.size / 2 + 1;
1938
- const x0 = Math.floor(s.x - r);
1939
- const y0 = Math.floor(s.y - r);
1940
- return { x: x0, y: y0, width: Math.ceil(s.x + r) - x0, height: Math.ceil(s.y + r) - y0 };
2080
+ var FUZZY_AMPLITUDE = 0.12;
2081
+ var NOMINAL_MS_PER_PX = 2;
2082
+ var RIBBON_SUB = 4;
2083
+ function chainDamp(damp, k) {
2084
+ return Math.min(damp, 1 / (1 + 0.8 * Math.sqrt(Math.max(0, k))) ** 2);
1941
2085
  }
1942
- function stickerStamp(s) {
1943
- if (s.ref) {
1944
- try {
1945
- return editorStampOf(s.ref);
1946
- } catch {
1947
- return null;
2086
+ function addPolygonArea(pts, rect, out, weight = 1) {
2087
+ const n = pts.length >> 1;
2088
+ if (n < 3) return;
2089
+ let y0 = Infinity;
2090
+ let y1 = -Infinity;
2091
+ for (let i = 1; i < pts.length; i += 2) {
2092
+ if (pts[i] < y0) y0 = pts[i];
2093
+ if (pts[i] > y1) y1 = pts[i];
2094
+ }
2095
+ const r0 = Math.max(0, Math.floor(y0) - rect.y);
2096
+ const r1 = Math.min(rect.height - 1, Math.floor(y1) - rect.y);
2097
+ const xs = [];
2098
+ const w = weight / RIBBON_SUB;
2099
+ for (let row = r0; row <= r1; row++) {
2100
+ const base = row * rect.width;
2101
+ for (let sub = 0; sub < RIBBON_SUB; sub++) {
2102
+ const y = rect.y + row + (sub + 0.5) / RIBBON_SUB;
2103
+ xs.length = 0;
2104
+ for (let i = 0, j = n - 1; i < n; j = i++) {
2105
+ const yi = pts[i * 2 + 1];
2106
+ const yj = pts[j * 2 + 1];
2107
+ if (yi <= y !== yj <= y) xs.push(pts[i * 2] + (y - yi) / (yj - yi) * (pts[j * 2] - pts[i * 2]));
2108
+ }
2109
+ if (xs.length < 2) continue;
2110
+ xs.sort((a, b) => a - b);
2111
+ for (let k = 0; k + 1 < xs.length; k += 2) {
2112
+ const a = Math.max(rect.x, xs[k]);
2113
+ const b = Math.min(rect.x + rect.width, xs[k + 1]);
2114
+ if (!(b > a)) continue;
2115
+ for (let px = Math.floor(a); px < b; px++) {
2116
+ const cover = Math.min(b, px + 1) - Math.max(a, px);
2117
+ if (cover > 0) out[base + px - rect.x] += cover * w;
2118
+ }
2119
+ }
1948
2120
  }
1949
2121
  }
1950
- return s.stamp && STAMP_KINDS.includes(s.stamp) ? s.stamp : null;
1951
2122
  }
1952
- function drawSticker(medium, s, art) {
1953
- const kind = stickerStamp(s);
1954
- if (!kind) {
1955
- const a2 = art?.(s.ref);
1956
- return a2 ? drawArtSticker(medium, s, a2) : stickerRect(s);
2123
+ function curl(x, y, seed) {
2124
+ const n = (u, v) => {
2125
+ const x0 = Math.floor(u);
2126
+ const y0 = Math.floor(v);
2127
+ const fx = u - x0;
2128
+ const fy = v - y0;
2129
+ const sx = fx * fx * (3 - 2 * fx);
2130
+ const sy = fy * fy * (3 - 2 * fy);
2131
+ const h = (a2, b2) => rand01(seed, a2 * 73856093 + b2 * 19349663, 41);
2132
+ const a = h(x0, y0);
2133
+ const b = h(x0 + 1, y0);
2134
+ const c = h(x0, y0 + 1);
2135
+ const d = h(x0 + 1, y0 + 1);
2136
+ return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
2137
+ };
2138
+ const e = 0.01;
2139
+ return [(n(x, y + e) - n(x, y - e)) / (2 * e), -(n(x + e, y) - n(x - e, y)) / (2 * e)];
2140
+ }
2141
+ var ParticleStroke = class extends MarkStroke {
2142
+ sets;
2143
+ /** Ribbon pairs per copy: particles in their order around the pen (non-flow kinds). */
2144
+ orders;
2145
+ s;
2146
+ spawn = 0;
2147
+ /** Textured dots drawn so far, per copy (sequence order, random picks). */
2148
+ dots;
2149
+ /** Ribbon fill: the area each pixel has had so far this stroke (telescoped into the coverage). */
2150
+ filled = /* @__PURE__ */ new Map();
2151
+ pts = [];
2152
+ constructor(brush, options) {
2153
+ super(brush, options);
2154
+ this.s = brush.particle;
2155
+ this.sets = this.copies.map(() => []);
2156
+ this.orders = this.copies.map(() => []);
2157
+ this.dots = this.copies.map(() => 0);
1957
2158
  }
1958
- const rect = stickerRect(s);
1959
- const n = rect.width * rect.height;
1960
- const cov = new Float32Array(n);
1961
- const field = new Float32Array(n * 3);
1962
- const rad2 = s.size / 2;
1963
- const a = s.rotation * Math.PI / 180;
1964
- const ca = Math.cos(a);
1965
- const sa = Math.sin(a);
1966
- const [r, g, b] = hexRgb(s.color);
1967
- for (let yy = 0, p = 0; yy < rect.height; yy++) {
1968
- for (let xx = 0, px = rect.x; xx < rect.width; xx++, p++, px++) {
1969
- const dx = px + 0.5 - s.x;
1970
- const dy = rect.y + yy + 0.5 - s.y;
1971
- const u = (dx * ca + dy * sa) / rad2;
1972
- const v = (-dx * sa + dy * ca) / rad2;
1973
- if (u < -1.05 || u > 1.05 || v < -1.05 || v > 1.05) continue;
1974
- let c = 0;
1975
- for (let k = 0; k < 4; k++) c += stampCoverage(kind, u + ((k & 1) - 0.5) / rad2 / 2, v + ((k >> 1) - 0.5) / rad2 / 2, s.seed);
1976
- c /= 4;
1977
- if (c <= 0) continue;
1978
- cov[p] = c;
1979
- const shade = 1 + 0.12 * (-(u + v) / 2) + 0.06 * (hash2(px, rect.y + yy, s.seed) - 0.5);
1980
- field[p * 3] = Math.min(1, Math.max(0, r * shade));
1981
- field[p * 3 + 1] = Math.min(1, Math.max(0, g * shade));
1982
- field[p * 3 + 2] = Math.min(1, Math.max(0, b * shade));
1983
- }
2159
+ /** Strand kinds: a chain of masses behind each particle. */
2160
+ get strands() {
2161
+ return this.s.kind === "string" || this.s.kind === "elastic" || this.s.kind === "fuzzy";
1984
2162
  }
1985
- medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
1986
- return rect;
1987
- }
1988
- function drawArtSticker(medium, s, art) {
1989
- const rect = stickerRect(s);
1990
- const n = rect.width * rect.height;
1991
- const cov = new Float32Array(n);
1992
- const field = new Float32Array(n * 3);
1993
- const a = s.rotation * Math.PI / 180;
1994
- const ca = Math.cos(a);
1995
- const sa = Math.sin(a);
1996
- const scale = Math.max(art.width, art.height) / s.size;
1997
- const [r, g, b] = hexRgb(s.color);
1998
- const px = [0, 0, 0, 0];
1999
- for (let yy = 0, p = 0; yy < rect.height; yy++) {
2000
- for (let xx = 0; xx < rect.width; xx++, p++) {
2001
- const dx = rect.x + xx + 0.5 - s.x;
2002
- const dy = rect.y + yy + 0.5 - s.y;
2003
- const ax = (dx * ca + dy * sa) * scale + art.width / 2;
2004
- const ay = (-dx * sa + dy * ca) * scale + art.height / 2;
2005
- if (ax < -1 || ay < -1 || ax > art.width + 1 || ay > art.height + 1) continue;
2006
- sampleArt(art, ax, ay, px);
2007
- if (!(px[3] > 0)) continue;
2008
- cov[p] = px[3];
2009
- field[p * 3] = art.tintable ? r : px[0];
2010
- field[p * 3 + 1] = art.tintable ? g : px[1];
2011
- field[p * 3 + 2] = art.tintable ? b : px[2];
2012
- }
2163
+ newParticle(x, y, ox, oy, k, phase, i, copy) {
2164
+ const s = this.s;
2165
+ const fuzzy = s.kind === "fuzzy";
2166
+ const jitter = fuzzy ? 1 : s.wavePhaseJitter;
2167
+ return {
2168
+ x,
2169
+ y,
2170
+ vx: 0,
2171
+ vy: 0,
2172
+ ox,
2173
+ oy,
2174
+ k,
2175
+ phase,
2176
+ age: 0,
2177
+ run: 0,
2178
+ bead: 0,
2179
+ alive: true,
2180
+ wavePhase: jitter > 0 ? jitter * Math.PI * 2 * rand01(this.seed, i, 43 + copy) : 0,
2181
+ waveScale: fuzzy ? 0.6 + 0.8 * rand01(this.seed, i, 47 + copy) : 1,
2182
+ dx: x,
2183
+ dy: y,
2184
+ px: NaN,
2185
+ py: NaN,
2186
+ fade: 1
2187
+ };
2013
2188
  }
2014
- medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
2015
- return rect;
2016
- }
2017
- function jitterColor(hex, amount, seed, i) {
2018
- const [r, g, b] = hexRgb(hex);
2019
- const lch = oklabToOklch(rgbToOklab({ r, g, b, a: 1 }));
2020
- const l = Math.min(1, Math.max(0, lch.l + (rand01(seed, i, 61) - 0.5) * amount * 0.3));
2021
- const h = lch.h + (rand01(seed, i, 63) - 0.5) * amount * 80;
2022
- const at = (c) => oklabToRgb(oklchToOklab({ l, c, h, alpha: 1 }));
2023
- const inGamut = (o) => [o.r, o.g, o.b].every((v) => v >= -1e-4 && v <= 1 + 1e-4);
2024
- let out = at(lch.c);
2025
- if (!inGamut(out)) {
2026
- let lo = 0;
2027
- let hi = lch.c;
2028
- for (let k = 0; k < 12; k++) {
2029
- const mid = (lo + hi) / 2;
2030
- if (inGamut(at(mid))) lo = mid;
2031
- else hi = mid;
2189
+ init(copy, d) {
2190
+ const s = this.s;
2191
+ const list = this.sets[copy];
2192
+ const size = Math.max(1, d.radius * 2);
2193
+ for (let i = 0; i < (s.kind === "flow" ? 0 : s.count); i++) {
2194
+ const r = rand01(this.seed, i, 3 + copy) * s.spread * size * 0.5;
2195
+ const a = rand01(this.seed, i, 5 + copy) * Math.PI * 2;
2196
+ const ox = Math.cos(a) * r;
2197
+ const oy = Math.sin(a) * r;
2198
+ const p = this.newParticle(d.x + ox, d.y + oy, ox, oy, 0.6 + 0.8 * rand01(this.seed, i, 7), a, i, copy);
2199
+ if (this.strands) p.strand = Array.from({ length: 6 }, () => ({ x: d.x + ox, y: d.y + oy, vx: 0, vy: 0 }));
2200
+ if (s.kind === "orbit") {
2201
+ p.ox = Math.max(1, r + size * 0.15);
2202
+ p.oy = 0;
2203
+ }
2204
+ list.push(p);
2032
2205
  }
2033
- out = at(lo);
2034
- }
2035
- return "#" + [out.r, out.g, out.b].map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")).join("");
2036
- }
2037
- var StickerSpray = class {
2038
- constructor(init) {
2039
- this.init = init;
2040
- const b = init.brush;
2041
- this.smoother = new PenSmoother(b.tip.smoothing, b.pressureSmoothing);
2042
- this.copies = init.copies.length ? init.copies : [IDENTITY_COPY];
2043
- const spacing = Math.max(0.05, 1 / Math.max(0.05, b.sticker.density * 4));
2044
- const walk = { ...b, tip: { ...b.tip, spacing }, dynamics: { ...b.dynamics, scatter: 0, sizeJitter: 0, angleJitter: 0 } };
2045
- this.walkers = this.copies.map((c, i) => new PenDabWalker(walk, init.seed + i * 7919 | 0, c.linear));
2046
- this.medium = new TileMedium(init.target, {});
2047
- this.next = init.nextId;
2206
+ this.orders[copy] = list.map((_, i) => i).sort((i, j) => list[i].phase - list[j].phase || i - j);
2048
2207
  }
2049
- init;
2050
- dabCount = 0;
2051
- stickers = [];
2052
- /** Refs the spray picked but could not draw because their art had not loaded. */
2053
- skipped = /* @__PURE__ */ new Set();
2054
- smoother;
2055
- walkers;
2056
- copies;
2057
- medium;
2058
- lastRaw = null;
2059
- lastFed = null;
2060
- next;
2061
- push(points) {
2062
- const rects = [];
2063
- for (const raw of points) {
2064
- this.lastRaw = raw;
2065
- const p = this.smoother.push(raw);
2066
- this.lastFed = p;
2067
- this.copies.forEach((c, i) => {
2068
- const dabs = [];
2069
- this.walkers[i].push(c.map(p), dabs);
2070
- for (const d of dabs) this.drop(d, rects);
2071
- });
2072
- }
2073
- return rects;
2074
- }
2075
- finish() {
2076
- const rects = [];
2077
- const raw = this.lastRaw;
2078
- const fed = this.lastFed;
2079
- this.copies.forEach((c, i) => {
2080
- const dabs = [];
2081
- if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.walkers[i].push(c.map({ ...raw, pressure: fed.pressure }), dabs);
2082
- this.walkers[i].finish(dabs);
2083
- for (const d of dabs) this.drop(d, rects);
2084
- });
2085
- return rects;
2086
- }
2087
- drop(d, rects) {
2088
- const st = this.init.brush.sticker;
2089
- const i = this.next;
2090
- const seed = this.init.seed;
2091
- const size = d.radius * 2 * (1 - st.sizeJitter * 0.7 * rand01(seed, i, 51));
2092
- const off = st.scatter * d.radius * 2;
2093
- const a = rand01(seed, i, 53) * Math.PI * 2;
2094
- const dist = rand01(seed, i, 55) * off;
2095
- const base = this.colorAt(d, Math.cos(a) * dist, Math.sin(a) * dist);
2096
- if (!(base.alpha > 0)) {
2097
- this.next++;
2098
- return;
2099
- }
2100
- const ref2 = st.stamps[Math.floor(rand01(seed, i, 57) * st.stamps.length)] ?? "editor:dot";
2101
- const stamp = stickerStamp({ ref: ref2 });
2102
- if (!stamp && !this.init.art?.(ref2)) {
2103
- this.skipped.add(ref2);
2104
- this.next++;
2105
- return;
2106
- }
2107
- const sticker = {
2108
- id: `st${this.next++}`,
2109
- ref: ref2,
2110
- ...stamp ? { stamp } : {},
2111
- x: Math.round((d.x + Math.cos(a) * dist) * 100) / 100,
2112
- y: Math.round((d.y + Math.sin(a) * dist) * 100) / 100,
2113
- size: Math.max(2, Math.round(size * 10) / 10),
2114
- rotation: Math.round((-d.angle * 180 / Math.PI + (rand01(seed, i, 59) * 2 - 1) * 180 * st.rotationJitter) * 10) / 10,
2115
- color: st.colorJitter > 0 ? jitterColor(base.color, st.colorJitter, seed, i) : base.color,
2116
- opacity: Math.round(Math.min(1, d.opacity * this.init.brush.tip.opacity * base.alpha) * 1e3) / 1e3,
2117
- seed: Math.floor(rand01(seed, i, 65) * 1e9)
2118
- };
2119
- this.stickers.push(sticker);
2120
- rects.push(drawSticker(this.medium, sticker, this.init.art));
2121
- this.dabCount++;
2208
+ /** The sideways wave of particle `p` at pen dab `d` (px along the normal to the pen's direction). */
2209
+ wave(p, d, size) {
2210
+ const s = this.s;
2211
+ const amp = s.kind === "fuzzy" ? Math.max(FUZZY_AMPLITUDE, s.waveAmplitude) : s.waveAmplitude;
2212
+ if (!(amp > 0)) return 0;
2213
+ const ms = d.time ?? d.travel * NOMINAL_MS_PER_PX;
2214
+ const phase = 2 * Math.PI * d.travel / (s.waveLength * p.waveScale) - s.waveSpeed * 0.025 * ms + p.wavePhase;
2215
+ return amp * size * Math.sin(phase);
2122
2216
  }
2123
- sampler;
2124
- /** A sticker's colour: the paint colour, or gradient paint at its dab (`dx`, `dy` from the dab's centre). */
2125
- colorAt(d, dx, dy) {
2126
- const effect = this.init.effect;
2127
- if (effect) {
2128
- const rgb = [0, 0, 0];
2129
- const alpha = sampleEffectAt(effect, d.x + dx, d.y + dy, rgb, d);
2130
- return { color: "#" + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join(""), alpha };
2217
+ advance(copy, d, rects) {
2218
+ const s = this.s;
2219
+ let list = this.sets[copy];
2220
+ if (d.index === 0 && !list.length) this.init(copy, d);
2221
+ const size = Math.max(1, d.radius * 2);
2222
+ const markR = Math.max(0.5, size * s.size / 2);
2223
+ const k = 0.02 + 0.3 * s.stiffness;
2224
+ const damp = 0.6 + 0.38 * (1 - s.damping);
2225
+ const g = s.gravity * 0.15;
2226
+ const op = d.opacity;
2227
+ const al = Math.max(0.05, d.alpha);
2228
+ const nx = Math.sin(d.direction);
2229
+ const ny = Math.cos(d.direction);
2230
+ if (s.kind === "flow") {
2231
+ this.spawn += s.count / 8;
2232
+ while (this.spawn >= 1) {
2233
+ this.spawn -= 1;
2234
+ const i = this.dabCount + list.length * 31 + Math.floor(d.index);
2235
+ const a = rand01(this.seed, i, 11 + copy) * Math.PI * 2;
2236
+ const r = rand01(this.seed, i, 13 + copy) * s.spread * size * 0.5;
2237
+ list.push(this.newParticle(d.x + Math.cos(a) * r, d.y + Math.sin(a) * r, 0, 0, 1, a, i, copy));
2238
+ }
2131
2239
  }
2132
- const g = this.init.paint;
2133
- if (!g) return { color: this.init.color, alpha: 1 };
2134
- this.sampler ??= new PaintGradientSampler(g, this.init.seed);
2135
- const s = g.map.by === "across-tip" ? this.sampler.sample(((dx * Math.sin(d.direction) + dy * Math.cos(d.direction)) / Math.max(0.5, d.radius) + 1) / 2) : this.sampler.dab(d);
2136
- 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 };
2137
- }
2138
- };
2139
- function renderStickers(stickers, target, art) {
2140
- const medium = new TileMedium(target, {});
2141
- for (const s of stickers) drawSticker(medium, s, art);
2142
- }
2143
- function normalizeStickerUpdate(u) {
2144
- const out = {};
2145
- 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).");
2146
- if (u.stamp !== void 0) {
2147
- 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).`);
2148
- out.ref = `editor:${u.stamp}`;
2149
- out.stamp = u.stamp;
2150
- }
2151
- if (u.ref !== void 0) {
2152
- out.ref = normalizeStickerRef(u.ref);
2153
- const stamp = stickerStamp({ ref: out.ref });
2154
- if (stamp) out.stamp = stamp;
2155
- else out.stamp = void 0;
2156
- }
2157
- for (const k of ["x", "y"]) if (u[k] !== void 0) {
2158
- if (!Number.isFinite(u[k])) throw new Error(`${k} must be a number of pixels.`);
2159
- out[k] = u[k];
2160
- }
2161
- if (u.size !== void 0) {
2162
- if (!(u.size >= 2 && u.size <= 2048)) throw new Error(`size ${u.size} is out of range; use 2..2048 px.`);
2163
- out.size = u.size;
2164
- }
2165
- if (u.rotation !== void 0) {
2166
- if (!Number.isFinite(u.rotation)) throw new Error("rotation must be degrees.");
2167
- out.rotation = u.rotation;
2168
- }
2169
- if (u.color !== void 0) {
2170
- if (!/^#[0-9a-f]{6}$/i.test(u.color)) throw new Error(`color ${JSON.stringify(u.color)} must be #rrggbb.`);
2171
- out.color = u.color.toLowerCase();
2172
- }
2173
- if (u.opacity !== void 0) {
2174
- if (!(u.opacity >= 0 && u.opacity <= 1)) throw new Error(`opacity ${u.opacity} is out of range; use 0..1.`);
2175
- out.opacity = u.opacity;
2176
- }
2177
- return out;
2178
- }
2179
- function stickerSurface(stickers, base, dirty, art) {
2180
- const only = base && dirty ? new Set(dirty.flatMap((r) => tilesInRect(r).map(({ tx, ty }) => tileKey(tx, ty)))) : null;
2181
- const live = /* @__PURE__ */ new Map();
2182
- let scratch = null;
2183
- const target = {
2184
- offset: { x: 0, y: 0 },
2185
- lockTransparency: false,
2186
- selection: null,
2187
- readTile: (tx, ty) => live.get(tileKey(tx, ty)) ?? null,
2188
- writeTile: (tx, ty) => {
2189
- const k = tileKey(tx, ty);
2190
- if (only && !only.has(k)) return (scratch ??= new Uint8ClampedArray(TILE * TILE * 4)).fill(0);
2191
- let t = live.get(k);
2192
- if (!t) live.set(k, t = new Uint8ClampedArray(TILE * TILE * 4));
2193
- return t;
2240
+ const lifeSteps = 8 + 120 * s.life;
2241
+ for (let i = 0; i < list.length; i++) {
2242
+ const p = list[i];
2243
+ if (!p.alive) continue;
2244
+ switch (s.kind) {
2245
+ case "spring": {
2246
+ p.vx = (p.vx + (d.x + p.ox - p.x) * k * p.k) * damp;
2247
+ p.vy = (p.vy + (d.y + p.oy - p.y) * k * p.k + g) * damp;
2248
+ p.x += p.vx;
2249
+ p.y += p.vy;
2250
+ break;
2251
+ }
2252
+ case "string":
2253
+ case "fuzzy": {
2254
+ const strand = p.strand;
2255
+ const dm = s.kind === "fuzzy" ? chainDamp(damp, k * p.k) : damp;
2256
+ let ax = d.x + p.ox;
2257
+ let ay = d.y + p.oy;
2258
+ for (const m of strand) {
2259
+ m.vx = (m.vx + (ax - m.x) * k * p.k + 0) * dm;
2260
+ m.vy = (m.vy + (ay - m.y) * k * p.k + g) * dm;
2261
+ m.x += m.vx;
2262
+ m.y += m.vy;
2263
+ ax = m.x;
2264
+ ay = m.y;
2265
+ }
2266
+ p.x = strand[strand.length - 1].x;
2267
+ p.y = strand[strand.length - 1].y;
2268
+ break;
2269
+ }
2270
+ case "elastic": {
2271
+ const strand = p.strand;
2272
+ const rest = size * 0.12;
2273
+ const ke = Math.min(0.9, (0.1 + 0.6 * s.stiffness) * p.k);
2274
+ const dm = chainDamp(damp, ke);
2275
+ let ax = d.x + p.ox;
2276
+ let ay = d.y + p.oy;
2277
+ for (const m of strand) {
2278
+ const ex = ax - m.x;
2279
+ const ey = ay - m.y;
2280
+ const len = Math.hypot(ex, ey);
2281
+ const pull = len > rest ? (len - rest) / len : 0;
2282
+ m.vx = (m.vx + ex * pull * ke) * dm;
2283
+ m.vy = (m.vy + ey * pull * ke + g) * dm;
2284
+ m.x += m.vx;
2285
+ m.y += m.vy;
2286
+ ax = m.x;
2287
+ ay = m.y;
2288
+ }
2289
+ p.x = strand[strand.length - 1].x;
2290
+ p.y = strand[strand.length - 1].y;
2291
+ break;
2292
+ }
2293
+ case "orbit": {
2294
+ p.phase += (0.05 + 0.3 * s.speed) * (i % 2 ? -1 : 1) * (0.7 + 0.6 * rand01(this.seed, i, 17));
2295
+ p.x = d.x + Math.cos(p.phase) * p.ox;
2296
+ p.y = d.y + Math.sin(p.phase) * p.ox + g * p.ox;
2297
+ break;
2298
+ }
2299
+ case "flow": {
2300
+ const [fx, fy] = curl(p.x / Math.max(8, size * 1.5), p.y / Math.max(8, size * 1.5), this.seed);
2301
+ const push = size * 0.012 * (0.3 + 1.5 * s.speed);
2302
+ const fl = Math.hypot(fx, fy) || 1;
2303
+ p.vx = p.vx * 0.8 + fx / fl * push;
2304
+ p.vy = p.vy * 0.8 + fy / fl * push + g * 0.2;
2305
+ p.x += p.vx;
2306
+ p.y += p.vy;
2307
+ p.age++;
2308
+ if (p.age > lifeSteps) p.alive = false;
2309
+ break;
2310
+ }
2311
+ }
2312
+ const w = this.wave(p, d, size);
2313
+ const qx = w ? p.x + nx * w : p.x;
2314
+ const qy = w ? p.y + ny * w : p.y;
2315
+ const px = p.dx;
2316
+ const py = p.dy;
2317
+ p.px = p.dx;
2318
+ p.py = p.dy;
2319
+ p.dx = qx;
2320
+ p.dy = qy;
2321
+ const fade = s.kind === "flow" ? 1 - p.age / lifeSteps : 1;
2322
+ p.fade = fade;
2323
+ const moved = Math.hypot(qx - px, qy - py);
2324
+ if (d.index === 0 && s.kind !== "flow") continue;
2325
+ if (s.render === "dot") this.dot(copy, qx, qy, qx - px, qy - py, markR * fade, al * fade, op, d, rects);
2326
+ else if (s.render === "line" || s.render === "ribbon") this.segment(px, py, qx, qy, markR * fade, al * fade, op, d.pressure, rects);
2327
+ else {
2328
+ p.run += moved;
2329
+ const gap = Math.max(markR * 2, size * (0.05 + s.interval));
2330
+ while (p.run >= gap) {
2331
+ p.run -= gap;
2332
+ p.bead++;
2333
+ const t = moved > 0 ? 1 - p.run / Math.max(moved, 1e-6) : 1;
2334
+ const bx = px + (qx - px) * Math.min(1, Math.max(0, t));
2335
+ const by = py + (qy - py) * Math.min(1, Math.max(0, t));
2336
+ this.dot(copy, bx, by, qx - px, qy - py, markR * (0.7 + 0.6 * (p.bead % 3 / 2)) * fade, al * fade, op, d, rects);
2337
+ }
2338
+ }
2194
2339
  }
2195
- };
2196
- const reaches = (s) => !only || tilesInRect(stickerRect(s)).some(({ tx, ty }) => only.has(tileKey(tx, ty)));
2197
- renderStickers(stickers.filter(reaches), target, art);
2198
- const tiles = /* @__PURE__ */ new Map();
2199
- if (only && base) {
2200
- for (const [k, t] of base.tiles) if (!only.has(k)) tiles.set(k, t);
2201
- }
2202
- for (const [k, d] of live) {
2203
- const t = finishTile(d, 4, 0);
2204
- if (t) tiles.set(k, t);
2205
- }
2206
- return new TiledSurface(4, tiles, 0);
2207
- }
2208
- function stickerAt(stickers, x, y, art) {
2209
- for (let i = stickers.length - 1; i >= 0; i--) {
2210
- const s = stickers[i];
2211
- const rad2 = s.size / 2;
2212
- const a = s.rotation * Math.PI / 180;
2213
- const dx = x - s.x;
2214
- const dy = y - s.y;
2215
- const u = (dx * Math.cos(a) + dy * Math.sin(a)) / rad2;
2216
- const v = (-dx * Math.sin(a) + dy * Math.cos(a)) / rad2;
2217
- if (Math.abs(u) > 1.05 || Math.abs(v) > 1.05) continue;
2218
- if (s.size < 16) return s;
2219
- const kind = stickerStamp(s);
2220
- if (kind) {
2221
- if (stampCoverage(kind, u, v, s.seed) > 0.25) return s;
2222
- continue;
2340
+ if (s.render === "ribbon" && !(d.index === 0 && s.kind !== "flow")) this.ribbon(copy, list, op, al, d, rects);
2341
+ if (s.kind === "flow" && list.length > 512) {
2342
+ list = list.filter((p) => p.alive);
2343
+ this.sets[copy] = list;
2223
2344
  }
2224
- const pic = art?.(s.ref);
2225
- if (!pic) return s;
2226
- const scale = Math.max(pic.width, pic.height) / 2;
2227
- if (sampleArt(pic, u * scale + pic.width / 2, v * scale + pic.height / 2, [0, 0, 0, 0])[3] > 0.25) return s;
2228
2345
  }
2229
- return null;
2230
- }
2231
-
2232
- // src/paint-editor/procedural.ts
2233
- var STEP_PX = 1.5;
2234
- var MarkStroke = class {
2235
- constructor(brush, options) {
2236
- this.brush = brush;
2237
- this.options = options;
2238
- this.sampler = options.paint ? new PaintGradientSampler(options.paint, options.seed ?? 0) : null;
2239
- this.tipTable = this.sampler?.acrossTip ? this.sampler.tipTable(TIP_GRADIENT_STEPS) : null;
2240
- this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
2241
- this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
2242
- this.seed = options.seed ?? 0;
2243
- const fine = { ...brush, tip: { ...brush.tip, spacing: Math.max(0.01, Math.min(0.25, STEP_PX / Math.max(1, brush.tip.size))) } };
2244
- this.walkers = this.copies.map((c, i) => new PenDabWalker(fine, (options.seed ?? 0) + i * 7919 | 0, c.linear));
2245
- this.depth = options.grain && !options.grain.flat ? Math.min(1, Math.max(0, options.paperDepth * brush.grain)) : 0;
2346
+ /** A dot (or bead): round, or a textured stamp turned by the particle's motion (mx, my), at random or to the tip angle. */
2347
+ dot(copy, x, y, mx, my, r, alpha, opacity, d, rects) {
2348
+ const s = this.s;
2349
+ const textures = s.dotTextures;
2350
+ if (!textures.length) return this.mark(x, y, r, alpha, opacity, d.pressure, rects);
2351
+ const n = this.dots[copy]++;
2352
+ const pick = s.dotTextureOrder === "random" ? Math.min(textures.length - 1, Math.floor(rand01(this.seed, n, 31 + copy) * textures.length)) : n % textures.length;
2353
+ 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;
2354
+ this.stamp(x, y, r, textures[pick], rotation, this.seed + n * 2654435761 >>> 0, alpha, opacity, d.pressure, rects);
2246
2355
  }
2247
- brush;
2248
- options;
2249
- coverage = new CoverageMap();
2250
- dabCount = 0;
2251
- smoother;
2252
- copies;
2253
- walkers;
2254
- depth;
2255
- buf = new Float32Array(0);
2256
- field = new Float32Array(0);
2257
- lastRaw = null;
2258
- lastFed = null;
2259
- seed;
2260
- sampler;
2261
- tipTable;
2262
- /** The pen dab the marks being made belong to, and its gradient colour. */
2263
- pen = null;
2264
- penColor = { rgb: [0, 0, 0], alpha: 1 };
2265
- push(points) {
2266
- const rects = [];
2267
- for (const raw of points) {
2268
- this.lastRaw = raw;
2269
- const p = this.smoother.push(raw);
2270
- this.lastFed = p;
2271
- this.copies.forEach((c, i) => {
2272
- const dabs = [];
2273
- this.walkers[i].push(c.map(p), dabs);
2274
- for (const d of dabs) this.step(i, d, rects);
2275
- });
2356
+ /**
2357
+ * Ribbon fill for one step: the band between each pair of joined particles, from where they
2358
+ * were drawn last step to where they are drawn now. Bands of one step, and of consecutive steps,
2359
+ * share edges exactly, so the fill has no seams; it is telescoped into the coverage
2360
+ * (`telescope`), so it does not build up at the joins.
2361
+ */
2362
+ ribbon(copy, list, opacity, alpha, d, rects) {
2363
+ const s = this.s;
2364
+ if (!(s.ribbonFill > 0)) return;
2365
+ const live = (p) => !!p && p.alive && !Number.isNaN(p.px);
2366
+ let order;
2367
+ if (s.kind === "flow") order = list.filter(live);
2368
+ else order = this.orders[copy].map((i) => list[i]).filter(live);
2369
+ if (order.length < 2) return;
2370
+ const pairs = [];
2371
+ if (s.ribbonPairs === "first-last") pairs.push([order[0], order[order.length - 1]]);
2372
+ else for (let i = 0; i + 1 < order.length; i++) pairs.push([order[i], order[i + 1]]);
2373
+ const pts = this.pts;
2374
+ const ceil = Math.min(1, opacity * s.ribbonFill);
2375
+ for (const [a, b] of pairs) {
2376
+ pts.length = 0;
2377
+ pts.push(a.px, a.py, a.dx, a.dy, b.dx, b.dy, b.px, b.py);
2378
+ let x0 = Infinity;
2379
+ let y0 = Infinity;
2380
+ let x1 = -Infinity;
2381
+ let y1 = -Infinity;
2382
+ for (let i = 0; i < 8; i += 2) {
2383
+ x0 = Math.min(x0, pts[i]);
2384
+ x1 = Math.max(x1, pts[i]);
2385
+ y0 = Math.min(y0, pts[i + 1]);
2386
+ y1 = Math.max(y1, pts[i + 1]);
2387
+ }
2388
+ 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 };
2389
+ const area = this.scratch(rect.width * rect.height);
2390
+ addPolygonArea(pts, rect, area, Math.min(a.fade, b.fade) * alpha);
2391
+ if (this.telescope(rect, area)) this.lay(rect, area, ceil, d.pressure, (a.dx + b.dx) / 2, (a.dy + b.dy) / 2, rects);
2276
2392
  }
2277
- return rects;
2278
- }
2279
- step(copy, d, rects) {
2280
- this.pen = d;
2281
- if (this.sampler && !this.tipTable) this.sampler.dab(d, this.penColor);
2282
- this.advance(copy, d, rects);
2283
- }
2284
- /** The colour of a mark at (x, y): its pen dab's, or across the tip by its offset from the pen. */
2285
- markColor(x, y) {
2286
- if (!this.sampler || !this.pen) return null;
2287
- if (!this.tipTable) return this.penColor;
2288
- const d = this.pen;
2289
- const u = ((x - d.x) * Math.sin(d.direction) + (y - d.y) * Math.cos(d.direction)) / Math.max(0.5, d.radius);
2290
- const last = this.tipTable.length - 1;
2291
- return this.tipTable[Math.min(last, Math.max(0, Math.round((u + 1) / 2 * last)))];
2292
- }
2293
- finish() {
2294
- const rects = [];
2295
- const raw = this.lastRaw;
2296
- const fed = this.lastFed;
2297
- this.copies.forEach((c, i) => {
2298
- const dabs = [];
2299
- if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.walkers[i].push(c.map({ ...raw, pressure: fed.pressure }), dabs);
2300
- this.walkers[i].finish(dabs);
2301
- for (const d of dabs) this.step(i, d, rects);
2302
- });
2303
- return rects;
2304
- }
2305
- /** Stamps one round mark of radius `r` at (x, y) with flow `alpha` towards `opacity`. */
2306
- mark(x, y, r, alpha, opacity, pressure, rects) {
2307
- if (!(r > 0.05) || !(alpha > 0)) return;
2308
- const dab = { x, y, radius: Math.max(0.5, r), alpha: Math.min(1, alpha), opacity: Math.min(1, opacity), angle: 0, roundness: 1 };
2309
- const hardness = this.brush.tip.hardness;
2310
- const rect = dabRect(dab, hardness);
2311
- const n = rect.width * rect.height;
2312
- if (n <= 0) return;
2313
- const vals = this.scratch(n);
2314
- rasterizeDab(dab, hardness, rect, vals);
2315
- this.lay(rect, vals, dab.opacity, pressure, x, y, rects);
2316
2393
  }
2317
2394
  /**
2318
- * Stamps a textured mark: `kind` from the sticker stamps over a square of half-size `r`, turned
2319
- * `rotation` radians clockwise, with flow `alpha` towards `opacity`. Small marks are supersampled.
2395
+ * Turns the area newly filled over `rect` into coverage to lay towards the fill's ceiling: the
2396
+ * share of what was left unfilled, so after any number of bands (in any grouping) a pixel's
2397
+ * coverage is the ceiling times the area filled there. False when nothing was added.
2320
2398
  */
2321
- stamp(x, y, r, kind, rotation, seed, alpha, opacity, pressure, rects) {
2322
- if (!(r > 0.05) || !(alpha > 0)) return;
2323
- const reach = Math.max(0.5, r) * Math.SQRT2 + 1;
2324
- const rect = { x: Math.floor(x - reach), y: Math.floor(y - reach), width: 0, height: 0 };
2325
- rect.width = Math.ceil(x + reach) - rect.x;
2326
- rect.height = Math.ceil(y + reach) - rect.y;
2327
- const n = rect.width * rect.height;
2328
- const vals = this.scratch(n);
2329
- const ss = r < 10 ? 3 : 1;
2330
- const c = Math.cos(rotation);
2331
- const s = Math.sin(rotation);
2332
- const inv = 1 / Math.max(0.5, r);
2333
- const a = Math.min(1, alpha);
2399
+ telescope(rect, area) {
2400
+ let any = false;
2334
2401
  for (let yy = 0, k = 0; yy < rect.height; yy++) {
2402
+ const gy = rect.y + yy;
2403
+ const ty = Math.floor(gy / TILE);
2404
+ let t;
2405
+ let tx = NaN;
2335
2406
  for (let xx = 0; xx < rect.width; xx++, k++) {
2336
- let sum = 0;
2337
- for (let sy = 0; sy < ss; sy++) {
2338
- for (let sx = 0; sx < ss; sx++) {
2339
- const dx = rect.x + xx + (sx + 0.5) / ss - x;
2340
- const dy = rect.y + yy + (sy + 0.5) / ss - y;
2341
- sum += stampCoverage(kind, (dx * c + dy * s) * inv, (dy * c - dx * s) * inv, seed);
2407
+ const add = area[k];
2408
+ if (!(add > 0)) continue;
2409
+ const gx = rect.x + xx;
2410
+ const ntx = Math.floor(gx / TILE);
2411
+ if (ntx !== tx || !t) {
2412
+ tx = ntx;
2413
+ const key = tileKey(tx, ty);
2414
+ t = this.filled.get(key);
2415
+ if (!t) {
2416
+ t = new Float32Array(TILE * TILE);
2417
+ this.filled.set(key, t);
2342
2418
  }
2343
2419
  }
2344
- vals[k] = sum / (ss * ss) * a;
2420
+ const ti = (gy - ty * TILE) * TILE + (gx - tx * TILE);
2421
+ const before = t[ti];
2422
+ const after = Math.min(1, before + add);
2423
+ t[ti] = after;
2424
+ area[k] = before < 1 ? (after - before) / (1 - before) : 0;
2425
+ any = true;
2345
2426
  }
2346
2427
  }
2347
- this.lay(rect, vals, Math.min(1, opacity), pressure, x, y, rects);
2428
+ return any;
2348
2429
  }
2349
- /** A scratch buffer of `n` values (reused; valid until the next mark). */
2350
- scratch(n) {
2351
- if (this.buf.length < n) this.buf = new Float32Array(n);
2352
- const vals = this.buf.subarray(0, n);
2353
- vals.fill(0);
2354
- return vals;
2430
+ };
2431
+ var BristleStroke = class extends MarkStroke {
2432
+ tufts;
2433
+ s;
2434
+ constructor(brush, options) {
2435
+ super(brush, options);
2436
+ this.s = brush.bristle;
2437
+ const s = this.s;
2438
+ this.tufts = this.copies.map(
2439
+ (_, c) => Array.from({ length: s.count }, (_2, i) => {
2440
+ const a = rand01(this.seed, i, 21 + c) * Math.PI * 2;
2441
+ const r = Math.sqrt(rand01(this.seed, i, 23 + c));
2442
+ return {
2443
+ u: Math.cos(a) * r,
2444
+ v: Math.sin(a) * r,
2445
+ x: 0,
2446
+ y: 0,
2447
+ paint: 1 - s.jitter * 0.4 * rand01(this.seed, i, 25 + c),
2448
+ thirst: 1 + s.jitter * (rand01(this.seed, i, 27 + c) * 2 - 1),
2449
+ w: 1 + s.jitter * 0.6 * (rand01(this.seed, i, 29 + c) - 0.5),
2450
+ started: false
2451
+ };
2452
+ })
2453
+ );
2355
2454
  }
2356
- /**
2357
- * Lays coverage `vals` over `rect` towards `opacity`: paper grain, then effect paint or the
2358
- * colour of a mark at (x, y) (gradient paint), into the stroke's coverage.
2359
- */
2360
- lay(rect, vals, opacity, pressure, x, y, rects) {
2361
- const n = rect.width * rect.height;
2362
- if (n <= 0) return;
2363
- const grain = this.options.grain;
2364
- if (this.depth > 0 && grain) {
2365
- for (let yy = 0, k = 0; yy < rect.height; yy++)
2366
- for (let xx = 0; xx < rect.width; xx++, k++) if (vals[k] > 0) vals[k] *= grainCatch(grain.at(rect.x + xx + 0.5, rect.y + yy + 0.5), this.depth, pressure);
2367
- }
2368
- if (this.options.effect) {
2369
- if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
2370
- const field = this.field.subarray(0, n * 3);
2371
- sampleEffectField(this.options.effect, rect, vals, field, this.pen);
2372
- this.coverage.accumulate(rect, vals, opacity, null, field);
2373
- } else {
2374
- const color = this.markColor(x, y);
2375
- if (color && color.alpha < 1) for (let k = 0; k < n; k++) vals[k] *= color.alpha;
2376
- this.coverage.accumulate(rect, vals, opacity, color ? color.rgb : null);
2455
+ advance(copy, d, rects) {
2456
+ const s = this.s;
2457
+ const tuft = this.tufts[copy];
2458
+ const spread = d.radius * (0.55 + 0.9 * s.splay * d.pressure);
2459
+ const ca = Math.cos(d.angle);
2460
+ const sa = Math.sin(d.angle);
2461
+ const ro = Math.max(0.15, d.roundness);
2462
+ const tx = Math.cos(d.direction);
2463
+ const ty = -Math.sin(d.direction);
2464
+ const follow = 0.15 + 0.85 * s.stiffness;
2465
+ const bend = d.radius * (1 - s.stiffness) * d.pressure * 0.8;
2466
+ const lineR = Math.max(0.5, d.radius * 2 * s.thickness * 0.5);
2467
+ const dryRate = 4e-3 * (1 - s.load);
2468
+ for (const b of tuft) {
2469
+ const lu = b.u * spread;
2470
+ const lv = b.v * spread * ro;
2471
+ const ox = lu * ca - lv * sa;
2472
+ const oy = -(lu * sa + lv * ca);
2473
+ const lag = bend * (1 - 0.5 * b.v);
2474
+ const wx = d.x + ox - tx * lag;
2475
+ const wy = d.y + oy - ty * lag;
2476
+ if (!b.started) {
2477
+ b.x = wx;
2478
+ b.y = wy;
2479
+ b.started = true;
2480
+ continue;
2481
+ }
2482
+ const nx = b.x + (wx - b.x) * follow;
2483
+ const ny = b.y + (wy - b.y) * follow;
2484
+ const paint = Math.max(0, b.paint);
2485
+ if (paint > 0.02) this.segment(b.x, b.y, nx, ny, lineR * b.w * (0.6 + 0.4 * d.pressure), d.alpha * Math.min(1, paint * 1.4), d.opacity, d.pressure, rects);
2486
+ b.paint -= dryRate * b.thirst * Math.hypot(nx - b.x, ny - b.y);
2487
+ b.x = nx;
2488
+ b.y = ny;
2377
2489
  }
2378
- rects.push(rect);
2379
- this.dabCount++;
2380
- }
2381
- /** Marks along a segment, spaced by a fraction of the radius. */
2382
- segment(x0, y0, x1, y1, r, alpha, opacity, pressure, rects) {
2383
- const len = Math.hypot(x1 - x0, y1 - y0);
2384
- const step = Math.max(0.35, r * 0.5);
2385
- const n = Math.max(1, Math.ceil(len / step));
2386
- for (let i = 1; i <= n; i++) this.mark(x0 + (x1 - x0) * i / n, y0 + (y1 - y0) * i / n, r, alpha, opacity, pressure, rects);
2387
2490
  }
2388
2491
  };
2389
- var FUZZY_AMPLITUDE = 0.12;
2390
- var NOMINAL_MS_PER_PX = 2;
2391
- var RIBBON_SUB = 4;
2392
- function chainDamp(damp, k) {
2393
- return Math.min(damp, 1 / (1 + 0.8 * Math.sqrt(Math.max(0, k))) ** 2);
2394
- }
2395
- function addPolygonArea(pts, rect, out, weight = 1) {
2396
- const n = pts.length >> 1;
2397
- if (n < 3) return;
2398
- let y0 = Infinity;
2399
- let y1 = -Infinity;
2400
- for (let i = 1; i < pts.length; i += 2) {
2401
- if (pts[i] < y0) y0 = pts[i];
2402
- if (pts[i] > y1) y1 = pts[i];
2403
- }
2404
- const r0 = Math.max(0, Math.floor(y0) - rect.y);
2405
- const r1 = Math.min(rect.height - 1, Math.floor(y1) - rect.y);
2406
- const xs = [];
2407
- const w = weight / RIBBON_SUB;
2408
- for (let row = r0; row <= r1; row++) {
2409
- const base = row * rect.width;
2410
- for (let sub = 0; sub < RIBBON_SUB; sub++) {
2411
- const y = rect.y + row + (sub + 0.5) / RIBBON_SUB;
2412
- xs.length = 0;
2413
- for (let i = 0, j = n - 1; i < n; j = i++) {
2414
- const yi = pts[i * 2 + 1];
2415
- const yj = pts[j * 2 + 1];
2416
- if (yi <= y !== yj <= y) xs.push(pts[i * 2] + (y - yi) / (yj - yi) * (pts[j * 2] - pts[i * 2]));
2417
- }
2418
- if (xs.length < 2) continue;
2419
- xs.sort((a, b) => a - b);
2420
- for (let k = 0; k + 1 < xs.length; k += 2) {
2421
- const a = Math.max(rect.x, xs[k]);
2422
- const b = Math.min(rect.x + rect.width, xs[k + 1]);
2423
- if (!(b > a)) continue;
2424
- for (let px = Math.floor(a); px < b; px++) {
2425
- const cover = Math.min(b, px + 1) - Math.max(a, px);
2426
- if (cover > 0) out[base + px - rect.x] += cover * w;
2427
- }
2428
- }
2492
+
2493
+ // src/paint-editor/sticker-art.ts
2494
+ var STICKER_ART_SIZE = 256;
2495
+ var defaultStickerRasterizer = async (asset, size) => {
2496
+ if (asset.kind === "image") {
2497
+ const parsed = parseDataUrl(asset.url);
2498
+ if (parsed && parsed.mime === "image/png") {
2499
+ const png = decodePng(parsed.bytes);
2500
+ return { width: png.width, height: png.height, data: new Uint8ClampedArray(png.data) };
2429
2501
  }
2430
2502
  }
2431
- }
2432
- function curl(x, y, seed) {
2433
- const n = (u, v) => {
2434
- const x0 = Math.floor(u);
2435
- const y0 = Math.floor(v);
2436
- const fx = u - x0;
2437
- const fy = v - y0;
2438
- const sx = fx * fx * (3 - 2 * fx);
2439
- const sy = fy * fy * (3 - 2 * fy);
2440
- const h = (a2, b2) => rand01(seed, a2 * 73856093 + b2 * 19349663, 41);
2441
- const a = h(x0, y0);
2442
- const b = h(x0 + 1, y0);
2443
- const c = h(x0, y0 + 1);
2444
- const d = h(x0 + 1, y0 + 1);
2445
- return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
2503
+ const g = globalThis;
2504
+ if (!g.createImageBitmap || !g.OffscreenCanvas) {
2505
+ 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).`);
2506
+ }
2507
+ const blob = asset.kind === "svg" ? new Blob([asset.svg], { type: "image/svg+xml" }) : await (await g.fetch(asset.url)).blob();
2508
+ const bitmap = await g.createImageBitmap(blob);
2509
+ const scale = size / Math.max(bitmap.width, bitmap.height, 1);
2510
+ const w = Math.max(1, Math.round(bitmap.width * scale));
2511
+ const h = Math.max(1, Math.round(bitmap.height * scale));
2512
+ const canvas = new g.OffscreenCanvas(w, h);
2513
+ const ctx = canvas.getContext("2d");
2514
+ ctx.drawImage(bitmap, 0, 0, w, h);
2515
+ const img = ctx.getImageData(0, 0, w, h);
2516
+ return { width: w, height: h, data: img.data };
2517
+ };
2518
+ var StickerArtCache = class {
2519
+ entries = /* @__PURE__ */ new Map();
2520
+ /** The art for `ref` when it has loaded (editor stamps never need any). */
2521
+ get = (ref2) => {
2522
+ const e = this.entries.get(ref2);
2523
+ return e?.status === "ready" ? e.art : void 0;
2446
2524
  };
2447
- const e = 0.01;
2448
- return [(n(x, y + e) - n(x, y - e)) / (2 * e), -(n(x + e, y) - n(x - e, y)) / (2 * e)];
2449
- }
2450
- var ParticleStroke = class extends MarkStroke {
2451
- sets;
2452
- /** Ribbon pairs per copy: particles in their order around the pen (non-flow kinds). */
2453
- orders;
2454
- s;
2455
- spawn = 0;
2456
- /** Textured dots drawn so far, per copy (sequence order, random picks). */
2457
- dots;
2458
- /** Ribbon fill: the area each pixel has had so far this stroke (telescoped into the coverage). */
2459
- filled = /* @__PURE__ */ new Map();
2460
- pts = [];
2461
- constructor(brush, options) {
2462
- super(brush, options);
2463
- this.s = brush.particle;
2464
- this.sets = this.copies.map(() => []);
2465
- this.orders = this.copies.map(() => []);
2466
- this.dots = this.copies.map(() => 0);
2525
+ /** `ready`, `loading`, `failed` (with the reason) or `none` (never asked for). Editor stamps are always `ready`. */
2526
+ status(ref2) {
2527
+ if (editorStampOf(ref2)) return { status: "ready" };
2528
+ const e = this.entries.get(ref2);
2529
+ if (!e) return { status: "none" };
2530
+ return e.status === "failed" ? { status: "failed", error: e.error } : { status: e.status };
2467
2531
  }
2468
- /** Strand kinds: a chain of masses behind each particle. */
2469
- get strands() {
2470
- return this.s.kind === "string" || this.s.kind === "elastic" || this.s.kind === "fuzzy";
2532
+ /**
2533
+ * Loads and rasterises `ref` from `provider` once (later calls share the load). Unknown refs
2534
+ * reject with the closest known ones; failures are kept so they are reported, and retried on
2535
+ * the next call.
2536
+ */
2537
+ ensure(provider, ref2, rasterize) {
2538
+ if (editorStampOf(ref2)) return Promise.resolve(null);
2539
+ const e = this.entries.get(ref2);
2540
+ if (e?.status === "ready") return Promise.resolve(e.art);
2541
+ if (e?.status === "loading") return e.promise;
2542
+ const promise = (async () => {
2543
+ const d = await describeStickerRef(provider, ref2);
2544
+ const id = stickerIdFor(provider, ref2);
2545
+ const max = provider.capabilities.maxSize ?? STICKER_ART_SIZE;
2546
+ const size = Math.min(STICKER_ART_SIZE, max);
2547
+ const asset = await provider.load(id, d.formats.includes("png") ? { format: "png", size } : { format: d.formats[0] ?? "svg", size });
2548
+ const px = await rasterize(asset, size);
2549
+ if (!(px.width > 0 && px.height > 0) || px.data.length !== px.width * px.height * 4) throw new StickerError("load_failed", `The art for "${ref2}" came back empty or the wrong size.`);
2550
+ return { ...px, tintable: d.tintable };
2551
+ })();
2552
+ this.entries.set(ref2, { status: "loading", promise });
2553
+ return promise.then(
2554
+ (art) => {
2555
+ this.entries.set(ref2, { status: "ready", art });
2556
+ return art;
2557
+ },
2558
+ (err) => {
2559
+ const message = err instanceof Error ? err.message : String(err);
2560
+ this.entries.set(ref2, { status: "failed", error: message });
2561
+ throw err;
2562
+ }
2563
+ );
2471
2564
  }
2472
- newParticle(x, y, ox, oy, k, phase, i, copy) {
2473
- const s = this.s;
2474
- const fuzzy = s.kind === "fuzzy";
2475
- const jitter = fuzzy ? 1 : s.wavePhaseJitter;
2476
- return {
2477
- x,
2478
- y,
2479
- vx: 0,
2480
- vy: 0,
2481
- ox,
2482
- oy,
2483
- k,
2484
- phase,
2485
- age: 0,
2486
- run: 0,
2487
- bead: 0,
2488
- alive: true,
2489
- wavePhase: jitter > 0 ? jitter * Math.PI * 2 * rand01(this.seed, i, 43 + copy) : 0,
2490
- waveScale: fuzzy ? 0.6 + 0.8 * rand01(this.seed, i, 47 + copy) : 1,
2491
- dx: x,
2492
- dy: y,
2493
- px: NaN,
2494
- py: NaN,
2495
- fade: 1
2496
- };
2565
+ /** Forgets every load (a new provider). */
2566
+ clear() {
2567
+ this.entries.clear();
2497
2568
  }
2498
- init(copy, d) {
2499
- const s = this.s;
2500
- const list = this.sets[copy];
2501
- const size = Math.max(1, d.radius * 2);
2502
- for (let i = 0; i < (s.kind === "flow" ? 0 : s.count); i++) {
2503
- const r = rand01(this.seed, i, 3 + copy) * s.spread * size * 0.5;
2504
- const a = rand01(this.seed, i, 5 + copy) * Math.PI * 2;
2505
- const ox = Math.cos(a) * r;
2506
- const oy = Math.sin(a) * r;
2507
- const p = this.newParticle(d.x + ox, d.y + oy, ox, oy, 0.6 + 0.8 * rand01(this.seed, i, 7), a, i, copy);
2508
- if (this.strands) p.strand = Array.from({ length: 6 }, () => ({ x: d.x + ox, y: d.y + oy, vx: 0, vy: 0 }));
2509
- if (s.kind === "orbit") {
2510
- p.ox = Math.max(1, r + size * 0.15);
2511
- p.oy = 0;
2512
- }
2513
- list.push(p);
2569
+ };
2570
+ function sampleArt(art, x, y, out) {
2571
+ const { width: w, height: h, data } = art;
2572
+ const fx = x - 0.5;
2573
+ const fy = y - 0.5;
2574
+ const x0 = Math.floor(fx);
2575
+ const y0 = Math.floor(fy);
2576
+ const tx = fx - x0;
2577
+ const ty = fy - y0;
2578
+ let r = 0;
2579
+ let g = 0;
2580
+ let b = 0;
2581
+ let a = 0;
2582
+ for (let k = 0; k < 4; k++) {
2583
+ const xx = x0 + (k & 1);
2584
+ const yy = y0 + (k >> 1);
2585
+ if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
2586
+ const wgt = (k & 1 ? tx : 1 - tx) * (k >> 1 ? ty : 1 - ty);
2587
+ const i = (yy * w + xx) * 4;
2588
+ const al = data[i + 3] * wgt;
2589
+ r += data[i] * al;
2590
+ g += data[i + 1] * al;
2591
+ b += data[i + 2] * al;
2592
+ a += al;
2593
+ }
2594
+ out[0] = a > 0 ? r / a / 255 : 0;
2595
+ out[1] = a > 0 ? g / a / 255 : 0;
2596
+ out[2] = a > 0 ? b / a / 255 : 0;
2597
+ out[3] = a / 255;
2598
+ return out;
2599
+ }
2600
+
2601
+ // src/paint-editor/stickers.ts
2602
+ function hexRgb(hex2) {
2603
+ const n = parseInt(hex2.slice(1, 7), 16);
2604
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
2605
+ }
2606
+ function stickerRect(s) {
2607
+ const r = s.size / 2 + 1;
2608
+ const x0 = Math.floor(s.x - r);
2609
+ const y0 = Math.floor(s.y - r);
2610
+ return { x: x0, y: y0, width: Math.ceil(s.x + r) - x0, height: Math.ceil(s.y + r) - y0 };
2611
+ }
2612
+ function stickerStamp(s) {
2613
+ if (s.ref) {
2614
+ try {
2615
+ return editorStampOf(s.ref);
2616
+ } catch {
2617
+ return null;
2514
2618
  }
2515
- this.orders[copy] = list.map((_, i) => i).sort((i, j) => list[i].phase - list[j].phase || i - j);
2516
2619
  }
2517
- /** The sideways wave of particle `p` at pen dab `d` (px along the normal to the pen's direction). */
2518
- wave(p, d, size) {
2519
- const s = this.s;
2520
- const amp = s.kind === "fuzzy" ? Math.max(FUZZY_AMPLITUDE, s.waveAmplitude) : s.waveAmplitude;
2521
- if (!(amp > 0)) return 0;
2522
- const ms = d.time ?? d.travel * NOMINAL_MS_PER_PX;
2523
- const phase = 2 * Math.PI * d.travel / (s.waveLength * p.waveScale) - s.waveSpeed * 0.025 * ms + p.wavePhase;
2524
- return amp * size * Math.sin(phase);
2620
+ return s.stamp && STAMP_KINDS.includes(s.stamp) ? s.stamp : null;
2621
+ }
2622
+ function drawSticker(medium, s, art) {
2623
+ const kind = stickerStamp(s);
2624
+ if (!kind) {
2625
+ const a2 = art?.(s.ref);
2626
+ return a2 ? drawArtSticker(medium, s, a2) : stickerRect(s);
2525
2627
  }
2526
- advance(copy, d, rects) {
2527
- const s = this.s;
2528
- let list = this.sets[copy];
2529
- if (d.index === 0 && !list.length) this.init(copy, d);
2530
- const size = Math.max(1, d.radius * 2);
2531
- const markR = Math.max(0.5, size * s.size / 2);
2532
- const k = 0.02 + 0.3 * s.stiffness;
2533
- const damp = 0.6 + 0.38 * (1 - s.damping);
2534
- const g = s.gravity * 0.15;
2535
- const op = d.opacity;
2536
- const al = Math.max(0.05, d.alpha);
2537
- const nx = Math.sin(d.direction);
2538
- const ny = Math.cos(d.direction);
2539
- if (s.kind === "flow") {
2540
- this.spawn += s.count / 8;
2541
- while (this.spawn >= 1) {
2542
- this.spawn -= 1;
2543
- const i = this.dabCount + list.length * 31 + Math.floor(d.index);
2544
- const a = rand01(this.seed, i, 11 + copy) * Math.PI * 2;
2545
- const r = rand01(this.seed, i, 13 + copy) * s.spread * size * 0.5;
2546
- list.push(this.newParticle(d.x + Math.cos(a) * r, d.y + Math.sin(a) * r, 0, 0, 1, a, i, copy));
2547
- }
2628
+ const rect = stickerRect(s);
2629
+ const n = rect.width * rect.height;
2630
+ const cov = new Float32Array(n);
2631
+ const field = new Float32Array(n * 3);
2632
+ const rad2 = s.size / 2;
2633
+ const a = s.rotation * Math.PI / 180;
2634
+ const ca = Math.cos(a);
2635
+ const sa = Math.sin(a);
2636
+ const [r, g, b] = hexRgb(s.color);
2637
+ for (let yy = 0, p = 0; yy < rect.height; yy++) {
2638
+ for (let xx = 0, px = rect.x; xx < rect.width; xx++, p++, px++) {
2639
+ const dx = px + 0.5 - s.x;
2640
+ const dy = rect.y + yy + 0.5 - s.y;
2641
+ const u = (dx * ca + dy * sa) / rad2;
2642
+ const v = (-dx * sa + dy * ca) / rad2;
2643
+ if (u < -1.05 || u > 1.05 || v < -1.05 || v > 1.05) continue;
2644
+ let c = 0;
2645
+ for (let k = 0; k < 4; k++) c += stampCoverage(kind, u + ((k & 1) - 0.5) / rad2 / 2, v + ((k >> 1) - 0.5) / rad2 / 2, s.seed);
2646
+ c /= 4;
2647
+ if (c <= 0) continue;
2648
+ cov[p] = c;
2649
+ const shade = 1 + 0.12 * (-(u + v) / 2) + 0.06 * (hash2(px, rect.y + yy, s.seed) - 0.5);
2650
+ field[p * 3] = Math.min(1, Math.max(0, r * shade));
2651
+ field[p * 3 + 1] = Math.min(1, Math.max(0, g * shade));
2652
+ field[p * 3 + 2] = Math.min(1, Math.max(0, b * shade));
2548
2653
  }
2549
- const lifeSteps = 8 + 120 * s.life;
2550
- for (let i = 0; i < list.length; i++) {
2551
- const p = list[i];
2552
- if (!p.alive) continue;
2553
- switch (s.kind) {
2554
- case "spring": {
2555
- p.vx = (p.vx + (d.x + p.ox - p.x) * k * p.k) * damp;
2556
- p.vy = (p.vy + (d.y + p.oy - p.y) * k * p.k + g) * damp;
2557
- p.x += p.vx;
2558
- p.y += p.vy;
2559
- break;
2560
- }
2561
- case "string":
2562
- case "fuzzy": {
2563
- const strand = p.strand;
2564
- const dm = s.kind === "fuzzy" ? chainDamp(damp, k * p.k) : damp;
2565
- let ax = d.x + p.ox;
2566
- let ay = d.y + p.oy;
2567
- for (const m of strand) {
2568
- m.vx = (m.vx + (ax - m.x) * k * p.k + 0) * dm;
2569
- m.vy = (m.vy + (ay - m.y) * k * p.k + g) * dm;
2570
- m.x += m.vx;
2571
- m.y += m.vy;
2572
- ax = m.x;
2573
- ay = m.y;
2574
- }
2575
- p.x = strand[strand.length - 1].x;
2576
- p.y = strand[strand.length - 1].y;
2577
- break;
2578
- }
2579
- case "elastic": {
2580
- const strand = p.strand;
2581
- const rest = size * 0.12;
2582
- const ke = Math.min(0.9, (0.1 + 0.6 * s.stiffness) * p.k);
2583
- const dm = chainDamp(damp, ke);
2584
- let ax = d.x + p.ox;
2585
- let ay = d.y + p.oy;
2586
- for (const m of strand) {
2587
- const ex = ax - m.x;
2588
- const ey = ay - m.y;
2589
- const len = Math.hypot(ex, ey);
2590
- const pull = len > rest ? (len - rest) / len : 0;
2591
- m.vx = (m.vx + ex * pull * ke) * dm;
2592
- m.vy = (m.vy + ey * pull * ke + g) * dm;
2593
- m.x += m.vx;
2594
- m.y += m.vy;
2595
- ax = m.x;
2596
- ay = m.y;
2597
- }
2598
- p.x = strand[strand.length - 1].x;
2599
- p.y = strand[strand.length - 1].y;
2600
- break;
2601
- }
2602
- case "orbit": {
2603
- p.phase += (0.05 + 0.3 * s.speed) * (i % 2 ? -1 : 1) * (0.7 + 0.6 * rand01(this.seed, i, 17));
2604
- p.x = d.x + Math.cos(p.phase) * p.ox;
2605
- p.y = d.y + Math.sin(p.phase) * p.ox + g * p.ox;
2606
- break;
2607
- }
2608
- case "flow": {
2609
- const [fx, fy] = curl(p.x / Math.max(8, size * 1.5), p.y / Math.max(8, size * 1.5), this.seed);
2610
- const push = size * 0.012 * (0.3 + 1.5 * s.speed);
2611
- const fl = Math.hypot(fx, fy) || 1;
2612
- p.vx = p.vx * 0.8 + fx / fl * push;
2613
- p.vy = p.vy * 0.8 + fy / fl * push + g * 0.2;
2614
- p.x += p.vx;
2615
- p.y += p.vy;
2616
- p.age++;
2617
- if (p.age > lifeSteps) p.alive = false;
2618
- break;
2619
- }
2620
- }
2621
- const w = this.wave(p, d, size);
2622
- const qx = w ? p.x + nx * w : p.x;
2623
- const qy = w ? p.y + ny * w : p.y;
2624
- const px = p.dx;
2625
- const py = p.dy;
2626
- p.px = p.dx;
2627
- p.py = p.dy;
2628
- p.dx = qx;
2629
- p.dy = qy;
2630
- const fade = s.kind === "flow" ? 1 - p.age / lifeSteps : 1;
2631
- p.fade = fade;
2632
- const moved = Math.hypot(qx - px, qy - py);
2633
- if (d.index === 0 && s.kind !== "flow") continue;
2634
- if (s.render === "dot") this.dot(copy, qx, qy, qx - px, qy - py, markR * fade, al * fade, op, d, rects);
2635
- else if (s.render === "line" || s.render === "ribbon") this.segment(px, py, qx, qy, markR * fade, al * fade, op, d.pressure, rects);
2636
- else {
2637
- p.run += moved;
2638
- const gap = Math.max(markR * 2, size * (0.05 + s.interval));
2639
- while (p.run >= gap) {
2640
- p.run -= gap;
2641
- p.bead++;
2642
- const t = moved > 0 ? 1 - p.run / Math.max(moved, 1e-6) : 1;
2643
- const bx = px + (qx - px) * Math.min(1, Math.max(0, t));
2644
- const by = py + (qy - py) * Math.min(1, Math.max(0, t));
2645
- this.dot(copy, bx, by, qx - px, qy - py, markR * (0.7 + 0.6 * (p.bead % 3 / 2)) * fade, al * fade, op, d, rects);
2646
- }
2647
- }
2654
+ }
2655
+ medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
2656
+ return rect;
2657
+ }
2658
+ function drawArtSticker(medium, s, art) {
2659
+ const rect = stickerRect(s);
2660
+ const n = rect.width * rect.height;
2661
+ const cov = new Float32Array(n);
2662
+ const field = new Float32Array(n * 3);
2663
+ const a = s.rotation * Math.PI / 180;
2664
+ const ca = Math.cos(a);
2665
+ const sa = Math.sin(a);
2666
+ const scale = Math.max(art.width, art.height) / s.size;
2667
+ const [r, g, b] = hexRgb(s.color);
2668
+ const px = [0, 0, 0, 0];
2669
+ for (let yy = 0, p = 0; yy < rect.height; yy++) {
2670
+ for (let xx = 0; xx < rect.width; xx++, p++) {
2671
+ const dx = rect.x + xx + 0.5 - s.x;
2672
+ const dy = rect.y + yy + 0.5 - s.y;
2673
+ const ax = (dx * ca + dy * sa) * scale + art.width / 2;
2674
+ const ay = (-dx * sa + dy * ca) * scale + art.height / 2;
2675
+ if (ax < -1 || ay < -1 || ax > art.width + 1 || ay > art.height + 1) continue;
2676
+ sampleArt(art, ax, ay, px);
2677
+ if (!(px[3] > 0)) continue;
2678
+ cov[p] = px[3];
2679
+ field[p * 3] = art.tintable ? r : px[0];
2680
+ field[p * 3 + 1] = art.tintable ? g : px[1];
2681
+ field[p * 3 + 2] = art.tintable ? b : px[2];
2648
2682
  }
2649
- if (s.render === "ribbon" && !(d.index === 0 && s.kind !== "flow")) this.ribbon(copy, list, op, al, d, rects);
2650
- if (s.kind === "flow" && list.length > 512) {
2651
- list = list.filter((p) => p.alive);
2652
- this.sets[copy] = list;
2683
+ }
2684
+ medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
2685
+ return rect;
2686
+ }
2687
+ function jitterColor(hex2, amount, seed, i) {
2688
+ const [r, g, b] = hexRgb(hex2);
2689
+ const lch = oklabToOklch(rgbToOklab({ r, g, b, a: 1 }));
2690
+ const l = Math.min(1, Math.max(0, lch.l + (rand01(seed, i, 61) - 0.5) * amount * 0.3));
2691
+ const h = lch.h + (rand01(seed, i, 63) - 0.5) * amount * 80;
2692
+ const at = (c) => oklabToRgb(oklchToOklab({ l, c, h, alpha: 1 }));
2693
+ const inGamut = (o) => [o.r, o.g, o.b].every((v) => v >= -1e-4 && v <= 1 + 1e-4);
2694
+ let out = at(lch.c);
2695
+ if (!inGamut(out)) {
2696
+ let lo = 0;
2697
+ let hi = lch.c;
2698
+ for (let k = 0; k < 12; k++) {
2699
+ const mid = (lo + hi) / 2;
2700
+ if (inGamut(at(mid))) lo = mid;
2701
+ else hi = mid;
2653
2702
  }
2703
+ out = at(lo);
2654
2704
  }
2655
- /** A dot (or bead): round, or a textured stamp turned by the particle's motion (mx, my), at random or to the tip angle. */
2656
- dot(copy, x, y, mx, my, r, alpha, opacity, d, rects) {
2657
- const s = this.s;
2658
- const textures = s.dotTextures;
2659
- if (!textures.length) return this.mark(x, y, r, alpha, opacity, d.pressure, rects);
2660
- const n = this.dots[copy]++;
2661
- const pick = s.dotTextureOrder === "random" ? Math.min(textures.length - 1, Math.floor(rand01(this.seed, n, 31 + copy) * textures.length)) : n % textures.length;
2662
- 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;
2663
- this.stamp(x, y, r, textures[pick], rotation, this.seed + n * 2654435761 >>> 0, alpha, opacity, d.pressure, rects);
2705
+ return "#" + [out.r, out.g, out.b].map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")).join("");
2706
+ }
2707
+ var StickerSpray = class {
2708
+ constructor(init) {
2709
+ this.init = init;
2710
+ const b = init.brush;
2711
+ this.smoother = new PenSmoother(b.tip.smoothing, b.pressureSmoothing);
2712
+ this.copies = init.copies.length ? init.copies : [IDENTITY_COPY];
2713
+ const spacing = Math.max(0.05, 1 / Math.max(0.05, b.sticker.density * 4));
2714
+ const walk = { ...b, tip: { ...b.tip, spacing }, dynamics: { ...b.dynamics, scatter: 0, sizeJitter: 0, angleJitter: 0 } };
2715
+ this.walkers = this.copies.map((c, i) => new PenDabWalker(walk, init.seed + i * 7919 | 0, c.linear));
2716
+ this.medium = new TileMedium(init.target, {});
2717
+ this.next = init.nextId;
2664
2718
  }
2665
- /**
2666
- * Ribbon fill for one step: the band between each pair of joined particles, from where they
2667
- * were drawn last step to where they are drawn now. Bands of one step, and of consecutive steps,
2668
- * share edges exactly, so the fill has no seams; it is telescoped into the coverage
2669
- * (`telescope`), so it does not build up at the joins.
2670
- */
2671
- ribbon(copy, list, opacity, alpha, d, rects) {
2672
- const s = this.s;
2673
- if (!(s.ribbonFill > 0)) return;
2674
- const live = (p) => !!p && p.alive && !Number.isNaN(p.px);
2675
- let order;
2676
- if (s.kind === "flow") order = list.filter(live);
2677
- else order = this.orders[copy].map((i) => list[i]).filter(live);
2678
- if (order.length < 2) return;
2679
- const pairs = [];
2680
- if (s.ribbonPairs === "first-last") pairs.push([order[0], order[order.length - 1]]);
2681
- else for (let i = 0; i + 1 < order.length; i++) pairs.push([order[i], order[i + 1]]);
2682
- const pts = this.pts;
2683
- const ceil = Math.min(1, opacity * s.ribbonFill);
2684
- for (const [a, b] of pairs) {
2685
- pts.length = 0;
2686
- pts.push(a.px, a.py, a.dx, a.dy, b.dx, b.dy, b.px, b.py);
2687
- let x0 = Infinity;
2688
- let y0 = Infinity;
2689
- let x1 = -Infinity;
2690
- let y1 = -Infinity;
2691
- for (let i = 0; i < 8; i += 2) {
2692
- x0 = Math.min(x0, pts[i]);
2693
- x1 = Math.max(x1, pts[i]);
2694
- y0 = Math.min(y0, pts[i + 1]);
2695
- y1 = Math.max(y1, pts[i + 1]);
2696
- }
2697
- 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 };
2698
- const area = this.scratch(rect.width * rect.height);
2699
- addPolygonArea(pts, rect, area, Math.min(a.fade, b.fade) * alpha);
2700
- if (this.telescope(rect, area)) this.lay(rect, area, ceil, d.pressure, (a.dx + b.dx) / 2, (a.dy + b.dy) / 2, rects);
2719
+ init;
2720
+ dabCount = 0;
2721
+ stickers = [];
2722
+ /** Refs the spray picked but could not draw because their art had not loaded. */
2723
+ skipped = /* @__PURE__ */ new Set();
2724
+ smoother;
2725
+ walkers;
2726
+ copies;
2727
+ medium;
2728
+ lastRaw = null;
2729
+ lastFed = null;
2730
+ next;
2731
+ push(points) {
2732
+ const rects = [];
2733
+ for (const raw of points) {
2734
+ this.lastRaw = raw;
2735
+ const p = this.smoother.push(raw);
2736
+ this.lastFed = p;
2737
+ this.copies.forEach((c, i) => {
2738
+ const dabs = [];
2739
+ this.walkers[i].push(c.map(p), dabs);
2740
+ for (const d of dabs) this.drop(d, rects);
2741
+ });
2701
2742
  }
2743
+ return rects;
2702
2744
  }
2703
- /**
2704
- * Turns the area newly filled over `rect` into coverage to lay towards the fill's ceiling: the
2705
- * share of what was left unfilled, so after any number of bands (in any grouping) a pixel's
2706
- * coverage is the ceiling times the area filled there. False when nothing was added.
2707
- */
2708
- telescope(rect, area) {
2709
- let any = false;
2710
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
2711
- const gy = rect.y + yy;
2712
- const ty = Math.floor(gy / TILE);
2713
- let t;
2714
- let tx = NaN;
2715
- for (let xx = 0; xx < rect.width; xx++, k++) {
2716
- const add = area[k];
2717
- if (!(add > 0)) continue;
2718
- const gx = rect.x + xx;
2719
- const ntx = Math.floor(gx / TILE);
2720
- if (ntx !== tx || !t) {
2721
- tx = ntx;
2722
- const key = tileKey(tx, ty);
2723
- t = this.filled.get(key);
2724
- if (!t) {
2725
- t = new Float32Array(TILE * TILE);
2726
- this.filled.set(key, t);
2727
- }
2728
- }
2729
- const ti = (gy - ty * TILE) * TILE + (gx - tx * TILE);
2730
- const before = t[ti];
2731
- const after = Math.min(1, before + add);
2732
- t[ti] = after;
2733
- area[k] = before < 1 ? (after - before) / (1 - before) : 0;
2734
- any = true;
2735
- }
2745
+ finish() {
2746
+ const rects = [];
2747
+ const raw = this.lastRaw;
2748
+ const fed = this.lastFed;
2749
+ this.copies.forEach((c, i) => {
2750
+ const dabs = [];
2751
+ if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.walkers[i].push(c.map({ ...raw, pressure: fed.pressure }), dabs);
2752
+ this.walkers[i].finish(dabs);
2753
+ for (const d of dabs) this.drop(d, rects);
2754
+ });
2755
+ return rects;
2756
+ }
2757
+ drop(d, rects) {
2758
+ const st = this.init.brush.sticker;
2759
+ const i = this.next;
2760
+ const seed = this.init.seed;
2761
+ const size = d.radius * 2 * (1 - st.sizeJitter * 0.7 * rand01(seed, i, 51));
2762
+ const off = st.scatter * d.radius * 2;
2763
+ const a = rand01(seed, i, 53) * Math.PI * 2;
2764
+ const dist = rand01(seed, i, 55) * off;
2765
+ const base = this.colorAt(d, Math.cos(a) * dist, Math.sin(a) * dist);
2766
+ if (!(base.alpha > 0)) {
2767
+ this.next++;
2768
+ return;
2769
+ }
2770
+ const ref2 = st.stamps[Math.floor(rand01(seed, i, 57) * st.stamps.length)] ?? "editor:dot";
2771
+ const stamp = stickerStamp({ ref: ref2 });
2772
+ if (!stamp && !this.init.art?.(ref2)) {
2773
+ this.skipped.add(ref2);
2774
+ this.next++;
2775
+ return;
2776
+ }
2777
+ const sticker = {
2778
+ id: `st${this.next++}`,
2779
+ ref: ref2,
2780
+ ...stamp ? { stamp } : {},
2781
+ x: Math.round((d.x + Math.cos(a) * dist) * 100) / 100,
2782
+ y: Math.round((d.y + Math.sin(a) * dist) * 100) / 100,
2783
+ size: Math.max(2, Math.round(size * 10) / 10),
2784
+ rotation: Math.round((-d.angle * 180 / Math.PI + (rand01(seed, i, 59) * 2 - 1) * 180 * st.rotationJitter) * 10) / 10,
2785
+ color: st.colorJitter > 0 ? jitterColor(base.color, st.colorJitter, seed, i) : base.color,
2786
+ opacity: Math.round(Math.min(1, d.opacity * this.init.brush.tip.opacity * base.alpha) * 1e3) / 1e3,
2787
+ seed: Math.floor(rand01(seed, i, 65) * 1e9)
2788
+ };
2789
+ this.stickers.push(sticker);
2790
+ rects.push(drawSticker(this.medium, sticker, this.init.art));
2791
+ this.dabCount++;
2792
+ }
2793
+ sampler;
2794
+ /** A sticker's colour: the paint colour, or gradient paint at its dab (`dx`, `dy` from the dab's centre). */
2795
+ colorAt(d, dx, dy) {
2796
+ const effect = this.init.effect;
2797
+ if (effect) {
2798
+ const rgb = [0, 0, 0];
2799
+ const alpha = sampleEffectAt(effect, d.x + dx, d.y + dy, rgb, d);
2800
+ return { color: "#" + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join(""), alpha };
2801
+ }
2802
+ const g = this.init.paint;
2803
+ if (!g) return { color: this.init.color, alpha: 1 };
2804
+ this.sampler ??= new PaintGradientSampler(g, this.init.seed);
2805
+ const s = g.map.by === "across-tip" ? this.sampler.sample(((dx * Math.sin(d.direction) + dy * Math.cos(d.direction)) / Math.max(0.5, d.radius) + 1) / 2) : this.sampler.dab(d);
2806
+ 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 };
2807
+ }
2808
+ };
2809
+ function renderStickers(stickers, target, art) {
2810
+ const medium = new TileMedium(target, {});
2811
+ for (const s of stickers) drawSticker(medium, s, art);
2812
+ }
2813
+ function normalizeStickerUpdate(u) {
2814
+ const out = {};
2815
+ 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).");
2816
+ if (u.stamp !== void 0) {
2817
+ 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).`);
2818
+ out.ref = `editor:${u.stamp}`;
2819
+ out.stamp = u.stamp;
2820
+ }
2821
+ if (u.ref !== void 0) {
2822
+ out.ref = normalizeStickerRef(u.ref);
2823
+ const stamp = stickerStamp({ ref: out.ref });
2824
+ if (stamp) out.stamp = stamp;
2825
+ else out.stamp = void 0;
2826
+ }
2827
+ for (const k of ["x", "y"]) if (u[k] !== void 0) {
2828
+ if (!Number.isFinite(u[k])) throw new Error(`${k} must be a number of pixels.`);
2829
+ out[k] = u[k];
2830
+ }
2831
+ if (u.size !== void 0) {
2832
+ if (!(u.size >= 2 && u.size <= 2048)) throw new Error(`size ${u.size} is out of range; use 2..2048 px.`);
2833
+ out.size = u.size;
2834
+ }
2835
+ if (u.rotation !== void 0) {
2836
+ if (!Number.isFinite(u.rotation)) throw new Error("rotation must be degrees.");
2837
+ out.rotation = u.rotation;
2838
+ }
2839
+ if (u.color !== void 0) {
2840
+ if (!/^#[0-9a-f]{6}$/i.test(u.color)) throw new Error(`color ${JSON.stringify(u.color)} must be #rrggbb.`);
2841
+ out.color = u.color.toLowerCase();
2842
+ }
2843
+ if (u.opacity !== void 0) {
2844
+ if (!(u.opacity >= 0 && u.opacity <= 1)) throw new Error(`opacity ${u.opacity} is out of range; use 0..1.`);
2845
+ out.opacity = u.opacity;
2846
+ }
2847
+ return out;
2848
+ }
2849
+ function stickerSurface(stickers, base, dirty, art) {
2850
+ const only = base && dirty ? new Set(dirty.flatMap((r) => tilesInRect(r).map(({ tx, ty }) => tileKey(tx, ty)))) : null;
2851
+ const live = /* @__PURE__ */ new Map();
2852
+ let scratch = null;
2853
+ const target = {
2854
+ offset: { x: 0, y: 0 },
2855
+ lockTransparency: false,
2856
+ selection: null,
2857
+ readTile: (tx, ty) => live.get(tileKey(tx, ty)) ?? null,
2858
+ writeTile: (tx, ty) => {
2859
+ const k = tileKey(tx, ty);
2860
+ if (only && !only.has(k)) return (scratch ??= new Uint8ClampedArray(TILE * TILE * 4)).fill(0);
2861
+ let t = live.get(k);
2862
+ if (!t) live.set(k, t = new Uint8ClampedArray(TILE * TILE * 4));
2863
+ return t;
2736
2864
  }
2737
- return any;
2865
+ };
2866
+ const reaches = (s) => !only || tilesInRect(stickerRect(s)).some(({ tx, ty }) => only.has(tileKey(tx, ty)));
2867
+ renderStickers(stickers.filter(reaches), target, art);
2868
+ const tiles = /* @__PURE__ */ new Map();
2869
+ if (only && base) {
2870
+ for (const [k, t] of base.tiles) if (!only.has(k)) tiles.set(k, t);
2738
2871
  }
2739
- };
2740
- var BristleStroke = class extends MarkStroke {
2741
- tufts;
2742
- s;
2743
- constructor(brush, options) {
2744
- super(brush, options);
2745
- this.s = brush.bristle;
2746
- const s = this.s;
2747
- this.tufts = this.copies.map(
2748
- (_, c) => Array.from({ length: s.count }, (_2, i) => {
2749
- const a = rand01(this.seed, i, 21 + c) * Math.PI * 2;
2750
- const r = Math.sqrt(rand01(this.seed, i, 23 + c));
2751
- return {
2752
- u: Math.cos(a) * r,
2753
- v: Math.sin(a) * r,
2754
- x: 0,
2755
- y: 0,
2756
- paint: 1 - s.jitter * 0.4 * rand01(this.seed, i, 25 + c),
2757
- thirst: 1 + s.jitter * (rand01(this.seed, i, 27 + c) * 2 - 1),
2758
- w: 1 + s.jitter * 0.6 * (rand01(this.seed, i, 29 + c) - 0.5),
2759
- started: false
2760
- };
2761
- })
2762
- );
2872
+ for (const [k, d] of live) {
2873
+ const t = finishTile(d, 4, 0);
2874
+ if (t) tiles.set(k, t);
2763
2875
  }
2764
- advance(copy, d, rects) {
2765
- const s = this.s;
2766
- const tuft = this.tufts[copy];
2767
- const spread = d.radius * (0.55 + 0.9 * s.splay * d.pressure);
2768
- const ca = Math.cos(d.angle);
2769
- const sa = Math.sin(d.angle);
2770
- const ro = Math.max(0.15, d.roundness);
2771
- const tx = Math.cos(d.direction);
2772
- const ty = -Math.sin(d.direction);
2773
- const follow = 0.15 + 0.85 * s.stiffness;
2774
- const bend = d.radius * (1 - s.stiffness) * d.pressure * 0.8;
2775
- const lineR = Math.max(0.5, d.radius * 2 * s.thickness * 0.5);
2776
- const dryRate = 4e-3 * (1 - s.load);
2777
- for (const b of tuft) {
2778
- const lu = b.u * spread;
2779
- const lv = b.v * spread * ro;
2780
- const ox = lu * ca - lv * sa;
2781
- const oy = -(lu * sa + lv * ca);
2782
- const lag = bend * (1 - 0.5 * b.v);
2783
- const wx = d.x + ox - tx * lag;
2784
- const wy = d.y + oy - ty * lag;
2785
- if (!b.started) {
2786
- b.x = wx;
2787
- b.y = wy;
2788
- b.started = true;
2789
- continue;
2790
- }
2791
- const nx = b.x + (wx - b.x) * follow;
2792
- const ny = b.y + (wy - b.y) * follow;
2793
- const paint = Math.max(0, b.paint);
2794
- if (paint > 0.02) this.segment(b.x, b.y, nx, ny, lineR * b.w * (0.6 + 0.4 * d.pressure), d.alpha * Math.min(1, paint * 1.4), d.opacity, d.pressure, rects);
2795
- b.paint -= dryRate * b.thirst * Math.hypot(nx - b.x, ny - b.y);
2796
- b.x = nx;
2797
- b.y = ny;
2876
+ return new TiledSurface(4, tiles, 0);
2877
+ }
2878
+ function stickerAt(stickers, x, y, art) {
2879
+ for (let i = stickers.length - 1; i >= 0; i--) {
2880
+ const s = stickers[i];
2881
+ const rad2 = s.size / 2;
2882
+ const a = s.rotation * Math.PI / 180;
2883
+ const dx = x - s.x;
2884
+ const dy = y - s.y;
2885
+ const u = (dx * Math.cos(a) + dy * Math.sin(a)) / rad2;
2886
+ const v = (-dx * Math.sin(a) + dy * Math.cos(a)) / rad2;
2887
+ if (Math.abs(u) > 1.05 || Math.abs(v) > 1.05) continue;
2888
+ if (s.size < 16) return s;
2889
+ const kind = stickerStamp(s);
2890
+ if (kind) {
2891
+ if (stampCoverage(kind, u, v, s.seed) > 0.25) return s;
2892
+ continue;
2798
2893
  }
2894
+ const pic = art?.(s.ref);
2895
+ if (!pic) return s;
2896
+ const scale = Math.max(pic.width, pic.height) / 2;
2897
+ if (sampleArt(pic, u * scale + pic.width / 2, v * scale + pic.height / 2, [0, 0, 0, 0])[3] > 0.25) return s;
2799
2898
  }
2800
- };
2899
+ return null;
2900
+ }
2801
2901
 
2802
2902
  // src/paint-editor/stroke.ts
2803
2903
  function defaultTile(surface) {
@@ -2861,7 +2961,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2861
2961
  effect
2862
2962
  });
2863
2963
  } else {
2864
- const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect };
2964
+ const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt };
2865
2965
  this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
2866
2966
  }
2867
2967
  }
@@ -2897,7 +2997,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2897
2997
  const init = this.init;
2898
2998
  const grey = Math.round(init.brush.light.amount * 255);
2899
2999
  const g = grey / 255;
2900
- const hex = "#" + grey.toString(16).padStart(2, "0").repeat(3);
3000
+ const hex2 = "#" + grey.toString(16).padStart(2, "0").repeat(3);
2901
3001
  const host = {
2902
3002
  update: () => {
2903
3003
  },
@@ -2906,14 +3006,14 @@ var PaintStrokeSession = class _PaintStrokeSession {
2906
3006
  }
2907
3007
  };
2908
3008
  const effect = init.effect ? { source: init.effect.source, pixels: { ...init.effect.pixels, data: greyPixels(init.effect.pixels.data, grey) } } : null;
2909
- const paint = init.paint ? { ...init.paint, stops: init.paint.stops.map((st) => ({ ...st, color: hex })) } : null;
3009
+ const paint = init.paint ? { ...init.paint, stops: init.paint.stops.map((st) => ({ ...st, color: hex2 })) } : null;
2910
3010
  return new _PaintStrokeSession(host, {
2911
3011
  ...init,
2912
3012
  surface: lightToShadow(light),
2913
3013
  lockTransparency: false,
2914
3014
  brush: lightTwinBrush(init.brush),
2915
3015
  color: [grey, grey, grey, 255],
2916
- colorHex: hex,
3016
+ colorHex: hex2,
2917
3017
  secondary: [grey, grey, grey, 255],
2918
3018
  inputFilter: void 0,
2919
3019
  mixerLoad: { reservoir: init.mixerLoad.reservoir ? [g, g, g] : null, amount: init.mixerLoad.amount, pickup: null },
@@ -4461,8 +4561,8 @@ var PaintWetLayers = class {
4461
4561
  this.gpu = null;
4462
4562
  }
4463
4563
  };
4464
- function wetPigmentOf(hex, body) {
4465
- const [r, g, b] = hexToRgb8(hex);
4564
+ function wetPigmentOf(hex2, body) {
4565
+ const [r, g, b] = hexToRgb8(hex2);
4466
4566
  return Array.from(pigmentOf([r / 255, g / 255, b / 255], body));
4467
4567
  }
4468
4568
 
@@ -4934,8 +5034,8 @@ var MixingPad = class {
4934
5034
  A += a;
4935
5035
  }
4936
5036
  if (A === 0) return null;
4937
- const hex = (v) => Math.round(v / A).toString(16).padStart(2, "0");
4938
- return `#${hex(R)}${hex(G)}${hex(B)}`;
5037
+ const hex2 = (v) => Math.round(v / A).toString(16).padStart(2, "0");
5038
+ return `#${hex2(R)}${hex2(G)}${hex2(B)}`;
4939
5039
  }
4940
5040
  clear() {
4941
5041
  this.surface = emptySurface(4);
@@ -4996,8 +5096,8 @@ var MixingPad = class {
4996
5096
  paints2.map((p) => p.parts)
4997
5097
  );
4998
5098
  const core = normalizePaintBrush({ tip: { ...PAD_BLOB.tip, size: R * 1.1, hardness: 0.55 } }, PAD_BLOB);
4999
- const hex = "#" + mixed.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
5000
- this.run(core, [{ x: cx, y: cy }], hex);
5099
+ const hex2 = "#" + mixed.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
5100
+ this.run(core, [{ x: cx, y: cy }], hex2);
5001
5101
  return { color: this.pick(cx, cy, 2), bounds };
5002
5102
  }
5003
5103
  /** The pad's pixels as a PNG data URL (with alpha), for saving. */
@@ -5699,8 +5799,8 @@ function parseColor(value, what = "color") {
5699
5799
  if (typeof value !== "string" || !HEX3.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
5700
5800
  return value.trim().toLowerCase();
5701
5801
  }
5702
- function rgbaOf(hex) {
5703
- const [r, g, b] = hexToRgb8(hex);
5802
+ function rgbaOf(hex2) {
5803
+ const [r, g, b] = hexToRgb8(hex2);
5704
5804
  return [r, g, b, 255];
5705
5805
  }
5706
5806
  function rgbToHex(r, g, b) {
@@ -5753,6 +5853,13 @@ var PaintEditorController = class _PaintEditorController {
5753
5853
  stickerPreview = null;
5754
5854
  encodeImage;
5755
5855
  listeners = /* @__PURE__ */ new Set();
5856
+ commitListeners = /* @__PURE__ */ new Set();
5857
+ /** Origins set by `withOrigin`, innermost last. */
5858
+ origins = [];
5859
+ /** `group` in progress: commits fold into one step, recorded when it ends. */
5860
+ grouping = null;
5861
+ /** `previewPaint` in progress: nothing is emitted or recorded. */
5862
+ previewing = false;
5756
5863
  version = 0;
5757
5864
  cached = null;
5758
5865
  session = null;
@@ -5824,6 +5931,7 @@ var PaintEditorController = class _PaintEditorController {
5824
5931
  void this.refreshBrushes().catch(() => void 0);
5825
5932
  this.pendingBrushId = start.brush.id === wanted ? null : wanted;
5826
5933
  this.color = options.color ? parseColor(options.color) : "#1f2937";
5934
+ this.colorScheme = options.colorScheme === "dark" ? "dark" : "light";
5827
5935
  this.hand = options.hand === "left" ? "left" : "right";
5828
5936
  this.encodeImage = options.encodeImage;
5829
5937
  this.guides = defaultGuides(this.doc.canvas);
@@ -5860,7 +5968,125 @@ var PaintEditorController = class _PaintEditorController {
5860
5968
  this.listeners.add(listener);
5861
5969
  return () => this.listeners.delete(listener);
5862
5970
  }
5971
+ /** Hears each undo step recorded, merged, undone or redone, with who made it. Returns an unsubscribe function. */
5972
+ onCommit(listener) {
5973
+ this.commitListeners.add(listener);
5974
+ return () => {
5975
+ this.commitListeners.delete(listener);
5976
+ };
5977
+ }
5978
+ notifyCommit(event) {
5979
+ if (this.previewing) return;
5980
+ for (const l of [...this.commitListeners]) l(event);
5981
+ }
5982
+ /** The origin steps are recorded with now: the innermost `withOrigin`, else `user`. */
5983
+ getOrigin() {
5984
+ return this.origins[this.origins.length - 1] ?? "user";
5985
+ }
5986
+ /**
5987
+ * Records the steps `fn` makes as made by `origin` (`agent:<id>` for an agent). When `fn` returns
5988
+ * a promise, the origin holds until it settles. Satisfies `createOriginMiddleware`'s history target,
5989
+ * so agent tool calls are recorded as the calling agent's.
5990
+ */
5991
+ withOrigin(origin, fn) {
5992
+ this.origins.push(origin);
5993
+ let pending = false;
5994
+ const pop = () => {
5995
+ const i = this.origins.lastIndexOf(origin);
5996
+ if (i >= 0) this.origins.splice(i, 1);
5997
+ };
5998
+ try {
5999
+ const result = fn();
6000
+ if (result && typeof result.then === "function") {
6001
+ pending = true;
6002
+ return Promise.resolve(result).finally(pop);
6003
+ }
6004
+ return result;
6005
+ } finally {
6006
+ if (!pending) pop();
6007
+ }
6008
+ }
6009
+ /**
6010
+ * Runs `fn` as one undo step named `label`: every change it makes (strokes, fills, new layers) is
6011
+ * undone together. Wet paint can't be grouped. Nested groups join the outer one. If `fn` throws,
6012
+ * its changes are put back and nothing is recorded.
6013
+ */
6014
+ group(label, fn) {
6015
+ if (this.grouping) return fn();
6016
+ this.requireIdle("grouping changes");
6017
+ const before = this.doc;
6018
+ const revision = this.docRevision;
6019
+ this.grouping = { label, before, changed: false };
6020
+ let result;
6021
+ try {
6022
+ result = fn();
6023
+ } catch (e) {
6024
+ this.grouping = null;
6025
+ if (this.doc !== before) {
6026
+ this.doc = before;
6027
+ this.docRevision = revision + 1;
6028
+ this.emit();
6029
+ }
6030
+ throw e;
6031
+ }
6032
+ const g = this.grouping;
6033
+ this.grouping = null;
6034
+ if (g.changed && this.doc !== before) {
6035
+ this.undoStack.push({ label, origin: this.getOrigin(), before, after: this.doc });
6036
+ if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
6037
+ this.redoStack = [];
6038
+ this.notifyCommit({ kind: "execute", label, origin: this.getOrigin() });
6039
+ this.emit();
6040
+ }
6041
+ return result;
6042
+ }
6043
+ /**
6044
+ * What `fn` would paint, without changing anything: it runs on a new empty layer (above the
6045
+ * active layer, another one, or at the bottom), and the layer's pixels and the picture with them
6046
+ * come back while the document, history and listeners stay as they were. `fn` gets that layer's id and must paint on it with dry brushes (strokes, replays and
6047
+ * fills); wet paint can't be previewed. For ghost paint an agent suggests (`createPaintProposals`).
6048
+ */
6049
+ previewPaint(fn, placement = {}) {
6050
+ this.requireIdle("previewing");
6051
+ if (this.previewing) throw new PaintEditorError("A preview is already running.");
6052
+ const saved = {
6053
+ doc: this.doc,
6054
+ undo: [...this.undoStack],
6055
+ redo: this.redoStack,
6056
+ revision: this.docRevision,
6057
+ strokeCounter: this.strokeCounter,
6058
+ seedCounter: this.seedCounter,
6059
+ mixerLoad: this.mixerLoad
6060
+ };
6061
+ this.previewing = true;
6062
+ try {
6063
+ const layer = this.addLayer({ name: "Preview", ...placement.aboveId ? { aboveId: placement.aboveId } : {} });
6064
+ if (placement.bottom) this.moveLayer(layer.id, 0, null);
6065
+ fn(layer.id);
6066
+ if (this.wetOp || this.pendingWetEvents.length) throw new PaintEditorError("Wet paint can't be previewed; suggest it with a dry brush.");
6067
+ const found = findLayer(this.doc.layers, layer.id)?.layer;
6068
+ const surface = found && isPixelLayer(found) ? found.surface : null;
6069
+ const bounds = surface ? contentBounds(surface) : null;
6070
+ const box = bounds ? clipToCanvas(bounds, this.doc.canvas) : null;
6071
+ if (!surface || !box) return { bounds: null, pixels: new Uint8ClampedArray(0), composite: new Uint8ClampedArray(0) };
6072
+ const composite = this.exportCompositor.composite(this.renderLayers({ wet: "display" }), box, this.sources());
6073
+ return { bounds: box, pixels: readRegion(surface, box), composite };
6074
+ } finally {
6075
+ this.wetOp = null;
6076
+ this.pendingWetEvents = [];
6077
+ this.doc = saved.doc;
6078
+ this.undoStack = saved.undo;
6079
+ this.redoStack = saved.redo;
6080
+ this.docRevision = saved.revision;
6081
+ this.strokeCounter = saved.strokeCounter;
6082
+ this.seedCounter = saved.seedCounter;
6083
+ this.mixerLoad = saved.mixerLoad;
6084
+ this.cached = null;
6085
+ this.previewing = false;
6086
+ }
6087
+ }
5863
6088
  emit(dirty = "all") {
6089
+ if (this.previewing) return;
5864
6090
  if (dirty === "all" || this.dirty === "all") this.dirty = "all";
5865
6091
  else this.dirty.push(...dirty);
5866
6092
  this.version++;
@@ -5877,6 +6103,8 @@ var PaintEditorController = class _PaintEditorController {
5877
6103
  brush: this.brush,
5878
6104
  brushes: this.library ??= this.brushEntries.map((e) => e.brush),
5879
6105
  color: this.color,
6106
+ colorScheme: this.colorScheme,
6107
+ colorLocked: this.colorLocked,
5880
6108
  secondaryColor: this.secondaryColor,
5881
6109
  paint: this.gradient ?? { type: "color", color: this.color },
5882
6110
  effectPaint: this.effectPaintPending ?? this.effectPaint,
@@ -5943,7 +6171,13 @@ var PaintEditorController = class _PaintEditorController {
5943
6171
  if (this.session) throw new PaintEditorError(`A stroke is in progress; end or cancel it before ${what}.`);
5944
6172
  }
5945
6173
  commit(label, next, before = this.doc) {
5946
- const entry = { label, before, after: next };
6174
+ if (this.grouping) {
6175
+ this.grouping.changed = true;
6176
+ this.doc = next;
6177
+ this.docRevision++;
6178
+ return;
6179
+ }
6180
+ const entry = { label, origin: this.getOrigin(), before, after: next };
5947
6181
  if (this.wetOp) {
5948
6182
  entry.wet = { before: this.wetOp.before, after: this.wet.snapshot(), lost: false };
5949
6183
  this.wetOp = null;
@@ -5954,6 +6188,7 @@ var PaintEditorController = class _PaintEditorController {
5954
6188
  this.doc = next;
5955
6189
  this.docRevision++;
5956
6190
  this.trimWetHistory();
6191
+ this.notifyCommit({ kind: "execute", label, origin: entry.origin });
5957
6192
  this.emit();
5958
6193
  }
5959
6194
  /**
@@ -5962,7 +6197,7 @@ var PaintEditorController = class _PaintEditorController {
5962
6197
  */
5963
6198
  commitOrAmend(label, next, commitToHistory) {
5964
6199
  const last = this.undoStack[this.undoStack.length - 1];
5965
- if (commitToHistory || !last || last.label !== label || last.wet || this.wetOp) {
6200
+ if (commitToHistory || !last || last.label !== label || last.wet || this.wetOp || this.grouping) {
5966
6201
  this.commit(label, next);
5967
6202
  return;
5968
6203
  }
@@ -5970,6 +6205,7 @@ var PaintEditorController = class _PaintEditorController {
5970
6205
  this.redoStack = [];
5971
6206
  this.doc = next;
5972
6207
  this.docRevision++;
6208
+ this.notifyCommit({ kind: "merge", label, origin: last.origin });
5973
6209
  this.emit();
5974
6210
  }
5975
6211
  /** Keeps the wet snapshots in history within the wet undo memory, dropping the oldest. */
@@ -6039,6 +6275,7 @@ var PaintEditorController = class _PaintEditorController {
6039
6275
  this.wet.restore(e.wet.lost ? null : e.wet.before);
6040
6276
  if (paper !== this.doc.paper) this.wet.paperChanged();
6041
6277
  }
6278
+ this.notifyCommit({ kind: "undo", label: e.label, origin: this.getOrigin() });
6042
6279
  this.emit();
6043
6280
  return e.label;
6044
6281
  }
@@ -6057,6 +6294,7 @@ var PaintEditorController = class _PaintEditorController {
6057
6294
  this.wet.restore(e.wet.lost ? null : e.wet.after);
6058
6295
  if (paper !== this.doc.paper) this.wet.paperChanged();
6059
6296
  }
6297
+ this.notifyCommit({ kind: "redo", label: e.label, origin: this.getOrigin() });
6060
6298
  this.emit();
6061
6299
  return e.label;
6062
6300
  }
@@ -7004,6 +7242,7 @@ var PaintEditorController = class _PaintEditorController {
7004
7242
  if (changes.paint !== void 0 && changes.paint !== null) this.readStrokePaint(changes.paint);
7005
7243
  const unchanged = !Object.keys(changes).length;
7006
7244
  const next = normalizePaintBrush({ ...changes, id: base.id }, base);
7245
+ if (entry) this.takeBrushColour(entry.spec);
7007
7246
  this.useBrush(next, unchanged ? baseSpec : brushSpecOf(next, baseSpec), unchanged && base.builtin === true);
7008
7247
  return this.brush;
7009
7248
  }
@@ -7020,6 +7259,7 @@ var PaintEditorController = class _PaintEditorController {
7020
7259
  const base = copyJSON(entry ? entry.spec : this.brushSpec);
7021
7260
  for (const k of unset) delete base[k];
7022
7261
  const spec = this.readBrushPatch(base, patch, problems);
7262
+ if (entry || patch.colour?.default !== void 0) this.takeBrushColour(spec);
7023
7263
  this.useBrush(unchanged && entry ? entry.brush : resolveBrush(spec), spec, unchanged && entry?.builtin === true);
7024
7264
  return { brush: this.brush, spec: copyJSON(this.brushSpec), problems };
7025
7265
  }
@@ -7069,7 +7309,8 @@ var PaintEditorController = class _PaintEditorController {
7069
7309
  this.brushSpec = { ...copyJSON(spec), id: next.id, name: next.name };
7070
7310
  this.pendingBrushId = null;
7071
7311
  this.emit([]);
7072
- if (next.engine === "sticker" && next.sticker.stamps.some((r) => !editorStampOf(r))) void this.prepareStickers(next.sticker.stamps).catch(() => this.emit([]));
7312
+ const refs = brushStickerRefs(next);
7313
+ if (refs.length) void this.prepareStickers(refs).catch(() => this.emit([]));
7073
7314
  }
7074
7315
  /**
7075
7316
  * Saves the active brush (or `brush`, engine settings on top of it) into the brush library under
@@ -7114,9 +7355,54 @@ var PaintEditorController = class _PaintEditorController {
7114
7355
  return renderBrushThumbnail(b, { paper: this.doc.paper, width: options.width, height: options.height, color: options.color ? parseColor(options.color) : this.color });
7115
7356
  }
7116
7357
  // --- Colour --------------------------------------------------------------------
7358
+ colorScheme;
7359
+ colorLocked = false;
7360
+ /** The brush default colour the paint colour came from (resolved again when the scheme changes), or `null`. */
7361
+ brushColour = null;
7362
+ /**
7363
+ * The host's colour scheme: a paint colour that came from a brush's light / dark default
7364
+ * resolves to the new side. Editor state, not an undo step.
7365
+ */
7366
+ setColorScheme(scheme) {
7367
+ const next = scheme === "dark" ? "dark" : "light";
7368
+ if (next === this.colorScheme) return;
7369
+ this.colorScheme = next;
7370
+ const hex2 = this.brushColour ? brushColourHex(this.brushColour, next) : null;
7371
+ if (hex2) this.color = hex2;
7372
+ this.emit([]);
7373
+ }
7374
+ /** Locks the paint colour: choosing a brush with a default colour keeps it. Editor state, not an undo step. */
7375
+ setColorLocked(locked) {
7376
+ if (this.colorLocked === locked) return;
7377
+ this.colorLocked = locked;
7378
+ this.emit([]);
7379
+ }
7380
+ /**
7381
+ * After the user chooses a brush: its solid or light / dark default colour becomes the paint
7382
+ * colour (resolved for the colour scheme), unless the colour is locked. Gradient defaults are the
7383
+ * brush's own paint already; effect defaults stay a suggestion.
7384
+ */
7385
+ takeBrushColour(spec) {
7386
+ if (this.colorLocked) return;
7387
+ const d = spec.colour?.default;
7388
+ const hex2 = d !== void 0 ? brushColourHex(d, this.colorScheme) : null;
7389
+ if (!hex2) return;
7390
+ this.color = hex2;
7391
+ this.gradient = null;
7392
+ this.effectPaint = this.effectPaintPending = null;
7393
+ this.paintEpoch++;
7394
+ this.brushColour = d;
7395
+ }
7396
+ /** A brush that always paints its own colour (`colour.own`): that colour, resolved for the scheme now; else `null`. */
7397
+ ownColour(brushId) {
7398
+ const spec = brushId !== void 0 ? this.findEntry(brushId)?.spec : this.brushSpec;
7399
+ const d = spec?.colour?.own ? spec.colour.default : void 0;
7400
+ return d !== void 0 ? brushColourHex(d, this.colorScheme) : null;
7401
+ }
7117
7402
  /** Sets the paint colour (and goes back to one colour from gradient paint). */
7118
7403
  setColor(color) {
7119
7404
  this.color = parseColor(color);
7405
+ this.brushColour = null;
7120
7406
  this.gradient = null;
7121
7407
  this.effectPaint = this.effectPaintPending = null;
7122
7408
  this.paintEpoch++;
@@ -7263,6 +7549,7 @@ var PaintEditorController = class _PaintEditorController {
7263
7549
  return l;
7264
7550
  })();
7265
7551
  const brush = this.strokeBrush(init);
7552
+ if (this.previewing && isWetBrush(brush)) throw new PaintEditorError(`Wet paint can't be previewed; the ${brush.name} brush paints wet media. Suggest it with a dry brush.`);
7266
7553
  const effect = this.strokeEffect(init, brush);
7267
7554
  if (brush.light.amount > 0) {
7268
7555
  if (brush.engine === "sticker") throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take light yet; paint light with a dab or particle light brush (glow-gel, neon-marker; list_brush_presets lists them), or set light.amount to 0 (set_brush).`);
@@ -7299,7 +7586,7 @@ var PaintEditorController = class _PaintEditorController {
7299
7586
  this.beginWetOp();
7300
7587
  const world = this.wet.ensureLane(layer.id, brush.wet.medium);
7301
7588
  const id = layer.id;
7302
- const wetColor = init.color === void 0 ? this.color : parseColor(init.color);
7589
+ const wetColor = init.color === void 0 ? this.ownColour(init.brushId) ?? this.color : parseColor(init.color);
7303
7590
  const [r, g, b] = hexToRgb8(wetColor);
7304
7591
  const wetGradient = this.strokeGradient(init, brush, wetColor);
7305
7592
  const load = init.load ?? brushLoadForStroke(brush, this.mixerLoad, [r / 255, g / 255, b / 255]);
@@ -7327,7 +7614,7 @@ var PaintEditorController = class _PaintEditorController {
7327
7614
  const dried = findLayer(this.doc.layers, layer.id).layer;
7328
7615
  layer = dried;
7329
7616
  }
7330
- let color = init.color === void 0 ? this.color : parseColor(init.color);
7617
+ let color = init.color === void 0 ? this.ownColour(init.brushId) ?? this.color : parseColor(init.color);
7331
7618
  const tracing = this.guides.tracing;
7332
7619
  if (init.color === void 0 && init.start && tracing?.visible && tracing.sampleColors) color = this.sampleTracingColor(init.start.x, init.start.y) ?? color;
7333
7620
  const perspective = this.guides.perspective;
@@ -7366,6 +7653,7 @@ var PaintEditorController = class _PaintEditorController {
7366
7653
  // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
7367
7654
  light: !wet && brush.engine !== "sticker" && (brush.light.amount > 0 || doc.light?.[layer.id]) ? { surface: doc.light?.[layer.id] ?? emptyLight() } : null,
7368
7655
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc), art: this.stickerArt.get } : void 0,
7656
+ stampArt: this.stickerArt.get,
7369
7657
  paint: effect ? null : this.strokeGradient(init, brush, color),
7370
7658
  effect
7371
7659
  });
@@ -9566,6 +9854,13 @@ function resampleInto(src, rect, width, height) {
9566
9854
  }
9567
9855
  return { width, height, data: out };
9568
9856
  }
9857
+ function clipToCanvas(rect, canvas) {
9858
+ const x0 = Math.max(0, rect.x);
9859
+ const y0 = Math.max(0, rect.y);
9860
+ const x1 = Math.min(canvas.width, rect.x + rect.width);
9861
+ const y1 = Math.min(canvas.height, rect.y + rect.height);
9862
+ return x1 > x0 && y1 > y0 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
9863
+ }
9569
9864
 
9570
9865
  // src/paint-editor/presence.ts
9571
9866
  var PaintPresence = class extends PresenceStore {
@@ -10786,8 +11081,8 @@ var POINTS = {
10786
11081
  }
10787
11082
  };
10788
11083
  function loadResult(load) {
10789
- const hex = (c) => c ? "#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("") : null;
10790
- return { reservoir: hex(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex(load.pickup) };
11084
+ const hex2 = (c) => c ? "#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("") : null;
11085
+ return { reservoir: hex2(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex2(load.pickup) };
10791
11086
  }
10792
11087
  function createPaintEditorToolProvider(controller, options = {}) {
10793
11088
  const c = typeof controller === "function" ? lazyController(controller) : controller;
@@ -11133,18 +11428,24 @@ function createPaintEditorToolProvider(controller, options = {}) {
11133
11428
  add({
11134
11429
  name: "set_color",
11135
11430
  title: "Set the paint colour",
11136
- description: "Sets the paint colour (and optionally the second colour) that strokes and fills use by default. Setting color goes back to one colour from gradient paint (set_paint) or effect paint (set_effect_paint). Editor state, not an undo step. Returns both colours.",
11431
+ description: "Sets the paint colour (and optionally the second colour) that strokes and fills use by default, and whether it is locked. Choosing a brush with a default colour (set_brush id, e.g. the highlighter's yellow) makes that the paint colour unless the colour is locked; a brush with colour.own always paints its own. Setting color goes back to one colour from gradient paint (set_paint) or effect paint (set_effect_paint). Editor state, not an undo step. Returns both colours and locked.",
11137
11432
  inputSchema: {
11138
11433
  type: "object",
11139
- properties: { color: { ...COLOR3, description: "Paint colour #rrggbb." }, secondaryColor: { ...COLOR3, description: "Second colour #rrggbb." } },
11434
+ properties: {
11435
+ color: { ...COLOR3, description: "Paint colour #rrggbb." },
11436
+ secondaryColor: { ...COLOR3, description: "Second colour #rrggbb." },
11437
+ locked: { type: "boolean", description: "Lock the paint colour: brushes chosen later keep it instead of taking their default colour (default: unchanged; the editor starts unlocked)." }
11438
+ },
11140
11439
  additionalProperties: false
11141
11440
  },
11142
11441
  annotations: { title: "Set the paint colour", ...SET4 },
11143
- handler: ({ color, secondaryColor }) => {
11442
+ handler: ({ color, secondaryColor, locked }) => {
11443
+ if (color === void 0 && secondaryColor === void 0 && locked === void 0) throw new Error("Pass color, secondaryColor or locked.");
11144
11444
  if (color !== void 0) c.setColor(color);
11145
11445
  if (secondaryColor !== void 0) c.setSecondaryColor(secondaryColor);
11446
+ if (locked !== void 0) c.setColorLocked(locked);
11146
11447
  const s = c.getState();
11147
- return { color: s.color, secondaryColor: s.secondaryColor };
11448
+ return { color: s.color, secondaryColor: s.secondaryColor, locked: s.colorLocked };
11148
11449
  }
11149
11450
  });
11150
11451
  add({
@@ -11226,7 +11527,10 @@ function createPaintEditorToolProvider(controller, options = {}) {
11226
11527
  await c.wetIdle();
11227
11528
  const base = a.brushId ? c.getBrush(a.brushId) : c.getState().brush;
11228
11529
  const brush = a.brush ? c.brushChanges(a.brush, a.brushId) : void 0;
11229
- if ((brush?.engine ?? base?.engine) === "sticker") await c.prepareStickers(brush?.sticker?.stamps ?? base?.sticker.stamps ?? []);
11530
+ if (base) {
11531
+ const refs = brushStickerRefs(normalizePaintBrush({ ...brush ?? {}, id: base.id }, base));
11532
+ if (refs.length) await c.prepareStickers(refs);
11533
+ }
11230
11534
  const stroke = { points: a.points, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed, snapToPerspective: a.snapToPerspective };
11231
11535
  const r = await act({ kind: "stroke", stroke }, () => c.paintStroke(stroke));
11232
11536
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
@@ -11603,6 +11907,7 @@ function strokeSummary(s, withPoints, live) {
11603
11907
  function createPaintBrushLibraryToolProvider(controller, options = {}) {
11604
11908
  return createBrushLibraryToolProvider(controller.brushLibrary, {
11605
11909
  profile: PAINT_PROFILE,
11910
+ stickers: () => controller.stickers.provider,
11606
11911
  ...options,
11607
11912
  renderPreview: (brush, { width, height, color }) => {
11608
11913
  const t = renderBrushThumbnail(resolveBrush(brush), { paper: controller.getState().document.paper, width, height, color: color.toLowerCase() });
@@ -12405,6 +12710,392 @@ function createPaintEditorDocument(controller, options = {}) {
12405
12710
  readOnly: () => controller.busyReason() ?? false
12406
12711
  });
12407
12712
  }
12713
+
12714
+ // src/paint-editor/collaboration.ts
12715
+ var LAYER_FIELDS = ["name", "visible", "opacity", "blend"];
12716
+ function createPaintChangeFeed(controller, options = {}) {
12717
+ const now = options.now ?? (() => Date.now());
12718
+ const listeners = /* @__PURE__ */ new Set();
12719
+ let seq = 0;
12720
+ let pending;
12721
+ const take = () => {
12722
+ const state = controller.getState();
12723
+ const doc = state.document;
12724
+ const layers = /* @__PURE__ */ new Map();
12725
+ for (const l of controller.listLayers()) layers.set(l.id, { name: l.name, visible: l.visible, opacity: l.opacity, blend: l.blend });
12726
+ return { doc, strokes: new Map(doc.strokes.map((s) => [s.id, s])), events: doc.paintEvents?.length ?? 0, layers, stroking: state.stroking };
12727
+ };
12728
+ let last = take();
12729
+ const check = () => {
12730
+ const state = controller.getState();
12731
+ if (state.document === last.doc && state.stroking === last.stroking) return null;
12732
+ const next = take();
12733
+ const addedStrokes = [...next.strokes.values()].filter((s) => !last.strokes.has(s.id));
12734
+ const removedStrokes = [...last.strokes.values()].filter((s) => !next.strokes.has(s.id));
12735
+ const allEvents = state.document.paintEvents ?? [];
12736
+ const events = next.events > last.events ? allEvents.slice(last.events) : [];
12737
+ const addedLayers = [];
12738
+ const changedLayers = [];
12739
+ for (const [id, l] of next.layers) {
12740
+ const before = last.layers.get(id);
12741
+ if (!before) addedLayers.push({ id, name: String(l.name) });
12742
+ else {
12743
+ const fields = LAYER_FIELDS.filter((f) => before[f] !== l[f]);
12744
+ if (fields.length) changedLayers.push({ id, name: String(l.name), fields });
12745
+ }
12746
+ }
12747
+ const removedLayers = [...last.layers].filter(([id]) => !next.layers.has(id)).map(([id, l]) => ({ id, name: String(l.name) }));
12748
+ const gestureStarted = next.stroking && !last.stroking;
12749
+ const gestureEnded = !next.stroking && last.stroking;
12750
+ last = next;
12751
+ const edited = addedStrokes.length + removedStrokes.length + events.length + addedLayers.length + removedLayers.length + changedLayers.length > 0;
12752
+ if (!edited && !gestureStarted && !gestureEnded) return null;
12753
+ const commit = edited ? pending : void 0;
12754
+ if (edited) pending = void 0;
12755
+ const change = {
12756
+ seq: ++seq,
12757
+ origin: commit?.origin ?? "user",
12758
+ ...commit ? { commit } : {},
12759
+ addedStrokes,
12760
+ removedStrokes,
12761
+ events,
12762
+ addedLayers,
12763
+ removedLayers,
12764
+ changedLayers,
12765
+ inGesture: next.stroking,
12766
+ ...gestureStarted ? { gestureStarted: true } : {},
12767
+ ...gestureEnded ? { gestureEnded: true } : {},
12768
+ at: now()
12769
+ };
12770
+ listeners.forEach((l) => l(change));
12771
+ return change;
12772
+ };
12773
+ const offCommit = controller.onCommit((event) => {
12774
+ pending = event;
12775
+ });
12776
+ const offState = controller.subscribe(() => {
12777
+ check();
12778
+ });
12779
+ return {
12780
+ check,
12781
+ subscribe(listener) {
12782
+ listeners.add(listener);
12783
+ return () => {
12784
+ listeners.delete(listener);
12785
+ };
12786
+ },
12787
+ reset() {
12788
+ last = take();
12789
+ pending = void 0;
12790
+ },
12791
+ dispose() {
12792
+ offCommit();
12793
+ offState();
12794
+ listeners.clear();
12795
+ }
12796
+ };
12797
+ }
12798
+ var HEX4 = /^#[0-9a-f]{6}$/i;
12799
+ var DRY_ENGINES = /* @__PURE__ */ new Set(["dab", "particle", "bristle", "mixer"]);
12800
+ function suggestionBrushRefusal(brush) {
12801
+ if (!DRY_ENGINES.has(brush.engine)) return `The ${brush.name} brush (${brush.engine}) can't paint a suggestion: suggestions are dry paint on a layer of their own. Use a dab, particle, bristle or mixer brush.`;
12802
+ if (brush.behavior.pickup > 0) return `The ${brush.name} brush picks up paint, which a suggestion on its own layer can't; choose a brush without pickup.`;
12803
+ return null;
12804
+ }
12805
+ function createPaintProposals(options) {
12806
+ const { controller } = options;
12807
+ const now = options.now ?? (() => Date.now());
12808
+ const maxPoints = options.maxPoints ?? 4e3;
12809
+ let seq = 0;
12810
+ const makeId = options.idGenerator ?? (() => `paint-proposal-${++seq}`);
12811
+ let state = { proposals: [] };
12812
+ const listeners = /* @__PURE__ */ new Set();
12813
+ const set = (proposals) => {
12814
+ state = { proposals };
12815
+ listeners.forEach((l) => l(state));
12816
+ };
12817
+ const find = (id) => {
12818
+ const p = state.proposals.find((x) => x.id === id);
12819
+ if (!p) throw new PaintEditorError(`No pending paint proposal "${id}". Call list_paint_proposals for the ids.`);
12820
+ return p;
12821
+ };
12822
+ const strokeIds = () => new Set(controller.getStrokes().map((s) => s.id));
12823
+ const validate = (input) => {
12824
+ if (!input.title?.trim()) throw new PaintEditorError("A proposal needs a title: what it adds, in a few words.");
12825
+ if (!Array.isArray(input.ops) || !input.ops.length) throw new PaintEditorError("A proposal needs at least one op (stroke, wash or echo).");
12826
+ const place = input.place ?? "top";
12827
+ if (place === "above") {
12828
+ if (!input.layerId) throw new PaintEditorError('place "above" needs layerId: the layer to put the suggestion above (list_layers).');
12829
+ if (!controller.listLayers().some((l) => l.id === input.layerId)) throw new PaintEditorError(`Unknown layer "${input.layerId}". Call list_layers for the ids.`);
12830
+ }
12831
+ const known = strokeIds();
12832
+ let points = 0;
12833
+ input.ops.forEach((op, i) => {
12834
+ const at = `ops[${i}]`;
12835
+ if (op.kind === "stroke") {
12836
+ if (!op.points?.length) throw new PaintEditorError(`${at}: a stroke needs points in document pixels.`);
12837
+ points += op.points.length;
12838
+ if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
12839
+ const brush = op.brushId ? controller.getBrush(op.brushId) : controller.getState().brush;
12840
+ if (!brush) throw new PaintEditorError(`${at}: unknown brush "${op.brushId}". Call list_brush_presets for the ids.`);
12841
+ const refusal = suggestionBrushRefusal(brush);
12842
+ if (refusal) throw new PaintEditorError(`${at}: ${refusal}`);
12843
+ if (op.size !== void 0 && !(op.size > 0)) throw new PaintEditorError(`${at}: size must be above 0 (document pixels).`);
12844
+ } else if (op.kind === "wash") {
12845
+ if (!HEX4.test(op.color ?? "")) throw new PaintEditorError(`${at}: a wash needs color as #rrggbb.`);
12846
+ if (op.opacity !== void 0 && !(op.opacity > 0 && op.opacity <= 1)) throw new PaintEditorError(`${at}: opacity must be in (0, 1].`);
12847
+ } else if (op.kind === "echo") {
12848
+ if (!op.strokeIds?.length) throw new PaintEditorError(`${at}: an echo needs strokeIds (list_strokes).`);
12849
+ const unknown = op.strokeIds.filter((id) => !known.has(id));
12850
+ if (unknown.length) throw new PaintEditorError(`${at}: unknown stroke ${unknown.map((u) => `"${u}"`).join(", ")}. Call list_strokes for the ids.`);
12851
+ if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
12852
+ if (op.repeat !== void 0 && !(Number.isInteger(op.repeat) && op.repeat >= 1 && op.repeat <= 50)) throw new PaintEditorError(`${at}: repeat must be a whole number from 1 to 50.`);
12853
+ for (const id of op.strokeIds) {
12854
+ const stroke = controller.getStrokes().find((s) => s.id === id);
12855
+ const refusal = suggestionBrushRefusal(stroke.brush);
12856
+ if (refusal) throw new PaintEditorError(`${at}: stroke "${id}": ${refusal}`);
12857
+ points += stroke.points.length * (op.repeat ?? 1);
12858
+ }
12859
+ } else {
12860
+ throw new PaintEditorError(`${at}: unknown kind "${op.kind}"; use stroke, wash or echo.`);
12861
+ }
12862
+ });
12863
+ if (points > maxPoints) throw new PaintEditorError(`The proposal paints ${points} points; the most is ${maxPoints}. Suggest less, or echo fewer strokes.`);
12864
+ };
12865
+ const paint = (ops, layerId) => {
12866
+ const before = strokeIds();
12867
+ for (const op of ops) {
12868
+ if (op.kind === "stroke") {
12869
+ controller.paintStroke({
12870
+ layerId,
12871
+ points: op.points,
12872
+ ...op.brushId ? { brushId: op.brushId } : {},
12873
+ ...op.color ? { color: op.color } : {},
12874
+ ...op.size ? { brush: { tip: { size: op.size } } } : {},
12875
+ stencils: false
12876
+ });
12877
+ } else if (op.kind === "wash") {
12878
+ controller.fillLayer({ layerId, color: op.color, opacity: op.opacity ?? 0.35, grain: op.grain ?? 0.4 });
12879
+ } else {
12880
+ const { kind: _kind, strokeIds: ids, color, repeat, ...variation } = op;
12881
+ controller.replayStrokes({ strokeIds: ids, layerId, ...color ? { color } : {}, ...repeat ? { repeat } : {}, ...variation });
12882
+ }
12883
+ }
12884
+ return controller.getStrokes().map((s) => s.id).filter((id) => !before.has(id));
12885
+ };
12886
+ const missing = (id) => {
12887
+ const p = state.proposals.find((x) => x.id === id);
12888
+ if (!p) return [];
12889
+ const known = strokeIds();
12890
+ return p.anchors.filter((a) => !known.has(a));
12891
+ };
12892
+ const placementOf = (input) => ({
12893
+ place: input.place ?? "top",
12894
+ ...input.layerId ? { layerId: input.layerId } : {},
12895
+ ...input.layerName ? { layerName: input.layerName } : {}
12896
+ });
12897
+ const topLayerId = () => controller.listLayers().find((l) => l.parentId === null)?.id;
12898
+ return {
12899
+ getState: () => state,
12900
+ subscribe(listener) {
12901
+ listeners.add(listener);
12902
+ return () => {
12903
+ listeners.delete(listener);
12904
+ };
12905
+ },
12906
+ propose(input) {
12907
+ validate(input);
12908
+ const placement = placementOf(input);
12909
+ const preview = controller.previewPaint(
12910
+ (layerId) => {
12911
+ paint(input.ops, layerId);
12912
+ },
12913
+ placement.place === "bottom" ? { bottom: true } : { aboveId: placement.place === "above" ? placement.layerId : topLayerId() }
12914
+ );
12915
+ const proposal = {
12916
+ id: makeId(),
12917
+ title: input.title.trim().slice(0, 120),
12918
+ ...input.rationale ? { rationale: input.rationale } : {},
12919
+ origin: input.origin ?? options.origin ?? "agent:anonymous",
12920
+ ops: input.ops.map((op) => ({ ...op })),
12921
+ placement,
12922
+ bounds: preview.bounds,
12923
+ ghost: preview.composite,
12924
+ anchors: [...new Set(input.ops.flatMap((op) => op.kind === "echo" ? op.strokeIds : []))],
12925
+ createdAt: now()
12926
+ };
12927
+ set([...state.proposals, proposal]);
12928
+ return proposal;
12929
+ },
12930
+ accept(id) {
12931
+ const p = find(id);
12932
+ const gone = missing(id);
12933
+ if (gone.length) throw new PaintEditorError(`Paint proposal "${p.title}" echoes strokes that are no longer on the canvas (${gone.join(", ")}); dismiss it or propose it again.`);
12934
+ const placement = p.placement;
12935
+ if (placement.place === "above" && !controller.listLayers().some((l) => l.id === placement.layerId)) {
12936
+ throw new PaintEditorError(`Paint proposal "${p.title}" goes above layer "${placement.layerId}", which is gone; dismiss it or propose it again.`);
12937
+ }
12938
+ const active = controller.getState().document.activeLayerId;
12939
+ const result = controller.withOrigin(
12940
+ p.origin,
12941
+ () => controller.group(p.title, () => {
12942
+ const layer = controller.addLayer({ name: placement.layerName ?? p.title, ...placement.place === "above" ? { aboveId: placement.layerId } : placement.place === "top" ? { aboveId: topLayerId() } : {} });
12943
+ if (placement.place === "bottom") controller.moveLayer(layer.id, 0, null);
12944
+ return { layerId: layer.id, strokeIds: paint(p.ops, layer.id) };
12945
+ })
12946
+ );
12947
+ if (active && controller.listLayers().some((l) => l.id === active)) controller.setActiveLayer(active);
12948
+ set(state.proposals.filter((x) => x.id !== id));
12949
+ return { proposalId: id, layerId: result.layerId, strokeIds: result.strokeIds, label: p.title };
12950
+ },
12951
+ reject(id) {
12952
+ const p = find(id);
12953
+ set(state.proposals.filter((x) => x.id !== id));
12954
+ return p;
12955
+ },
12956
+ clear(origin) {
12957
+ const dropped = state.proposals.filter((p) => !origin || p.origin === origin);
12958
+ if (dropped.length) set(state.proposals.filter((p) => !dropped.includes(p)));
12959
+ return dropped.map((p) => p.id);
12960
+ },
12961
+ get: (id) => state.proposals.find((p) => p.id === id),
12962
+ missing
12963
+ };
12964
+ }
12965
+ function paintProposalGhost(proposal) {
12966
+ const b = proposal.bounds;
12967
+ if (!b || !b.width || !b.height || proposal.ghost.length !== b.width * b.height * 4) return null;
12968
+ return { x: b.x, y: b.y, width: b.width, height: b.height, data: proposal.ghost };
12969
+ }
12970
+
12971
+ // src/paint-editor/collaboration-tools.ts
12972
+ var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
12973
+ var WRITE6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
12974
+ var proposalIdArg = {
12975
+ type: "object",
12976
+ properties: { proposalId: { type: "string", minLength: 1, description: "Id of a pending paint proposal, from propose_paint or list_paint_proposals." } },
12977
+ required: ["proposalId"],
12978
+ additionalProperties: false
12979
+ };
12980
+ var hex = { type: "string", pattern: "^#[0-9a-fA-F]{6}$" };
12981
+ var opSchema = {
12982
+ type: "object",
12983
+ description: `One thing to paint. kind "stroke": a stroke along points with a dry brush. kind "wash": an even wash over the suggestion's layer (a ground, a glaze). kind "echo": strokes already on the canvas painted again, moved, turned, scaled or mirrored (grass along a horizon, rain, hatching).`,
12984
+ properties: {
12985
+ kind: { type: "string", enum: ["stroke", "wash", "echo"], description: "What this op paints (see above)." },
12986
+ points: {
12987
+ type: "array",
12988
+ minItems: 1,
12989
+ description: "stroke: pen samples in document pixels, in drawing order.",
12990
+ items: {
12991
+ type: "object",
12992
+ properties: {
12993
+ x: { type: "number", description: "Document x in pixels." },
12994
+ y: { type: "number", description: "Document y in pixels." },
12995
+ pressure: { type: "number", minimum: 0, maximum: 1, description: "Pen pressure 0..1 (default 0.5)." }
12996
+ },
12997
+ required: ["x", "y"],
12998
+ additionalProperties: false
12999
+ }
13000
+ },
13001
+ brushId: { type: "string", description: "stroke: a dry brush id from list_brush_presets (dab, particle, bristle or mixer without pickup). Default the editor brush." },
13002
+ size: { type: "number", exclusiveMinimum: 0, description: "stroke: brush size in document pixels. Default the brush's." },
13003
+ color: { ...hex, description: "stroke and echo: #rrggbb (stroke default the editor colour; echo default each stroke's colour). wash: required." },
13004
+ opacity: { type: "number", exclusiveMinimum: 0, maximum: 1, description: "wash: 0..1 (default 0.35)." },
13005
+ grain: { type: "number", minimum: 0, maximum: 1, description: "wash: how much it catches the paper grain, 0..1 (default 0.4)." },
13006
+ strokeIds: { type: "array", minItems: 1, items: { type: "string" }, description: "echo: ids of strokes on the canvas (list_strokes)." },
13007
+ dx: { type: "number", description: "echo: move right by this many document pixels (default 0)." },
13008
+ dy: { type: "number", description: "echo: move down by this many document pixels (default 0)." },
13009
+ rotate: { type: "number", description: "echo: turn this many degrees clockwise about the strokes' centre (default 0)." },
13010
+ scale: { type: "number", exclusiveMinimum: 0, description: "echo: scale about the centre; brush size scales too (default 1)." },
13011
+ flipX: { type: "boolean", description: "echo: mirror horizontally about the centre (default false)." },
13012
+ repeat: { type: "integer", minimum: 1, maximum: 50, description: "echo: repeat the move this many times, each on top of the last (default 1)." }
13013
+ },
13014
+ required: ["kind"],
13015
+ additionalProperties: false
13016
+ };
13017
+ function createPaintProposalToolProvider(proposals, options = {}) {
13018
+ const readOnlyReason = () => {
13019
+ const ro = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
13020
+ return ro ? typeof ro === "string" ? ro : "The painting is read-only: paint proposals can be made and rejected but not accepted." : null;
13021
+ };
13022
+ const describe = (p) => ({
13023
+ id: p.id,
13024
+ title: p.title,
13025
+ ...p.rationale ? { rationale: p.rationale } : {},
13026
+ origin: p.origin,
13027
+ ops: p.ops.map((op) => op.kind),
13028
+ placement: p.placement,
13029
+ bounds: p.bounds,
13030
+ anchors: p.anchors,
13031
+ missing: proposals.missing(p.id),
13032
+ createdAt: new Date(p.createdAt).toISOString()
13033
+ });
13034
+ const proposeTool = {
13035
+ name: "propose_paint",
13036
+ title: "Propose paint",
13037
+ description: "Suggests paint without painting it: the user sees it as a ghost over the canvas and accepts or dismisses it, so use it for contributions they did not ask for. Accepted, it is painted on a new layer of its own (on top by default, at the bottom for a ground, or above a layer) as one undo step recorded as yours. Prefer what agents do well: a wash or ground, an echo of the user's strokes, a few deliberate strokes. Dry brushes only. Returns the proposal id, where the ghost sits (bounds, document pixels) and the strokes it builds on. Follow with accept_paint_proposal only when the user asked for it.",
13038
+ inputSchema: {
13039
+ type: "object",
13040
+ properties: {
13041
+ title: { type: "string", minLength: 1, maxLength: 120, description: `What the suggestion adds, in a few words, e.g. "Warm ground behind the figure". The undo step's name once accepted.` },
13042
+ rationale: { type: "string", description: "Why it fits what the user is painting, in one sentence. Shown beside the ghost." },
13043
+ ops: { type: "array", minItems: 1, items: opSchema, description: "What to paint, in order, onto the suggestion's layer." },
13044
+ place: { type: "string", enum: ["top", "bottom", "above"], description: "Where the new layer goes: top (default), bottom (behind everything: grounds and washes), or above layerId." },
13045
+ layerId: { type: "string", description: 'With place "above": the layer to put it above (list_layers).' },
13046
+ layerName: { type: "string", description: "The new layer's name (default the title)." }
13047
+ },
13048
+ required: ["title", "ops"],
13049
+ additionalProperties: false
13050
+ },
13051
+ annotations: { title: "Propose paint", ...WRITE6 },
13052
+ handler: (args, context) => {
13053
+ const origin = context.actor?.id ? `agent:${context.actor.id}` : options.fallbackOrigin;
13054
+ const proposal = proposals.propose({ ...args, ...origin ? { origin } : {} });
13055
+ const { id: _id, missing: _missing, createdAt: _createdAt, ...rest } = describe(proposal);
13056
+ return { proposalId: proposal.id, ...rest };
13057
+ }
13058
+ };
13059
+ const listTool = {
13060
+ name: "list_paint_proposals",
13061
+ title: "List paint proposals",
13062
+ description: "Lists the pending paint proposals (ghost paint not yet on the canvas): id, title, rationale, who proposed it, its op kinds, where it goes, where the ghost sits, the strokes it echoes and any of those since removed (missing: it can no longer be accepted), and whether accepting is allowed. Call before accept_paint_proposal or reject_paint_proposal.",
13063
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13064
+ annotations: { title: "List paint proposals", ...READ7 },
13065
+ handler: () => ({ proposals: proposals.getState().proposals.map(describe), readOnly: readOnlyReason() })
13066
+ };
13067
+ const acceptTool = {
13068
+ name: "accept_paint_proposal",
13069
+ title: "Accept a paint proposal",
13070
+ description: "Paints a pending proposal on a new layer as one undo step, recorded as made by whoever proposed it, and drops it from the pending list; the user's active layer stays theirs. Refuses, changing nothing, when the painting is read-only or a stroke it echoes was removed. Returns the new layer id, the stroke ids painted and the undo step name.",
13071
+ inputSchema: proposalIdArg,
13072
+ annotations: { title: "Accept a paint proposal", ...WRITE6 },
13073
+ handler: (args) => {
13074
+ const reason = readOnlyReason();
13075
+ if (reason) throw new Error(reason);
13076
+ return proposals.accept(args.proposalId);
13077
+ }
13078
+ };
13079
+ const rejectTool = {
13080
+ name: "reject_paint_proposal",
13081
+ title: "Reject a paint proposal",
13082
+ description: "Dismisses a pending paint proposal: its ghost disappears and the painting and its history are unchanged. Use it to withdraw a suggestion that no longer fits. Returns the dismissed proposal id and title.",
13083
+ inputSchema: proposalIdArg,
13084
+ annotations: { title: "Reject a paint proposal", ...WRITE6 },
13085
+ handler: (args) => {
13086
+ const p = proposals.reject(args.proposalId);
13087
+ return { proposalId: p.id, title: p.title, rejected: true };
13088
+ }
13089
+ };
13090
+ return {
13091
+ id: options.id ?? "paint-proposals",
13092
+ ...options.namespace !== void 0 ? { namespace: options.namespace } : {},
13093
+ title: "Paint proposals",
13094
+ description: "Suggest paint as a ghost the user accepts or dismisses, and accept or reject pending suggestions.",
13095
+ skill: "pixerate-paint-editor",
13096
+ tools: [proposeTool, listTool, acceptTool, rejectTool]
13097
+ };
13098
+ }
12408
13099
  export {
12409
13100
  BACKLIGHT_SPILL,
12410
13101
  BLEND_MENU_GROUPS,
@@ -12429,6 +13120,7 @@ export {
12429
13120
  DEFAULT_PAINT_GRADIENT_MAPPING,
12430
13121
  DEFAULT_PAPER,
12431
13122
  DEFAULT_PARTICLE,
13123
+ DEFAULT_SPRAY,
12432
13124
  DEFAULT_STICKER,
12433
13125
  DEFAULT_SWATCHES,
12434
13126
  DEFAULT_VIEW,
@@ -12565,12 +13257,15 @@ export {
12565
13257
  composeAffine,
12566
13258
  contactSheet,
12567
13259
  createPaintBrushLibraryToolProvider,
13260
+ createPaintChangeFeed,
12568
13261
  createPaintCompositor,
12569
13262
  createPaintDocument,
12570
13263
  createPaintEditorDocument,
12571
13264
  createPaintEditorToolProvider,
12572
13265
  createPaintEffectsToolProvider,
12573
13266
  createPaintLayer,
13267
+ createPaintProposalToolProvider,
13268
+ createPaintProposals,
12574
13269
  createPaintReplayTools,
12575
13270
  createPaintStickerTools,
12576
13271
  createPaintThickTools,
@@ -12655,6 +13350,7 @@ export {
12655
13350
  padSampleRadius,
12656
13351
  paintGradientMappingFor,
12657
13352
  paintPlaybackPanel,
13353
+ paintProposalGhost,
12658
13354
  paintSourceColor,
12659
13355
  paintSourceCss,
12660
13356
  paintToMultiColor,