@pixerate/editor 0.108.0 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/animation/index.cjs +5 -5
  2. package/dist/animation/index.cjs.map +1 -1
  3. package/dist/animation/index.js +9 -9
  4. package/dist/animation-editor/index.cjs +59 -59
  5. package/dist/animation-editor/index.cjs.map +1 -1
  6. package/dist/animation-editor/index.d.cts +2 -1
  7. package/dist/animation-editor/index.d.ts +2 -1
  8. package/dist/animation-editor/index.js +29 -29
  9. package/dist/blending-DXl06NSx.d.ts +486 -0
  10. package/dist/blending-aOE6Y3UE.d.cts +486 -0
  11. package/dist/canvas/index.cjs +27 -27
  12. package/dist/canvas/index.cjs.map +1 -1
  13. package/dist/canvas/index.js +11 -11
  14. package/dist/{chunk-2FGRTYJN.cjs → chunk-477UVMNR.cjs} +3 -3
  15. package/dist/{chunk-2FGRTYJN.cjs.map → chunk-477UVMNR.cjs.map} +1 -1
  16. package/dist/chunk-7VOHOPLW.cjs +1136 -0
  17. package/dist/chunk-7VOHOPLW.cjs.map +1 -0
  18. package/dist/{chunk-HUQ7EHVI.js → chunk-BLWCAQCW.js} +723 -1975
  19. package/dist/chunk-BLWCAQCW.js.map +1 -0
  20. package/dist/{chunk-SOROZ2BF.js → chunk-DMRSJBJF.js} +1939 -318
  21. package/dist/chunk-DMRSJBJF.js.map +1 -0
  22. package/dist/chunk-FTBZPI77.cjs +1 -0
  23. package/dist/chunk-FTBZPI77.cjs.map +1 -0
  24. package/dist/{chunk-WMBIJCHA.js → chunk-HNS7UHCM.js} +5 -1
  25. package/dist/{chunk-WMBIJCHA.js.map → chunk-HNS7UHCM.js.map} +1 -1
  26. package/dist/chunk-JLYX4V57.cjs +2001 -0
  27. package/dist/chunk-JLYX4V57.cjs.map +1 -0
  28. package/dist/chunk-KB2YBCVS.js +2001 -0
  29. package/dist/chunk-KB2YBCVS.js.map +1 -0
  30. package/dist/{chunk-G4BIYE6R.js → chunk-LQEEDHOP.js} +40 -2
  31. package/dist/chunk-LQEEDHOP.js.map +1 -0
  32. package/dist/{chunk-FRD3FUXI.cjs → chunk-MGL4U5WA.cjs} +2144 -523
  33. package/dist/chunk-MGL4U5WA.cjs.map +1 -0
  34. package/dist/{chunk-T3QZ6S47.cjs → chunk-POGBUYWE.cjs} +131 -93
  35. package/dist/chunk-POGBUYWE.cjs.map +1 -0
  36. package/dist/chunk-VKK5VS2T.js +1 -0
  37. package/dist/{chunk-UTEVPQTS.cjs → chunk-W3GXSUSQ.cjs} +746 -1998
  38. package/dist/chunk-W3GXSUSQ.cjs.map +1 -0
  39. package/dist/{chunk-SZ6KMZNM.cjs → chunk-XIOMMGPD.cjs} +6 -2
  40. package/dist/chunk-XIOMMGPD.cjs.map +1 -0
  41. package/dist/{chunk-JK6M6V7G.js → chunk-Z3YWVUWT.js} +4 -4
  42. package/dist/{chunk-3PHA3UKI.js → chunk-Z4BC6M74.js} +523 -121
  43. package/dist/chunk-Z4BC6M74.js.map +1 -0
  44. package/dist/{export-a9bnF7Cl.d.ts → export-CO2ryu3I.d.ts} +419 -18
  45. package/dist/{export-JGWQEPJn.d.cts → export-DRiLzP_j.d.cts} +419 -18
  46. package/dist/image-editor/index.cjs +1665 -1113
  47. package/dist/image-editor/index.cjs.map +1 -1
  48. package/dist/image-editor/index.d.cts +78 -14
  49. package/dist/image-editor/index.d.ts +78 -14
  50. package/dist/image-editor/index.js +843 -291
  51. package/dist/image-editor/index.js.map +1 -1
  52. package/dist/paint/index.cjs +28 -4
  53. package/dist/paint/index.cjs.map +1 -1
  54. package/dist/paint/index.d.cts +3 -89
  55. package/dist/paint/index.d.ts +3 -89
  56. package/dist/paint/index.js +27 -3
  57. package/dist/paint-editor/index.cjs +1056 -287
  58. package/dist/paint-editor/index.cjs.map +1 -1
  59. package/dist/paint-editor/index.d.cts +304 -22
  60. package/dist/paint-editor/index.d.ts +304 -22
  61. package/dist/paint-editor/index.js +852 -83
  62. package/dist/paint-editor/index.js.map +1 -1
  63. package/dist/raster/index.cjs +7 -3
  64. package/dist/raster/index.cjs.map +1 -1
  65. package/dist/raster/index.d.cts +3 -129
  66. package/dist/raster/index.d.ts +3 -129
  67. package/dist/raster/index.js +6 -2
  68. package/dist/{compositor-D92URMlm.d.cts → render-BEriFuqK.d.cts} +136 -2
  69. package/dist/{compositor-Kt_M4ruo.d.ts → render-tjzKaik-.d.ts} +136 -2
  70. package/dist/{surface-CkKOHGPm.d.cts → surface-C8RRg_Bh.d.cts} +109 -1
  71. package/dist/{surface-CkKOHGPm.d.ts → surface-C8RRg_Bh.d.ts} +109 -1
  72. package/dist/{svg-SseJTIrA.d.ts → svg-D_cfkhlB.d.cts} +3 -629
  73. package/dist/{svg-ClVIG4T7.d.cts → svg-wIaKLrU0.d.ts} +3 -629
  74. package/dist/types-DtL_qLFz.d.ts +630 -0
  75. package/dist/types-DyQq-WPY.d.cts +630 -0
  76. package/dist/vector/index.cjs +563 -563
  77. package/dist/vector/index.cjs.map +1 -1
  78. package/dist/vector/index.d.cts +4 -2
  79. package/dist/vector/index.d.ts +4 -2
  80. package/dist/vector/index.js +69 -69
  81. package/dist/video-editor/index.cjs +3 -3
  82. package/dist/video-editor/index.js +2 -2
  83. package/package.json +1 -1
  84. package/skills/pixerate-paint-editor/SKILL.md +49 -13
  85. package/dist/chunk-3PHA3UKI.js.map +0 -1
  86. package/dist/chunk-6BU5WU4R.cjs +0 -1
  87. package/dist/chunk-6BU5WU4R.cjs.map +0 -1
  88. package/dist/chunk-DWGZUSES.cjs +0 -694
  89. package/dist/chunk-DWGZUSES.cjs.map +0 -1
  90. package/dist/chunk-FRD3FUXI.cjs.map +0 -1
  91. package/dist/chunk-G4BIYE6R.js.map +0 -1
  92. package/dist/chunk-HUFQD4CB.cjs +0 -734
  93. package/dist/chunk-HUFQD4CB.cjs.map +0 -1
  94. package/dist/chunk-HUQ7EHVI.js.map +0 -1
  95. package/dist/chunk-KKREERP2.js +0 -694
  96. package/dist/chunk-KKREERP2.js.map +0 -1
  97. package/dist/chunk-SOROZ2BF.js.map +0 -1
  98. package/dist/chunk-SZ6KMZNM.cjs.map +0 -1
  99. package/dist/chunk-T3QZ6S47.cjs.map +0 -1
  100. package/dist/chunk-UTEVPQTS.cjs.map +0 -1
  101. package/dist/chunk-XYBJKUAU.js +0 -1
  102. package/dist/mixer-5ziHU8OS.d.cts +0 -282
  103. package/dist/mixer-DNqiYXYQ.d.ts +0 -282
  104. /package/dist/{chunk-XYBJKUAU.js.map → chunk-VKK5VS2T.js.map} +0 -0
  105. /package/dist/{chunk-JK6M6V7G.js.map → chunk-Z3YWVUWT.js.map} +0 -0
@@ -4,9 +4,10 @@ import {
4
4
  resolveExternalAssets,
5
5
  serializeLayers,
6
6
  serializeLayersExternal
7
- } from "../chunk-SOROZ2BF.js";
7
+ } from "../chunk-DMRSJBJF.js";
8
8
  import {
9
9
  RasterCompositor,
10
+ base64ToBytes,
10
11
  bytesToBase64,
11
12
  decodePng,
12
13
  encodePng,
@@ -14,28 +15,39 @@ import {
14
15
  flattenLayers,
15
16
  insertLayer,
16
17
  isLayerLocked,
18
+ isPng,
17
19
  mapLayer,
18
20
  parseDataUrl,
19
21
  pngDataUrl
20
- } from "../chunk-G4BIYE6R.js";
22
+ } from "../chunk-LQEEDHOP.js";
21
23
  import "../chunk-GX332W2C.js";
22
24
  import {
23
25
  CoverageMap,
26
+ DEFAULT_BLENDER,
24
27
  DEFAULT_BRUSH,
25
28
  DEFAULT_MIXER,
29
+ DEFAULT_SMUDGE,
26
30
  EMPTY_MIXER_LOAD,
27
31
  MAX_BRUSH_SIZE,
28
32
  MIXING_MODELS,
29
- MixerStroke,
33
+ PaintExchange,
34
+ SmudgeTip,
35
+ TileMedium,
30
36
  applyStrokeToTile,
37
+ blenderDab,
31
38
  cleanMixer,
39
+ createBlenderScratch,
32
40
  createPigmentTools,
33
41
  dabRect,
42
+ exchangeRates,
34
43
  loadMixer,
44
+ mixerRates,
45
+ normalizeBlender,
35
46
  normalizeBrush,
36
47
  normalizeMixer,
48
+ normalizeSmudge,
37
49
  rasterizeDab
38
- } from "../chunk-3PHA3UKI.js";
50
+ } from "../chunk-Z4BC6M74.js";
39
51
  import {
40
52
  BLEND_MODES,
41
53
  PAINTERLY_BLEND_MODES,
@@ -47,20 +59,26 @@ import {
47
59
  finishTile,
48
60
  isBlendMode,
49
61
  isPainterlyBlendMode,
62
+ mixPigment8,
63
+ mixPigments,
50
64
  readPixel,
51
65
  readRegion,
52
66
  standardBlendMode,
53
67
  surfaceFromPixels,
54
68
  tileKey,
55
- tilesInRect
56
- } from "../chunk-WMBIJCHA.js";
69
+ tilesInRect,
70
+ writeRegion
71
+ } from "../chunk-HNS7UHCM.js";
57
72
  import {
58
73
  PerformanceQueue,
59
74
  PresenceStore,
60
75
  realPresenceClock,
61
76
  tweenPresenceCursor
62
77
  } from "../chunk-B6DSIEXA.js";
63
- import "../chunk-KKREERP2.js";
78
+ import "../chunk-BLWCAQCW.js";
79
+ import "../chunk-YCSFS3GK.js";
80
+ import "../chunk-ZRJMJIQU.js";
81
+ import "../chunk-TGD52FEZ.js";
64
82
 
65
83
  // src/paint-editor/noise.ts
66
84
  function mix32(h) {
@@ -435,8 +453,11 @@ function createPaintDocument(options = {}) {
435
453
  }
436
454
 
437
455
  // src/paint-editor/brushes.ts
438
- var PAINT_BRUSH_ENGINES = ["dab", "eraser", "mixer"];
439
- var PAINT_BRUSH_CATEGORIES = ["pencil", "pastel", "charcoal", "airbrush", "marker", "ink", "eraser", "blender", "smudge", "mixer", "custom"];
456
+ var PAINT_BRUSH_ENGINES = ["dab", "eraser", "mixer", "blender", "smudge"];
457
+ var PAINT_BRUSH_CATEGORIES = ["pencil", "pastel", "charcoal", "airbrush", "marker", "ink", "paint", "eraser", "blender", "smudge", "mixer", "custom"];
458
+ var MULTI_COLOR_MODES = ["off", "tip", "dab"];
459
+ var DEFAULT_MULTI_COLOR = { mode: "off", colors: [], order: "sequence", length: 12, softness: 1 };
460
+ var DEFAULT_BEHAVIOR = { pickup: 0, mix: 0.5, dirty: false, mixing: "pigment", multicolor: DEFAULT_MULTI_COLOR };
440
461
  var BRUSH_ROTATIONS = ["fixed", "stroke", "barrel"];
441
462
  var DEFAULT_DYNAMICS = {
442
463
  minSize: 0,
@@ -469,21 +490,62 @@ function normalizeDynamics(raw = {}, base = DEFAULT_DYNAMICS) {
469
490
  };
470
491
  }
471
492
  var BRUSH_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
493
+ var HEX2 = /^#[0-9a-f]{6}$/i;
494
+ function normalizeMultiColor(raw = {}, base = DEFAULT_MULTI_COLOR) {
495
+ const colors = Array.isArray(raw.colors) ? raw.colors.filter((c) => typeof c === "string" && HEX2.test(c.trim())).map((c) => c.trim().toLowerCase()).slice(0, 8) : base.colors;
496
+ return {
497
+ mode: MULTI_COLOR_MODES.includes(raw.mode) ? raw.mode : base.mode,
498
+ colors: [...colors],
499
+ order: raw.order === "random" || raw.order === "sequence" ? raw.order : base.order,
500
+ length: typeof raw.length === "number" && Number.isFinite(raw.length) ? Math.min(500, Math.max(1, Math.round(raw.length))) : base.length,
501
+ softness: unit2(raw.softness, base.softness)
502
+ };
503
+ }
504
+ function normalizeBehavior(raw = {}, base = DEFAULT_BEHAVIOR) {
505
+ return {
506
+ pickup: unit2(raw.pickup, base.pickup),
507
+ mix: unit2(raw.mix, base.mix),
508
+ dirty: typeof raw.dirty === "boolean" ? raw.dirty : base.dirty,
509
+ mixing: raw.mixing === "rgb" || raw.mixing === "pigment" ? raw.mixing : base.mixing,
510
+ multicolor: normalizeMultiColor(raw.multicolor ?? {}, base.multicolor)
511
+ };
512
+ }
513
+ function isWetCapable(brush) {
514
+ return brush.engine === "dab" || brush.engine === "mixer";
515
+ }
516
+ function usesPaintExchange(brush) {
517
+ return brush.engine === "dab" && (brush.behavior.pickup > 0 || brush.behavior.multicolor.mode !== "off");
518
+ }
472
519
  function normalizePaintBrush(raw = {}, base) {
473
520
  const b = base ?? BASE_BRUSH;
474
521
  const id = typeof raw.id === "string" && BRUSH_ID.test(raw.id) ? raw.id : b.id;
522
+ const engine = PAINT_BRUSH_ENGINES.includes(raw.engine) ? raw.engine : b.engine;
523
+ const rb = raw.behavior ?? {};
524
+ const mixerIn = { ...b.mixer, ...raw.mixer ?? {} };
525
+ if (engine === "mixer") {
526
+ if (rb.pickup !== void 0) mixerIn.wet = rb.pickup;
527
+ if (rb.mix !== void 0) mixerIn.mix = rb.mix;
528
+ if (rb.dirty !== void 0) mixerIn.cleanEachStroke = !rb.dirty;
529
+ if (rb.mixing !== void 0) mixerIn.mixing = rb.mixing;
530
+ }
531
+ const mixer = normalizeMixer(mixerIn);
532
+ let behavior = normalizeBehavior(rb, b.behavior);
533
+ if (engine === "mixer") behavior = { ...behavior, pickup: mixer.wet, mix: mixer.mix, dirty: !mixer.cleanEachStroke, mixing: mixer.mixing };
475
534
  return {
476
535
  id,
477
536
  name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, 80) : b.name,
478
537
  category: PAINT_BRUSH_CATEGORIES.includes(raw.category) ? raw.category : b.category,
479
- engine: PAINT_BRUSH_ENGINES.includes(raw.engine) ? raw.engine : b.engine,
538
+ engine,
480
539
  tip: normalizeBrush({ ...b.tip, ...raw.tip ?? {} }),
481
540
  grain: unit2(raw.grain, b.grain),
482
541
  pressureSmoothing: range(raw.pressureSmoothing, 0, 0.95, b.pressureSmoothing),
483
542
  rotation: BRUSH_ROTATIONS.includes(raw.rotation) ? raw.rotation : b.rotation,
484
543
  dynamics: normalizeDynamics(raw.dynamics ?? {}, b.dynamics),
485
- mixer: normalizeMixer({ ...b.mixer, ...raw.mixer ?? {} }),
544
+ mixer,
486
545
  loadsColor: typeof raw.loadsColor === "boolean" ? raw.loadsColor : b.loadsColor,
546
+ behavior,
547
+ blender: normalizeBlender(raw.blender ?? {}, b.blender),
548
+ smudge: normalizeSmudge(raw.smudge ?? {}, b.smudge),
487
549
  ...raw.builtin ? { builtin: true } : {}
488
550
  };
489
551
  }
@@ -498,7 +560,10 @@ var BASE_BRUSH = {
498
560
  rotation: "fixed",
499
561
  dynamics: { ...DEFAULT_DYNAMICS },
500
562
  mixer: { ...DEFAULT_MIXER },
501
- loadsColor: true
563
+ loadsColor: true,
564
+ behavior: { ...DEFAULT_BEHAVIOR, multicolor: { ...DEFAULT_MULTI_COLOR, colors: [] } },
565
+ blender: { ...DEFAULT_BLENDER },
566
+ smudge: { ...DEFAULT_SMUDGE }
502
567
  };
503
568
  var builtin = (init) => ({ ...normalizePaintBrush(init), builtin: true });
504
569
  var BUILTIN_BRUSHES = [
@@ -612,19 +677,54 @@ var BUILTIN_BRUSHES = [
612
677
  id: "blender",
613
678
  name: "Blender",
614
679
  category: "blender",
615
- engine: "mixer",
680
+ engine: "blender",
616
681
  loadsColor: false,
617
- tip: { size: 34, hardness: 0.4, spacing: 0.08, opacity: 1, flow: 0.35, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
618
- mixer: { wet: 0.7, load: 0, mix: 1, loadEachStroke: false, cleanEachStroke: true, mixing: "pigment" }
682
+ tip: { size: 34, hardness: 0.4, spacing: 0.1, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
683
+ blender: { strength: 0.5, distance: 0.35, mixing: "pigment" }
619
684
  }),
620
685
  builtin({
621
686
  id: "smudge",
622
687
  name: "Smudge",
623
688
  category: "smudge",
624
- engine: "mixer",
689
+ engine: "smudge",
625
690
  loadsColor: false,
626
- tip: { size: 26, hardness: 0.6, spacing: 0.06, opacity: 1, flow: 0.8, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
627
- mixer: { wet: 1, load: 0, mix: 1, loadEachStroke: false, cleanEachStroke: true, mixing: "rgb" }
691
+ tip: { size: 26, hardness: 0.6, spacing: 0.06, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
692
+ smudge: { strength: 0.75, length: 0.85, mixing: "rgb" }
693
+ }),
694
+ builtin({
695
+ id: "wet-smudge",
696
+ name: "Pigment smudge",
697
+ category: "smudge",
698
+ engine: "smudge",
699
+ loadsColor: false,
700
+ tip: { size: 30, hardness: 0.5, spacing: 0.06, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: true, pressureOpacity: true },
701
+ smudge: { strength: 0.7, length: 0.9, mixing: "pigment" },
702
+ dynamics: { minSize: 0.6 }
703
+ }),
704
+ builtin({
705
+ id: "dirty-round",
706
+ name: "Dirty round",
707
+ category: "paint",
708
+ tip: { size: 22, hardness: 0.65, spacing: 0.06, opacity: 1, flow: 0.7, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
709
+ behavior: { pickup: 0.55, mix: 0.6, dirty: true, mixing: "pigment" },
710
+ dynamics: { minSize: 0.5 }
711
+ }),
712
+ builtin({
713
+ id: "multicolor-flat",
714
+ name: "Multi-colour flat",
715
+ category: "paint",
716
+ rotation: "stroke",
717
+ tip: { size: 30, hardness: 0.75, spacing: 0.05, angle: 90, roundness: 0.3, opacity: 1, flow: 0.85, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
718
+ behavior: { pickup: 0.15, mix: 0.4, mixing: "pigment", multicolor: { mode: "tip", softness: 0.7 } },
719
+ dynamics: { minSize: 0.6 }
720
+ }),
721
+ builtin({
722
+ id: "palette-dabber",
723
+ name: "Palette dabber",
724
+ category: "paint",
725
+ tip: { size: 18, hardness: 0.7, spacing: 0.35, opacity: 1, flow: 0.9, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
726
+ behavior: { multicolor: { mode: "dab", order: "sequence", length: 6, softness: 0.4 } },
727
+ dynamics: { minSize: 0.5, sizeJitter: 0.15, scatter: 0.3 }
628
728
  }),
629
729
  builtin({
630
730
  id: "mixer-brush",
@@ -804,7 +904,9 @@ var PenDabWalker = class {
804
904
  opacity: Math.min(1, Math.max(0, opacity)),
805
905
  angle,
806
906
  roundness: Math.min(1, Math.max(0.01, tip.roundness * (1 - dyn.tiltRoundness * s.lean * 0.8))),
807
- pressure: s.pressure
907
+ pressure: s.pressure,
908
+ direction: this.dir,
909
+ index: i
808
910
  };
809
911
  }
810
912
  push(p, out) {
@@ -935,6 +1037,248 @@ function toStrokePoint(p) {
935
1037
  return { x: p.x, y: p.y, pressure: pressureOf(p), ...typeof p.t === "number" ? { t: p.t } : {} };
936
1038
  }
937
1039
 
1040
+ // src/paint-editor/dab-stroke.ts
1041
+ var GRADIENT_STEPS = 32;
1042
+ function multiColorPalette(mc, color, secondary) {
1043
+ if (mc.colors.length >= 2) {
1044
+ return mc.colors.map((h) => {
1045
+ const n = parseInt(h.slice(1), 16);
1046
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
1047
+ });
1048
+ }
1049
+ return [[...color], [...secondary]];
1050
+ }
1051
+ function soften(f, softness) {
1052
+ if (softness >= 1) return f;
1053
+ const w = Math.max(1e-3, softness);
1054
+ return Math.min(1, Math.max(0, (f - 0.5) / w + 0.5));
1055
+ }
1056
+ function mixRgb(a, b, t, mixing) {
1057
+ if (t <= 0) return [...a];
1058
+ if (t >= 1) return [...b];
1059
+ if (mixing === "pigment") {
1060
+ const o = [0, 0, 0];
1061
+ const q = (v) => Math.round(Math.min(1, Math.max(0, v)) * 255);
1062
+ mixPigment8(q(a[0]), q(a[1]), q(a[2]), q(b[0]), q(b[1]), q(b[2]), t, o);
1063
+ return [o[0], o[1], o[2]];
1064
+ }
1065
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
1066
+ }
1067
+ function multiColorGradient(palette, softness, mixing) {
1068
+ const n = palette.length;
1069
+ const out = [];
1070
+ for (let j = 0; j < GRADIENT_STEPS; j++) {
1071
+ const f = j / (GRADIENT_STEPS - 1) * (n - 1);
1072
+ const i = Math.min(n - 2, Math.floor(f));
1073
+ out.push(mixRgb(palette[i], palette[i + 1], soften(f - i, softness), mixing));
1074
+ }
1075
+ return out;
1076
+ }
1077
+ function multiColorForDab(mc, palette, index, seed, mixing) {
1078
+ const n = palette.length;
1079
+ if (mc.order === "random") {
1080
+ const run = Math.floor(index / mc.length);
1081
+ return [...palette[Math.min(n - 1, Math.floor(rand01(seed, run, 11) * n))]];
1082
+ }
1083
+ const f = index / mc.length % n;
1084
+ const i = Math.floor(f);
1085
+ return mixRgb(palette[i], palette[(i + 1) % n], soften(f - i, mc.softness), mixing);
1086
+ }
1087
+ var PaintDabStroke = class {
1088
+ constructor(brush, options) {
1089
+ this.brush = brush;
1090
+ this.options = options;
1091
+ this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
1092
+ this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
1093
+ const exchanging = brush.engine === "mixer" || brush.engine === "dab";
1094
+ const rates = brush.engine === "mixer" ? mixerRates(brush.mixer) : exchangeRates(brush.behavior.pickup, brush.behavior.mix, 1, brush.behavior.mixing);
1095
+ this.mixing = brush.engine === "mixer" ? brush.mixer.mixing : brush.behavior.mixing;
1096
+ this.states = this.copies.map((c, i) => ({
1097
+ walker: new PenDabWalker(brush, (options.seed ?? 0) + i * 7919 | 0, c.linear),
1098
+ exchange: exchanging ? new PaintExchange(rates, options.load) : null,
1099
+ smudge: brush.engine === "smudge" ? new SmudgeTip(brush.smudge, brush.tip.size * (1 + brush.dynamics.tiltSize * 1.5)) : null
1100
+ }));
1101
+ this.medium = new TileMedium(options.target, brush.engine === "dab" ? { blend: brush.tip.blend } : {});
1102
+ this.depth = options.grain && !options.grain.flat ? Math.min(1, Math.max(0, options.paperDepth * brush.grain)) : 0;
1103
+ this.coverage = brush.engine === "dab" ? new CoverageMap() : null;
1104
+ const mc = brush.behavior.multicolor;
1105
+ this.palette = exchanging && mc.mode !== "off" ? multiColorPalette(mc, options.color, options.secondary) : null;
1106
+ this.gradient = this.palette && mc.mode === "tip" ? multiColorGradient(this.palette, mc.softness, this.mixing) : null;
1107
+ this.scratch = brush.engine === "blender" ? createBlenderScratch() : null;
1108
+ }
1109
+ brush;
1110
+ options;
1111
+ dabCount = 0;
1112
+ smoother;
1113
+ copies;
1114
+ states;
1115
+ medium;
1116
+ depth;
1117
+ coverage;
1118
+ palette;
1119
+ gradient;
1120
+ fieldLut = [];
1121
+ scratch;
1122
+ mixing;
1123
+ tip = new Float32Array(0);
1124
+ cov = new Float32Array(0);
1125
+ field = new Float32Array(0);
1126
+ paint = [0, 0, 0];
1127
+ lastRaw = null;
1128
+ lastFed = null;
1129
+ /** What copy 0's brush carries after the dabs so far (mixer and wet-capable dab brushes). */
1130
+ get load() {
1131
+ return this.states[0].exchange?.load ?? null;
1132
+ }
1133
+ push(points) {
1134
+ const rects = [];
1135
+ for (const raw of points) {
1136
+ this.lastRaw = raw;
1137
+ const p = this.smoother.push(raw);
1138
+ this.lastFed = p;
1139
+ this.copies.forEach((c, i) => {
1140
+ const dabs = [];
1141
+ this.states[i].walker.push(c.map(p), dabs);
1142
+ this.paintDabs(i, dabs, rects);
1143
+ });
1144
+ }
1145
+ return rects;
1146
+ }
1147
+ /** Ends the stroke: the smoothed path catches up with the last raw sample; a tap paints one dab. */
1148
+ finish() {
1149
+ const rects = [];
1150
+ const raw = this.lastRaw;
1151
+ const fed = this.lastFed;
1152
+ this.copies.forEach((c, i) => {
1153
+ const dabs = [];
1154
+ if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.states[i].walker.push(c.map({ ...raw, pressure: fed.pressure }), dabs);
1155
+ this.states[i].walker.finish(dabs);
1156
+ this.paintDabs(i, dabs, rects);
1157
+ });
1158
+ return rects;
1159
+ }
1160
+ paintDabs(copy, dabs, rects) {
1161
+ for (const d of dabs) {
1162
+ const rect = dabRect(d, this.brush.tip.hardness);
1163
+ if (rect.width <= 0 || rect.height <= 0) continue;
1164
+ this.dab(copy, d, rect);
1165
+ rects.push(rect);
1166
+ this.dabCount++;
1167
+ }
1168
+ }
1169
+ /** The tip's pure coverage (`tip`) and, with grain, what the paper catches of it (`cov`). */
1170
+ rasterize(d, rect) {
1171
+ const n = rect.width * rect.height;
1172
+ if (this.tip.length < n) {
1173
+ this.tip = new Float32Array(n);
1174
+ this.cov = new Float32Array(n);
1175
+ }
1176
+ const tip = this.tip.subarray(0, n);
1177
+ tip.fill(0);
1178
+ rasterizeDab({ ...d, alpha: 1 }, this.brush.tip.hardness, rect, tip);
1179
+ if (!(this.depth > 0 && this.options.grain)) return { tip, cov: tip };
1180
+ const cov = this.cov.subarray(0, n);
1181
+ const grain = this.options.grain;
1182
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1183
+ const y = rect.y + yy + 0.5;
1184
+ for (let xx = 0; xx < rect.width; xx++, k++) cov[k] = tip[k] > 0 ? tip[k] * grainCatch(grain.at(rect.x + xx + 0.5, y), this.depth, d.pressure) : 0;
1185
+ }
1186
+ return { tip, cov };
1187
+ }
1188
+ dab(copy, d, rect) {
1189
+ const brush = this.brush;
1190
+ const { tip, cov } = this.rasterize(d, rect);
1191
+ const st = this.states[copy];
1192
+ if (brush.engine === "blender") {
1193
+ blenderDab(this.medium, rect, cov, d.opacity * d.alpha * brush.tip.opacity, d.radius, brush.blender, this.scratch);
1194
+ return;
1195
+ }
1196
+ if (brush.engine === "smudge") {
1197
+ st.smudge.dab(this.medium, { x: d.x, y: d.y, radius: d.radius, opacity: d.opacity * d.alpha * brush.tip.opacity }, rect, cov);
1198
+ return;
1199
+ }
1200
+ const ex = st.exchange;
1201
+ ex.pickUp(this.medium.sample(rect, tip));
1202
+ const mc = brush.behavior.multicolor;
1203
+ if (this.palette && mc.mode === "dab") {
1204
+ const c = multiColorForDab(mc, this.palette, d.index, this.options.seed ?? 0, this.mixing);
1205
+ ex.setReservoirColor(c[0], c[1], c[2]);
1206
+ }
1207
+ let field = null;
1208
+ let total;
1209
+ if (this.gradient) {
1210
+ total = this.tipField(ex, d, rect);
1211
+ field = this.field;
1212
+ } else total = ex.paint(this.paint);
1213
+ if (total > 0) {
1214
+ if (this.coverage) this.depositWithCeiling(d, rect, cov, field, ex);
1215
+ else {
1216
+ const dep = Math.min(1, total) * d.alpha * brush.tip.opacity * d.opacity;
1217
+ if (dep > 0) this.medium.deposit(rect, cov, dep, this.paint, ex.paintPigment, field);
1218
+ }
1219
+ }
1220
+ ex.drain();
1221
+ }
1222
+ /** Fills `this.field` with the tip gradient (mixed with the carried paint) per pixel; returns the paint weight. */
1223
+ tipField(ex, d, rect) {
1224
+ const g = this.gradient;
1225
+ const lut = this.fieldLut;
1226
+ let total = 0;
1227
+ for (let j = 0; j < g.length; j++) {
1228
+ ex.setReservoirColor(g[j][0], g[j][1], g[j][2]);
1229
+ const out = lut[j] ?? [0, 0, 0];
1230
+ total = ex.paint(out);
1231
+ lut[j] = out;
1232
+ }
1233
+ const n = rect.width * rect.height;
1234
+ if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
1235
+ const f = this.field;
1236
+ const nx = Math.sin(d.direction);
1237
+ const ny = Math.cos(d.direction);
1238
+ const r = Math.max(0.5, d.radius);
1239
+ const last = g.length - 1;
1240
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1241
+ const py = rect.y + yy + 0.5 - d.y;
1242
+ for (let xx = 0; xx < rect.width; xx++, k++) {
1243
+ const u = ((rect.x + xx + 0.5 - d.x) * nx + py * ny) / r;
1244
+ const j = Math.min(last, Math.max(0, Math.round((u + 1) / 2 * last)));
1245
+ f[k * 3] = lut[j][0];
1246
+ f[k * 3 + 1] = lut[j][1];
1247
+ f[k * 3 + 2] = lut[j][2];
1248
+ }
1249
+ }
1250
+ return total;
1251
+ }
1252
+ /** A dab brush's deposit: only what this dab adds to the stroke's coverage, under its opacity ceiling. */
1253
+ depositWithCeiling(d, rect, cov, field, ex) {
1254
+ const map = this.coverage;
1255
+ const n = rect.width * rect.height;
1256
+ const before = new Float32Array(n);
1257
+ const vals = new Float32Array(n);
1258
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1259
+ for (let xx = 0; xx < rect.width; xx++, k++) {
1260
+ if (cov[k] <= 0) continue;
1261
+ before[k] = map.get(rect.x + xx, rect.y + yy);
1262
+ vals[k] = cov[k] * d.alpha;
1263
+ }
1264
+ }
1265
+ map.accumulate(rect, vals, d.opacity);
1266
+ const o = this.brush.tip.opacity;
1267
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1268
+ for (let xx = 0; xx < rect.width; xx++, k++) {
1269
+ if (cov[k] <= 0) {
1270
+ vals[k] = 0;
1271
+ continue;
1272
+ }
1273
+ const c0 = before[k] * o;
1274
+ const c1 = map.get(rect.x + xx, rect.y + yy) * o;
1275
+ vals[k] = c1 > c0 && c0 < 1 ? (c1 - c0) / (1 - c0) : 0;
1276
+ }
1277
+ }
1278
+ this.medium.deposit(rect, vals, 1, this.paint, ex.paintPigment, field);
1279
+ }
1280
+ };
1281
+
938
1282
  // src/paint-editor/stroke.ts
939
1283
  function defaultTile(surface) {
940
1284
  const data = new Uint8ClampedArray(TILE * TILE * surface.channels);
@@ -973,10 +1317,18 @@ var PaintStrokeSession = class {
973
1317
  selection: null,
974
1318
  offset: init.offset
975
1319
  };
976
- if (brush.engine === "mixer") {
977
- this.mixerSmoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
978
- const load = init.mixerLoad;
979
- for (let i = 0; i < this.copies.length; i++) this.mixers.push(new MixerStroke(mixerTip(brush), brush.mixer, load, this.mixerTarget()));
1320
+ if (brush.engine === "mixer" || brush.engine === "blender" || brush.engine === "smudge" || usesPaintExchange(brush)) {
1321
+ const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
1322
+ this.wet = new PaintDabStroke(brush, {
1323
+ target: this.tileTarget(),
1324
+ color: rgb(init.color),
1325
+ secondary: rgb(init.secondary ?? init.background),
1326
+ load: init.mixerLoad,
1327
+ grain: init.grain,
1328
+ paperDepth: init.paperDepth,
1329
+ seed: init.seed,
1330
+ copies: this.copies
1331
+ });
980
1332
  } else {
981
1333
  this.dry = new DryStroke(brush, { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies });
982
1334
  }
@@ -991,15 +1343,14 @@ var PaintStrokeSession = class {
991
1343
  touched = /* @__PURE__ */ new Set();
992
1344
  fresh = /* @__PURE__ */ new Set();
993
1345
  dry = null;
994
- mixers = [];
995
- mixerSmoother = null;
1346
+ wet = null;
996
1347
  apply;
997
1348
  copies;
998
1349
  bounds = null;
999
1350
  lastRaw = null;
1000
1351
  closed = false;
1001
1352
  result = null;
1002
- mixerTarget() {
1353
+ tileTarget() {
1003
1354
  return {
1004
1355
  offset: this.init.offset,
1005
1356
  lockTransparency: this.init.lockTransparency,
@@ -1027,15 +1378,7 @@ var PaintStrokeSession = class {
1027
1378
  if (filter) points = points.map(filter);
1028
1379
  for (const p of points) this.points.push(p);
1029
1380
  this.lastRaw = points[points.length - 1];
1030
- if (this.dry) this.update(this.dry.push(points));
1031
- else {
1032
- const rects = [];
1033
- for (const raw of points) {
1034
- const p = toStrokePoint(this.mixerSmoother.push(raw));
1035
- this.copies.forEach((c, i) => rects.push(...this.mixers[i].push([toStrokePoint(c.map(p))])));
1036
- }
1037
- this.update(rects);
1038
- }
1381
+ this.update(this.dry ? this.dry.push(points) : this.wet.push(points));
1039
1382
  }
1040
1383
  update(rects) {
1041
1384
  if (!rects.length) return;
@@ -1068,13 +1411,7 @@ var PaintStrokeSession = class {
1068
1411
  /** Ends the stroke (one undo step) and returns what changed. */
1069
1412
  end() {
1070
1413
  if (this.closed) throw new Error("This stroke has already ended.");
1071
- if (this.dry) this.update(this.dry.finish());
1072
- else {
1073
- const rects = [];
1074
- const last = this.lastRaw ? toStrokePoint(this.lastRaw) : void 0;
1075
- this.copies.forEach((c, i) => rects.push(...this.mixers[i].finish(last ? toStrokePoint(c.map(last)) : void 0)));
1076
- this.update(rects);
1077
- }
1414
+ this.update(this.dry ? this.dry.finish() : this.wet.finish());
1078
1415
  this.closed = true;
1079
1416
  const ch = this.base.channels;
1080
1417
  const tiles = new Map(this.base.tiles);
@@ -1087,14 +1424,10 @@ var PaintStrokeSession = class {
1087
1424
  if (!sameTile(prev, tile)) changed = true;
1088
1425
  }
1089
1426
  const surface = changed ? new TiledSurface(ch, tiles, this.base.defaultValue) : null;
1090
- const dabs = this.dry ? this.dry.dabCount : this.mixers.reduce((n, m) => n + m.dabCount, 0);
1427
+ const dabs = this.dry ? this.dry.dabCount : this.wet.dabCount;
1091
1428
  this.result = { layerId: this.layerId, bounds: changed ? this.bounds : null, dabs, strokeId: null };
1092
- if (this.mixers.length) {
1093
- const brush = this.init.brush;
1094
- let load = this.mixers[0].load;
1095
- if (brush.mixer.cleanEachStroke) load = cleanMixer(load);
1096
- this.result.mixerLoad = load;
1097
- }
1429
+ const carried = this.wet?.load;
1430
+ if (carried) this.result.mixerLoad = this.init.brush.behavior.dirty ? carried : cleanMixer(carried);
1098
1431
  this.host.commit(this, surface, false);
1099
1432
  return this.result;
1100
1433
  }
@@ -1110,6 +1443,11 @@ function mixerLoadForStroke(brush, current, color) {
1110
1443
  if (brush.mixer.loadEachStroke || !current.reservoir) return loadMixer(current, color);
1111
1444
  return current;
1112
1445
  }
1446
+ function brushLoadForStroke(brush, current, color) {
1447
+ if (brush.engine === "mixer") return mixerLoadForStroke(brush, current, color);
1448
+ if (usesPaintExchange(brush)) return { reservoir: [color[0], color[1], color[2]], amount: 1, pickup: brush.behavior.dirty ? current.pickup : null };
1449
+ return EMPTY_MIXER_LOAD;
1450
+ }
1113
1451
 
1114
1452
  // src/paint-editor/strokes.ts
1115
1453
  var IDENTITY_AFFINE = [1, 0, 0, 1, 0, 0];
@@ -1155,6 +1493,17 @@ function readPoint(raw) {
1155
1493
  function readAffine(raw) {
1156
1494
  return Array.isArray(raw) && raw.length === 6 && raw.every(finite) ? raw : null;
1157
1495
  }
1496
+ function readRgb(raw) {
1497
+ return Array.isArray(raw) && raw.length === 3 && raw.every(finite) ? raw.map((v) => Math.min(1, Math.max(0, v))) : null;
1498
+ }
1499
+ function readLoad(raw) {
1500
+ if (!raw || typeof raw !== "object") return null;
1501
+ const r = raw;
1502
+ const reservoir = readRgb(r.reservoir);
1503
+ const pickup = readRgb(r.pickup);
1504
+ if (!reservoir && !pickup) return null;
1505
+ return { reservoir, amount: reservoir && finite(r.amount) ? Math.min(1, Math.max(0, r.amount)) : 0, pickup };
1506
+ }
1158
1507
  function normalizeBrushStroke(raw) {
1159
1508
  if (!raw || typeof raw !== "object") return null;
1160
1509
  const r = raw;
@@ -1171,6 +1520,8 @@ function normalizeBrushStroke(raw) {
1171
1520
  };
1172
1521
  const copies = Array.isArray(r.copies) ? r.copies.map(readAffine).filter((m) => m !== null) : [];
1173
1522
  if (copies.length) out.copies = copies;
1523
+ const load = readLoad(r.load);
1524
+ if (load) out.load = load;
1174
1525
  return out;
1175
1526
  }
1176
1527
  function strokeBounds(points) {
@@ -1322,7 +1673,7 @@ function downsampleRgba(data, width, height, tw, th) {
1322
1673
  // src/paint-editor/serialize.ts
1323
1674
  var PAINT_FORMAT = "pixerate-paint";
1324
1675
  var PAINT_SCHEMA_VERSION = 1;
1325
- var KNOWN = /* @__PURE__ */ new Set(["format", "version", "width", "height", "dpi", "paper", "layers", "assets", "activeLayerId", "strokes", "guides", "swatches"]);
1676
+ var KNOWN = /* @__PURE__ */ new Set(["format", "version", "width", "height", "dpi", "paper", "layers", "assets", "activeLayerId", "strokes", "guides", "swatches", "mixingPad"]);
1326
1677
  function documentExtensions(raw) {
1327
1678
  const out = {};
1328
1679
  for (const [k, v] of Object.entries(raw)) if (!KNOWN.has(k)) out[k] = v;
@@ -1342,6 +1693,7 @@ function header(state, options) {
1342
1693
  if (options.strokes !== false && state.strokes.length) out.strokes = JSON.parse(JSON.stringify(state.strokes));
1343
1694
  if (options.guides !== void 0) out.guides = JSON.parse(JSON.stringify(options.guides));
1344
1695
  if (options.swatches !== void 0) out.swatches = JSON.parse(JSON.stringify(options.swatches));
1696
+ if (options.mixingPad !== void 0) out.mixingPad = JSON.parse(JSON.stringify(options.mixingPad));
1345
1697
  return out;
1346
1698
  }
1347
1699
  function serializePaintDocument(state, options = {}) {
@@ -1389,7 +1741,7 @@ function deserializePaintDocument(raw) {
1389
1741
  const layers = paintLayersOnly(deserializeLayers(r.layers, { assets, problems }), problems);
1390
1742
  const wanted = typeof r.activeLayerId === "string" ? r.activeLayerId : null;
1391
1743
  const activeLayerId = wanted && findLayer(layers, wanted) ? wanted : flattenLayers(layers).filter((l) => l.type === "pixel").pop()?.id ?? null;
1392
- return { state: { canvas, paper, layers, activeLayerId, strokes: readStrokes(r.strokes, problems) }, extensions: documentExtensions(r), guides: r.guides, swatches: r.swatches, problems };
1744
+ return { state: { canvas, paper, layers, activeLayerId, strokes: readStrokes(r.strokes, problems) }, extensions: documentExtensions(r), guides: r.guides, swatches: r.swatches, mixingPad: r.mixingPad, problems };
1393
1745
  }
1394
1746
  async function deserializePaintDocumentAsync(raw, store) {
1395
1747
  if (!raw || typeof raw !== "object") throw new Error("Not a paint document: expected a JSON object.");
@@ -1574,7 +1926,7 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
1574
1926
  colorHex: color,
1575
1927
  affineCopies: [],
1576
1928
  copies: [IDENTITY_COPY],
1577
- mixerLoad: brush.engine === "mixer" ? mixerLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255]) : EMPTY_MIXER_LOAD
1929
+ mixerLoad: brushLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255])
1578
1930
  }
1579
1931
  );
1580
1932
  session.push(thumbnailStroke(width, height));
@@ -1590,7 +1942,7 @@ function renderBrushThumbnail(brush, options) {
1590
1942
  let surface = emptySurface(4);
1591
1943
  if (b.engine === "eraser") {
1592
1944
  surface = fillRect(surface, { x: 0, y: 0, width, height }, rgba);
1593
- } else if (b.engine === "mixer" && !b.loadsColor) {
1945
+ } else if (b.engine === "blender" || b.engine === "smudge" || b.engine === "mixer" && !b.loadsColor || b.behavior.pickup > 0) {
1594
1946
  const half = Math.ceil(height / 2);
1595
1947
  surface = fillRect(surface, { x: 0, y: 0, width, height: half }, rgba);
1596
1948
  surface = fillRect(surface, { x: 0, y: half, width, height: height - half }, [232, 196, 64, 255]);
@@ -1783,9 +2135,9 @@ function perspectiveGuideLines(g, size, count = 12) {
1783
2135
  }
1784
2136
 
1785
2137
  // src/paint-editor/color.ts
1786
- var HEX2 = /^#?([0-9a-f]{6})$/i;
2138
+ var HEX3 = /^#?([0-9a-f]{6})$/i;
1787
2139
  function parseHexColor(value, what = "color") {
1788
- const m = typeof value === "string" ? HEX2.exec(value.trim()) : null;
2140
+ const m = typeof value === "string" ? HEX3.exec(value.trim()) : null;
1789
2141
  if (!m) throw new Error(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
1790
2142
  const n = parseInt(m[1], 16);
1791
2143
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
@@ -1913,10 +2265,18 @@ var PAD_KNIFE = normalizePaintBrush({
1913
2265
  id: "pad-knife",
1914
2266
  name: "Palette knife",
1915
2267
  category: "smudge",
1916
- engine: "mixer",
2268
+ engine: "smudge",
1917
2269
  loadsColor: false,
1918
- tip: { size: 34, hardness: 0.9, spacing: 0.08, roundness: 0.35, angle: 90, opacity: 1, flow: 0.85, pressureSize: false, pressureOpacity: false },
1919
- mixer: { wet: 1, load: 0, mix: 1, loadEachStroke: false, cleanEachStroke: true, mixing: "pigment" }
2270
+ tip: { size: 34, hardness: 0.9, spacing: 0.08, roundness: 0.35, angle: 90, opacity: 1, flow: 1, pressureSize: false, pressureOpacity: false },
2271
+ // A knife drags streaks of paint and folds them into each other, mixing them as pigment.
2272
+ smudge: { strength: 0.6, length: 0.8, mixing: "pigment" }
2273
+ });
2274
+ var PAD_BLOB = normalizePaintBrush({
2275
+ id: "pad-blob",
2276
+ name: "Pad blob",
2277
+ category: "custom",
2278
+ engine: "dab",
2279
+ tip: { size: 20, hardness: 1, spacing: 0.1, opacity: 1, flow: 1, pressureSize: false, pressureOpacity: false }
1920
2280
  });
1921
2281
  var MixingPad = class {
1922
2282
  width = MIXING_PAD_WIDTH;
@@ -1934,7 +2294,11 @@ var MixingPad = class {
1934
2294
  }
1935
2295
  let brush = options.tool === "knife" ? PAD_KNIFE : PAD_PAINT;
1936
2296
  if (options.size !== void 0) brush = normalizePaintBrush({ tip: { ...brush.tip, size: options.size } }, brush);
1937
- const [r, g, b] = hexToRgb8(options.color);
2297
+ return this.run(brush, points, options.color);
2298
+ }
2299
+ /** Paints `points` with `brush` in `color` on the pad. */
2300
+ run(brush, points, color) {
2301
+ const [r, g, b] = hexToRgb8(color);
1938
2302
  let result = null;
1939
2303
  const session = new PaintStrokeSession(
1940
2304
  { update: () => {
@@ -1950,10 +2314,10 @@ var MixingPad = class {
1950
2314
  grain: null,
1951
2315
  paperDepth: 0,
1952
2316
  seed: 1,
1953
- colorHex: options.color,
2317
+ colorHex: color,
1954
2318
  affineCopies: [],
1955
2319
  copies: [IDENTITY_COPY],
1956
- mixerLoad: mixerLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255])
2320
+ mixerLoad: brushLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255])
1957
2321
  }
1958
2322
  );
1959
2323
  session.push(points);
@@ -1992,6 +2356,82 @@ var MixingPad = class {
1992
2356
  this.surface = emptySurface(4);
1993
2357
  this.version++;
1994
2358
  }
2359
+ /**
2360
+ * Mixes paints the way a painter does on a palette: blobs whose areas are in proportion to
2361
+ * their parts, side by side around (x, y), spiral knife passes that fold them together, and then the centre worked until it is fully mixed: a soft-edged core
2362
+ * of the Kubelka-Munk mix of the parts (`mixPigments`), with partly mixed streaks around it.
2363
+ * Returns that colour (picked back from the centre) and the pile's bounds.
2364
+ *
2365
+ * The core is laid, not smeared, because smearing alone drifts: the knife samples in RGB and a
2366
+ * mixed colour's scattering is re-estimated from its luminance, so repeated small mixes favour
2367
+ * the lighter paint (see GOTCHAS).
2368
+ */
2369
+ mix(paints, options = {}) {
2370
+ if (!paints.length || paints.length > 8) throw new Error(`Give 1 to 8 paints to mix; got ${paints.length}.`);
2371
+ const total = paints.reduce((n, p) => n + p.parts, 0);
2372
+ paints.forEach((p, i) => {
2373
+ if (!(Number.isFinite(p.parts) && p.parts >= 0)) throw new Error(`paints[${i}].parts is ${p.parts}; use a number \u2265 0.`);
2374
+ });
2375
+ if (!(total > 0)) throw new Error("Every paint has 0 parts; give at least one paint parts above 0.");
2376
+ const R = options.radius ?? 40;
2377
+ if (!(R >= 12 && R <= 100)) throw new Error(`radius ${R} is out of range; use 12..100 pad px.`);
2378
+ const passes = options.passes ?? 3;
2379
+ if (!(Number.isInteger(passes) && passes >= 1 && passes <= 20)) throw new Error(`passes ${passes} is out of range; use a whole number 1..20.`);
2380
+ const cx = options.x ?? this.width / 2;
2381
+ const cy = options.y ?? this.height / 2;
2382
+ if (!(cx - R >= 0 && cy - R >= 0 && cx + R <= this.width && cy + R <= this.height)) {
2383
+ throw new Error(`A pile of radius ${R} at (${cx}, ${cy}) does not fit on the ${this.width}\xD7${this.height} pad; move it at least ${R} px from every edge or use a smaller radius.`);
2384
+ }
2385
+ const bounds = { x: Math.floor(cx - R), y: Math.floor(cy - R), width: Math.ceil(2 * R), height: Math.ceil(2 * R) };
2386
+ if (options.clear !== false) this.surface = writeRegion(this.surface, bounds, new Uint8ClampedArray(bounds.width * bounds.height * 4));
2387
+ const used = paints.filter((p) => p.parts > 0);
2388
+ const area = Math.PI * (0.7 * R) ** 2;
2389
+ used.forEach((p, i) => {
2390
+ const r = Math.sqrt(area * (p.parts / total) / Math.PI);
2391
+ const a = i / used.length * Math.PI * 2;
2392
+ const off = used.length === 1 ? 0 : 0.45 * R;
2393
+ const x = Math.min(cx + R - r, Math.max(cx - R + r, cx + Math.cos(a) * off));
2394
+ const y = Math.min(cy + R - r, Math.max(cy - R + r, cy + Math.sin(a) * off));
2395
+ const blob = normalizePaintBrush({ tip: { ...PAD_BLOB.tip, size: Math.max(2, 2 * r) } }, PAD_BLOB);
2396
+ this.run(blob, [{ x, y }], p.color.toLowerCase());
2397
+ });
2398
+ const knife = normalizePaintBrush({ tip: { ...PAD_KNIFE.tip, size: Math.max(12, R * 0.5) } }, PAD_KNIFE);
2399
+ for (let k = 0; k < passes; k++) {
2400
+ const stir = [];
2401
+ const turns = 1.6;
2402
+ for (let t = 0; t <= 48; t++) {
2403
+ const a = t / 48 * Math.PI * 2 * turns + k * 1.3;
2404
+ const rr = R * (0.85 - 0.6 * (t / 48));
2405
+ stir.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr });
2406
+ }
2407
+ this.run(knife, stir, "#000000");
2408
+ }
2409
+ const mixed = mixPigments(
2410
+ paints.map((p) => hexToRgb8(p.color).map((v) => v / 255)),
2411
+ paints.map((p) => p.parts)
2412
+ );
2413
+ const core = normalizePaintBrush({ tip: { ...PAD_BLOB.tip, size: R * 1.1, hardness: 0.55 } }, PAD_BLOB);
2414
+ const hex = "#" + mixed.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
2415
+ this.run(core, [{ x: cx, y: cy }], hex);
2416
+ return { color: this.pick(cx, cy, 2), bounds };
2417
+ }
2418
+ /** The pad's pixels as a PNG data URL (with alpha), for saving. */
2419
+ toPng() {
2420
+ return pngDataUrl(this.width, this.height, readRegion(this.surface, { x: 0, y: 0, width: this.width, height: this.height }));
2421
+ }
2422
+ /** Replaces the pad with a PNG (data URL or base64) of the pad's size, as `toPng` writes. */
2423
+ fromPng(png) {
2424
+ const bytes = png.startsWith("data:") ? parseDataUrl(png)?.bytes : base64ToBytes(png);
2425
+ if (!bytes || !isPng(bytes)) throw new Error("The pad image is not a PNG data URL or base64 PNG.");
2426
+ const img = decodePng(bytes);
2427
+ if (img.width !== this.width || img.height !== this.height) throw new Error(`The pad image is ${img.width}\xD7${img.height}; a mixing pad is ${this.width}\xD7${this.height}.`);
2428
+ this.surface = surfaceFromPixels({ width: img.width, height: img.height, data: img.data }, 4);
2429
+ this.version++;
2430
+ }
2431
+ /** Whether nothing is on the pad. */
2432
+ get empty() {
2433
+ return this.surface.tiles.size === 0;
2434
+ }
1995
2435
  /** The pad over white, `width × height` RGBA. */
1996
2436
  pixels() {
1997
2437
  const px = readRegion(this.surface, { x: 0, y: 0, width: this.width, height: this.height });
@@ -2013,9 +2453,9 @@ var PaintEditorError = class extends Error {
2013
2453
  this.name = "PaintEditorError";
2014
2454
  }
2015
2455
  };
2016
- var HEX3 = /^#[0-9a-f]{6}$/i;
2456
+ var HEX4 = /^#[0-9a-f]{6}$/i;
2017
2457
  function parseColor(value, what = "color") {
2018
- if (typeof value !== "string" || !HEX3.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
2458
+ if (typeof value !== "string" || !HEX4.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
2019
2459
  return value.trim().toLowerCase();
2020
2460
  }
2021
2461
  function rgbaOf(hex) {
@@ -2041,6 +2481,8 @@ var PaintEditorController = class {
2041
2481
  historyLimit;
2042
2482
  brush;
2043
2483
  userBrushes;
2484
+ /** The library list in the state (the same array until the user brushes change, so thumbnails are not redrawn). */
2485
+ library = null;
2044
2486
  color;
2045
2487
  secondaryColor = "#ffffff";
2046
2488
  mixerLoad = EMPTY_MIXER_LOAD;
@@ -2059,6 +2501,8 @@ var PaintEditorController = class {
2059
2501
  guides;
2060
2502
  swatches = DEFAULT_SWATCHES.map((s) => ({ ...s }));
2061
2503
  pad = new MixingPad();
2504
+ savedPads = [];
2505
+ nextPadId = 1;
2062
2506
  tracingVersion = 1;
2063
2507
  tracingCache = null;
2064
2508
  imagePixels = /* @__PURE__ */ new Map();
@@ -2096,7 +2540,7 @@ var PaintEditorController = class {
2096
2540
  this.cached = {
2097
2541
  document: this.doc,
2098
2542
  brush: this.brush,
2099
- brushes: [...BUILTIN_BRUSHES, ...this.userBrushes],
2543
+ brushes: this.library ??= [...BUILTIN_BRUSHES, ...this.userBrushes],
2100
2544
  color: this.color,
2101
2545
  secondaryColor: this.secondaryColor,
2102
2546
  mixerLoad: this.mixerLoad,
@@ -2109,6 +2553,7 @@ var PaintEditorController = class {
2109
2553
  guides: this.guides,
2110
2554
  swatches: this.swatches,
2111
2555
  mixingPadVersion: this.pad.version,
2556
+ savedMixingPads: this.savedPads.map(({ id, name }) => ({ id, name })),
2112
2557
  stroking: Boolean(this.session),
2113
2558
  version: this.version
2114
2559
  };
@@ -2478,6 +2923,7 @@ var PaintEditorController = class {
2478
2923
  const i = this.userBrushes.findIndex((b) => b.id === options.id);
2479
2924
  if (i >= 0) this.userBrushes[i] = saved;
2480
2925
  else this.userBrushes.push(saved);
2926
+ this.library = null;
2481
2927
  this.emit([]);
2482
2928
  return saved;
2483
2929
  }
@@ -2486,6 +2932,7 @@ var PaintEditorController = class {
2486
2932
  const i = this.userBrushes.findIndex((b) => b.id === id);
2487
2933
  if (i < 0) throw new PaintEditorError(`Unknown brush "${id}". Call list_brushes for the ids.`);
2488
2934
  this.userBrushes.splice(i, 1);
2935
+ this.library = null;
2489
2936
  this.emit([]);
2490
2937
  }
2491
2938
  /** The user's saved brushes. */
@@ -2526,7 +2973,10 @@ var PaintEditorController = class {
2526
2973
  else this.userBrushes.push(b);
2527
2974
  added.push(b.id);
2528
2975
  }
2529
- if (added.length) this.emit([]);
2976
+ if (added.length) {
2977
+ this.library = null;
2978
+ this.emit([]);
2979
+ }
2530
2980
  return { added, skipped, problems };
2531
2981
  }
2532
2982
  /** A thumbnail of a brush: a test stroke on the current paper. */
@@ -2600,6 +3050,14 @@ var PaintEditorController = class {
2600
3050
  if (init.brush) brush = normalizePaintBrush({ ...init.brush, id: brush.id }, brush);
2601
3051
  return brush;
2602
3052
  }
3053
+ /**
3054
+ * After a stroke: a Mixer Brush that loads colour keeps its whole load; any other dirty brush
3055
+ * keeps only the paint it carries (the reservoir stays the Mixer Brush's).
3056
+ */
3057
+ keepLoad(brush, load) {
3058
+ if (brush.engine === "mixer" && brush.loadsColor) this.mixerLoad = load;
3059
+ else if (brush.behavior.dirty) this.mixerLoad = { ...this.mixerLoad, pickup: load.pickup };
3060
+ }
2603
3061
  /** Extra copies every new stroke paints (the painting guides add mirror and kaleidoscope copies). */
2604
3062
  strokeCopies() {
2605
3063
  return symmetryCopies(this.guides.symmetry);
@@ -2640,7 +3098,8 @@ var PaintEditorController = class {
2640
3098
  affineCopies: copies,
2641
3099
  copies: [IDENTITY_AFFINE, ...copies].map(affineCopy),
2642
3100
  inputFilter: snapper ? (p) => snapper.snap(p) : void 0,
2643
- mixerLoad: brush.engine === "mixer" ? mixerLoadForStroke(brush, this.mixerLoad, [rgba[0] / 255, rgba[1] / 255, rgba[2] / 255]) : EMPTY_MIXER_LOAD
3101
+ mixerLoad: init.load ?? brushLoadForStroke(brush, this.mixerLoad, [rgba[0] / 255, rgba[1] / 255, rgba[2] / 255]),
3102
+ secondary: rgbaOf(this.secondaryColor)
2644
3103
  });
2645
3104
  return session;
2646
3105
  }
@@ -2655,6 +3114,7 @@ var PaintEditorController = class {
2655
3114
  seed: init.seed
2656
3115
  };
2657
3116
  if (init.affineCopies.length) stroke.copies = init.affineCopies.map((m) => [...m]);
3117
+ if (init.mixerLoad.reservoir || init.mixerLoad.pickup) stroke.load = JSON.parse(JSON.stringify(init.mixerLoad));
2658
3118
  return stroke;
2659
3119
  }
2660
3120
  /**
@@ -2673,7 +3133,7 @@ var PaintEditorController = class {
2673
3133
  this.live = null;
2674
3134
  this.liveLayers = null;
2675
3135
  const brush = s.init.brush;
2676
- if (!cancelled && s.result?.mixerLoad && brush.loadsColor) this.mixerLoad = s.result.mixerLoad;
3136
+ if (!cancelled && s.result?.mixerLoad) this.keepLoad(brush, s.result.mixerLoad);
2677
3137
  if (cancelled || !surface) {
2678
3138
  this.emit();
2679
3139
  return;
@@ -2717,7 +3177,7 @@ var PaintEditorController = class {
2717
3177
  this.requireIdle("painting");
2718
3178
  const points = this.checkPoints(init.points);
2719
3179
  const { doc, result, brush } = this.runStroke(this.doc, { ...init, start: init.start ?? points[0] }, points);
2720
- if (result.mixerLoad && brush.loadsColor) this.mixerLoad = result.mixerLoad;
3180
+ if (result.mixerLoad) this.keepLoad(brush, result.mixerLoad);
2721
3181
  if (doc !== this.doc) this.commit(`${brush.name} stroke`, doc);
2722
3182
  else this.emit([]);
2723
3183
  return result;
@@ -2777,7 +3237,12 @@ var PaintEditorController = class {
2777
3237
  }
2778
3238
  const layerId = options.layerId ?? s.layerId;
2779
3239
  if (!findLayer(doc.layers, layerId)) throw new PaintEditorError(`Stroke "${s.id}" was painted on layer "${layerId}", which no longer exists; pass layerId to replay it elsewhere.`);
2780
- const run = this.runStroke(doc, { layerId, color: options.color ?? s.color, brush, seed: s.seed, copies: v.copies ?? [] }, v.points);
3240
+ let load = s.load;
3241
+ if (load && options.color !== void 0 && load.reservoir) {
3242
+ const [r, g, b] = hexToRgb8(options.color);
3243
+ load = { ...load, reservoir: [r / 255, g / 255, b / 255] };
3244
+ }
3245
+ const run = this.runStroke(doc, { layerId, color: options.color ?? s.color, brush, seed: s.seed, copies: v.copies ?? [], ...load && options.brushId === void 0 ? { load } : {} }, v.points);
2781
3246
  doc = run.doc;
2782
3247
  bounds = unionRect(bounds, run.result.bounds);
2783
3248
  if (run.result.strokeId) ids.push(run.result.strokeId);
@@ -2856,14 +3321,15 @@ var PaintEditorController = class {
2856
3321
  // --- Save, load and export -------------------------------------------------------
2857
3322
  /** The document as JSON with inline PNG assets. */
2858
3323
  toJSON(options = {}) {
2859
- return serializePaintDocument(this.doc, { strokes: options.strokes, extensions: this.extensions, guides: this.guides, swatches: this.swatches });
3324
+ return serializePaintDocument(this.doc, { strokes: options.strokes, extensions: this.extensions, guides: this.guides, swatches: this.swatches, mixingPad: this.mixingPadJSON() });
2860
3325
  }
2861
3326
  /** The document as JSON with each PNG stored in `store`. */
2862
3327
  toJSONExternal(store, options = {}) {
2863
- return serializePaintDocumentExternal(this.doc, store, { strokes: options.strokes, extensions: this.extensions, guides: this.guides, swatches: this.swatches });
3328
+ return serializePaintDocumentExternal(this.doc, store, { strokes: options.strokes, extensions: this.extensions, guides: this.guides, swatches: this.swatches, mixingPad: this.mixingPadJSON() });
2864
3329
  }
2865
3330
  adopt(loaded) {
2866
3331
  this.extensions = loaded.extensions;
3332
+ this.readMixingPad(loaded.mixingPad, loaded.problems);
2867
3333
  this.readGuides(loaded.guides, loaded.state, loaded.problems);
2868
3334
  if (Array.isArray(loaded.swatches)) {
2869
3335
  this.swatches = loaded.swatches.filter((x) => Boolean(x) && typeof x.color === "string" && /^#[0-9a-f]{6}$/i.test(x.color)).map((x) => typeof x.name === "string" ? { color: x.color.toLowerCase(), name: x.name } : { color: x.color.toLowerCase() });
@@ -3218,6 +3684,99 @@ var PaintEditorController = class {
3218
3684
  this.pad.clear();
3219
3685
  this.emit([]);
3220
3686
  }
3687
+ /**
3688
+ * Mixes paints on the pad like a painter: blobs in proportion to their `parts`, knife passes,
3689
+ * and a fully mixed core of the Kubelka-Munk mix (see `MixingPad.mix`). With `setColor` the
3690
+ * mix becomes the paint colour. Returns the mixed colour and the pile's bounds.
3691
+ */
3692
+ mixOnPad(paints, options = {}) {
3693
+ const parsed = (paints ?? []).map((p, i) => ({ color: parseColor(p?.color, `paints[${i}].color`), parts: p?.parts }));
3694
+ let r;
3695
+ try {
3696
+ r = this.pad.mix(parsed, options);
3697
+ } catch (e) {
3698
+ throw new PaintEditorError(e.message);
3699
+ }
3700
+ const color = r.color ?? parsed[0].color;
3701
+ this.emit([]);
3702
+ if (options.setColor) this.setColor(color);
3703
+ return { color, bounds: r.bounds };
3704
+ }
3705
+ /** The mixing pad as a PNG data URL (with alpha). */
3706
+ mixingPadPng() {
3707
+ return this.pad.toPng();
3708
+ }
3709
+ /** Replaces the mixing pad with a PNG (data URL or base64, 360 × 220). */
3710
+ setMixingPadPng(png) {
3711
+ try {
3712
+ this.pad.fromPng(png);
3713
+ } catch (e) {
3714
+ throw new PaintEditorError(e.message);
3715
+ }
3716
+ this.emit([]);
3717
+ }
3718
+ /** The mixing pads kept by name. */
3719
+ listMixingPads() {
3720
+ return this.savedPads.map(({ id, name }) => ({ id, name }));
3721
+ }
3722
+ /** Keeps the mixing pad as it is now under `name` (saved with the document). Returns its id. */
3723
+ saveMixingPad(name) {
3724
+ if (this.pad.empty) throw new PaintEditorError("The mixing pad is empty; put paint on it (mixing_pad_stroke or mix_on_pad) before saving it.");
3725
+ const label = (typeof name === "string" && name.trim() ? name.trim() : `Pad ${this.nextPadId}`).slice(0, 80);
3726
+ const pad = { id: `pad${this.nextPadId++}`, name: label, png: this.pad.toPng() };
3727
+ this.savedPads.push(pad);
3728
+ this.emit([]);
3729
+ return { id: pad.id, name: pad.name };
3730
+ }
3731
+ requirePad(id) {
3732
+ const p = this.savedPads.find((x) => x.id === id);
3733
+ if (!p) throw new PaintEditorError(`Unknown mixing pad "${id}". Call list_mixing_pads for the ids.`);
3734
+ return p;
3735
+ }
3736
+ /** Puts a kept pad back on the mixing pad (replacing what is there). */
3737
+ loadMixingPad(id) {
3738
+ const p = this.requirePad(id);
3739
+ this.setMixingPadPng(p.png);
3740
+ return { id: p.id, name: p.name };
3741
+ }
3742
+ /** Forgets a kept pad. */
3743
+ removeMixingPad(id) {
3744
+ const p = this.requirePad(id);
3745
+ this.savedPads = this.savedPads.filter((x) => x !== p);
3746
+ this.emit([]);
3747
+ return { id: p.id, name: p.name };
3748
+ }
3749
+ mixingPadJSON() {
3750
+ if (this.pad.empty && !this.savedPads.length) return void 0;
3751
+ return { ...this.pad.empty ? {} : { current: this.pad.toPng() }, saved: this.savedPads.map((p) => ({ ...p })) };
3752
+ }
3753
+ readMixingPad(raw, problems) {
3754
+ this.savedPads = [];
3755
+ this.nextPadId = 1;
3756
+ this.pad.clear();
3757
+ if (raw === void 0 || raw === null) return;
3758
+ const r = raw;
3759
+ if (typeof r.current === "string") {
3760
+ try {
3761
+ this.pad.fromPng(r.current);
3762
+ } catch (e) {
3763
+ problems.push(`The mixing pad could not be read (${e.message}); it starts empty.`);
3764
+ }
3765
+ }
3766
+ if (Array.isArray(r.saved)) {
3767
+ r.saved.forEach((p, i) => {
3768
+ const x = p;
3769
+ if (!x || typeof x.png !== "string" || typeof x.name !== "string") {
3770
+ problems.push(`mixingPad.saved[${i}] is not a saved pad; skipped.`);
3771
+ return;
3772
+ }
3773
+ const id = typeof x.id === "string" && /^pad\d+$/.test(x.id) ? x.id : `pad${this.nextPadId}`;
3774
+ const n = Number(id.slice(3));
3775
+ if (n >= this.nextPadId) this.nextPadId = n + 1;
3776
+ this.savedPads.push({ id, name: x.name, png: x.png });
3777
+ });
3778
+ }
3779
+ }
3221
3780
  /** A layer's tile surface (live while stroking), for thumbnails. */
3222
3781
  layerSurface(id) {
3223
3782
  const l = findLayer(this.currentLayers(), id)?.layer;
@@ -3675,7 +4234,7 @@ function createPaintGuideTools(c) {
3675
4234
  {
3676
4235
  name: "mixing_pad_stroke",
3677
4236
  title: "Paint on the mixing pad",
3678
- description: "Paints on the mixing pad (a 360 \xD7 220 scratch canvas, not the painting): tool paint lays down a colour with a pigment Mixer Brush; tool knife smears what is there like a palette knife, mixing it as paint. Points are pad pixels. Not an undo step. Returns the changed bounds; then pick_mixing_pad_color.",
4237
+ description: "Paints on the mixing pad (a 360 \xD7 220 scratch canvas, not the painting): tool paint lays down a colour with a pigment Mixer Brush; tool knife smears what is there like a palette knife, mixing it as paint. Points are pad pixels. Not an undo step. Returns the changed bounds; then pick_mixing_pad_color. For a measured mix in one call use mix_on_pad.",
3679
4238
  inputSchema: {
3680
4239
  type: "object",
3681
4240
  properties: {
@@ -3730,6 +4289,73 @@ function createPaintGuideTools(c) {
3730
4289
  return { cleared: true };
3731
4290
  }
3732
4291
  },
4292
+ {
4293
+ name: "mix_on_pad",
4294
+ title: "Mix paints on the pad",
4295
+ description: "Mixes paints on the mixing pad as a painter would: blobs whose areas follow their parts, worked with the palette knife, and a fully mixed core of the Kubelka-Munk (pigment) mix, so blue and yellow make green and white tints. One call per mix. Not an undo step (the pad is scratch space). Returns the mixed colour #rrggbb and the pile bounds in pad pixels; with setColor it becomes the paint colour. pick_mixing_pad_color reads the streaks around it.",
4296
+ inputSchema: {
4297
+ type: "object",
4298
+ properties: {
4299
+ paints: {
4300
+ type: "array",
4301
+ minItems: 1,
4302
+ maxItems: 8,
4303
+ description: "The paints, 1 to 8.",
4304
+ items: {
4305
+ type: "object",
4306
+ properties: {
4307
+ color: { ...COLOR, description: "Paint colour #rrggbb." },
4308
+ parts: { type: "number", minimum: 0, description: "Parts of this paint, any scale (1 and 3 for a 1:3 mix); at least one above 0." }
4309
+ },
4310
+ required: ["color", "parts"],
4311
+ additionalProperties: false
4312
+ }
4313
+ },
4314
+ x: { type: "number", minimum: 0, maximum: 360, description: "Centre of the pile, pad x (default 180)." },
4315
+ y: { type: "number", minimum: 0, maximum: 220, description: "Centre of the pile, pad y (default 110)." },
4316
+ radius: { type: "number", minimum: 12, maximum: 100, description: "Radius of the pile in pad pixels (default 40); it must fit on the pad." },
4317
+ passes: { type: "integer", minimum: 1, maximum: 20, description: "Palette-knife passes (default 3; more gives finer streaks around the core)." },
4318
+ clear: { type: "boolean", description: "Wipe the pile's area first (default true); false mixes into the paint already there." },
4319
+ setColor: { type: "boolean", description: "Also make the mix the paint colour (default false)." }
4320
+ },
4321
+ required: ["paints"],
4322
+ additionalProperties: false
4323
+ },
4324
+ annotations: { title: "Mix paints on the pad", ...WRITE },
4325
+ handler: ({ paints, x, y, radius, passes, clear, setColor }) => c.mixOnPad(paints, { x, y, radius, passes, clear, setColor })
4326
+ },
4327
+ {
4328
+ name: "save_mixing_pad",
4329
+ title: "Keep the mixing pad",
4330
+ description: "Keeps the mixing pad as it is now under a name, saved with the document, to come back to with load_mixing_pad. Refused while the pad is empty. Returns the kept pad id and name.",
4331
+ inputSchema: { type: "object", properties: { name: { type: "string", minLength: 1, maxLength: 80, description: 'Name (default "Pad n").' } }, additionalProperties: false },
4332
+ annotations: { title: "Keep the mixing pad", ...WRITE },
4333
+ handler: ({ name }) => c.saveMixingPad(name)
4334
+ },
4335
+ {
4336
+ name: "list_mixing_pads",
4337
+ title: "List kept mixing pads",
4338
+ description: "Lists the mixing pads kept with save_mixing_pad (id, name). Changes nothing.",
4339
+ inputSchema: EMPTY,
4340
+ annotations: { title: "List kept mixing pads", ...READ },
4341
+ handler: () => ({ pads: c.listMixingPads() })
4342
+ },
4343
+ {
4344
+ name: "load_mixing_pad",
4345
+ title: "Load a kept mixing pad",
4346
+ description: "Puts a kept pad (id from list_mixing_pads) back on the mixing pad, replacing what is on it. Returns the pad id and name.",
4347
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "Kept pad id from list_mixing_pads." } }, required: ["id"], additionalProperties: false },
4348
+ annotations: { title: "Load a kept mixing pad", ...DESTRUCTIVE },
4349
+ handler: ({ id }) => c.loadMixingPad(id)
4350
+ },
4351
+ {
4352
+ name: "remove_mixing_pad",
4353
+ title: "Remove a kept mixing pad",
4354
+ description: "Forgets a kept pad (id from list_mixing_pads); the pad on screen is untouched. Returns the removed id and name.",
4355
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "Kept pad id from list_mixing_pads." } }, required: ["id"], additionalProperties: false },
4356
+ annotations: { title: "Remove a kept mixing pad", ...DESTRUCTIVE },
4357
+ handler: ({ id }) => c.removeMixingPad(id)
4358
+ },
3733
4359
  {
3734
4360
  name: "render_mixing_pad",
3735
4361
  title: "Render the mixing pad",
@@ -3799,28 +4425,79 @@ var DYNAMICS = {
3799
4425
  };
3800
4426
  var MIXER = {
3801
4427
  type: "object",
3802
- description: "Mixer engine settings (Mixer Brush, blender, smudge). Missing fields keep their current values.",
4428
+ description: "Mixer engine settings (the Mixer Brush). Missing fields keep their current values.",
3803
4429
  properties: {
3804
- wet: { type: "number", minimum: 0, maximum: 1, description: "How much canvas colour each dab picks up, 0 (dry) .. 1." },
4430
+ wet: { type: "number", minimum: 0, maximum: 1, description: "Pick-up ratio: how much canvas colour each dab picks up, 0 (dry) .. 1." },
3805
4431
  load: { type: "number", minimum: 0, maximum: 1, description: "How much paint the reservoir holds; below 1 it drains per dab, so long strokes run dry." },
3806
- mix: { type: "number", minimum: 0, maximum: 1, description: "How far each dab's paint moves from the reservoir towards the picked-up colour." },
4432
+ mix: { type: "number", minimum: 0, maximum: 1, description: "Mix ratio: how far each dab's paint moves from the reservoir towards the picked-up colour." },
3807
4433
  loadEachStroke: { type: "boolean", description: "Refill the reservoir with the colour before each stroke." },
3808
4434
  cleanEachStroke: { type: "boolean", description: "Empty the pickup well after each stroke." },
3809
4435
  mixing: { type: "string", enum: [...MIXING_MODELS], description: "rgb: RGB averages. pigment: mixes like paint (Kubelka-Munk)." }
3810
4436
  },
3811
4437
  additionalProperties: false
3812
4438
  };
4439
+ var MIXING = { type: "string", enum: [...MIXING_MODELS], description: "pigment: mixes like paint (Kubelka-Munk, blue and yellow make green); rgb: straight RGB averages." };
4440
+ var BEHAVIOR = {
4441
+ type: "object",
4442
+ description: "How the brush handles paint on the canvas (dab and mixer engines; on a mixer brush pickup, mix, dirty and mixing are its mixer wet, mix, !cleanEachStroke and mixing). Missing fields keep their current values.",
4443
+ properties: {
4444
+ pickup: { type: "number", minimum: 0, maximum: 1, description: "Pick-up ratio 0..1: how much of the paint under each dab the brush lifts and carries along (0: none, a dry brush)." },
4445
+ mix: { type: "number", minimum: 0, maximum: 1, description: "Mix ratio 0..1: how much of the carried paint goes into what the brush lays down, against the brush's own colour (scaled by pickup)." },
4446
+ dirty: { type: "boolean", description: "Dirty brush: keep the carried paint after the stroke, so the next stroke starts with the last colour it touched (needs pickup above 0). clean_mixer_brush wipes it." },
4447
+ mixing: MIXING,
4448
+ multicolor: {
4449
+ type: "object",
4450
+ description: "Multi-coloured brush. Missing fields keep their current values.",
4451
+ properties: {
4452
+ mode: { type: "string", enum: [...MULTI_COLOR_MODES], description: "off; tip: the colours lie side by side across the tip (stripes along the stroke, like a brush loaded with several paints); dab: the colour changes along the stroke." },
4453
+ colors: { type: "array", maxItems: 8, items: { ...COLOR2 }, description: "2..8 colours #rrggbb; fewer than 2 uses the paint colour and the secondary colour." },
4454
+ order: { type: "string", enum: ["sequence", "random"], description: "dab mode: sequence runs through the colours in order, blending between them; random picks one per run of dabs (seeded)." },
4455
+ length: { type: "integer", minimum: 1, maximum: 500, description: "dab mode: dabs per colour (default 12)." },
4456
+ softness: { type: "number", minimum: 0, maximum: 1, description: "How softly neighbouring colours blend: 0 hard stripes or steps, 1 a smooth gradient." }
4457
+ },
4458
+ additionalProperties: false
4459
+ }
4460
+ },
4461
+ additionalProperties: false
4462
+ };
4463
+ var BLENDER = {
4464
+ type: "object",
4465
+ description: "Blender engine settings. Missing fields keep their current values.",
4466
+ properties: {
4467
+ strength: { type: "number", minimum: 0, maximum: 1, description: "How far each pixel under the tip moves towards the mix of the paint near it, per dab." },
4468
+ distance: { type: "number", minimum: 0, maximum: 1, description: "Blend distance: how far away colours are mixed in, as a fraction of the tip diameter (0.35 default; higher blends wider and softer)." },
4469
+ mixing: MIXING
4470
+ },
4471
+ additionalProperties: false
4472
+ };
4473
+ var SMUDGE = {
4474
+ type: "object",
4475
+ description: "Smudge engine settings. Missing fields keep their current values.",
4476
+ properties: {
4477
+ strength: { type: "number", minimum: 0, maximum: 1, description: "How strongly the dragged paint covers what is under the tip, per dab." },
4478
+ length: { type: "number", minimum: 0, maximum: 1, description: "How far paint is dragged: the share of the carried paint kept from dab to dab (0.85 default; 1 drags it the whole stroke)." },
4479
+ mixing: { ...MIXING, description: "rgb (default): smeared colours average; pigment: they mix like paint." }
4480
+ },
4481
+ additionalProperties: false
4482
+ };
3813
4483
  var BRUSH_FIELDS = {
3814
4484
  name: { type: "string", minLength: 1, maxLength: 80, description: "Display name." },
3815
4485
  category: { type: "string", enum: [...PAINT_BRUSH_CATEGORIES], description: "Library category." },
3816
- engine: { type: "string", enum: [...PAINT_BRUSH_ENGINES], description: "dab: paints dabs; eraser: removes paint; mixer: picks up and moves paint (Mixer Brush, blender, smudge)." },
4486
+ engine: {
4487
+ type: "string",
4488
+ enum: [...PAINT_BRUSH_ENGINES],
4489
+ description: "dab: paints dabs (with behavior.pickup or multicolor it paints dab by dab, lifting and mixing paint); eraser: removes paint; mixer: the Mixer Brush (a reservoir plus picked-up paint); blender: mixes the colours under and near the tip; smudge: drags paint along the stroke."
4490
+ },
3817
4491
  tip: TIP,
3818
4492
  grain: { type: "number", minimum: 0, maximum: 1, description: "Paper grain response: 0 ignores the grain, 1 catches only its peaks at light pressure (pencil, pastel, charcoal). Multiplied by the paper depth." },
3819
4493
  pressureSmoothing: { type: "number", minimum: 0, maximum: 0.95, description: "Pressure smoothing, separate from the tip position smoothing." },
3820
4494
  rotation: { type: "string", enum: [...BRUSH_ROTATIONS], description: "What turns the tip: fixed (tip.angle), stroke (follows the direction of travel) or barrel (the pen barrel rotation, twist)." },
3821
4495
  dynamics: DYNAMICS,
3822
4496
  mixer: MIXER,
3823
- loadsColor: { type: "boolean", description: "Mixer engine: load the colour each stroke (false for blender and smudge, which only move paint)." }
4497
+ loadsColor: { type: "boolean", description: "Mixer engine: load the colour each stroke (false for a mixer that only moves paint)." },
4498
+ behavior: BEHAVIOR,
4499
+ blender: BLENDER,
4500
+ smudge: SMUDGE
3824
4501
  };
3825
4502
  var POINTS = {
3826
4503
  type: "array",
@@ -3849,7 +4526,20 @@ function loadResult(load) {
3849
4526
  return { reservoir: hex(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex(load.pickup) };
3850
4527
  }
3851
4528
  function brushSummary(b) {
3852
- return { id: b.id, name: b.name, category: b.category, engine: b.engine, size: b.tip.size, grain: b.grain, rotation: b.rotation, builtin: Boolean(b.builtin) };
4529
+ return {
4530
+ id: b.id,
4531
+ name: b.name,
4532
+ category: b.category,
4533
+ engine: b.engine,
4534
+ size: b.tip.size,
4535
+ grain: b.grain,
4536
+ rotation: b.rotation,
4537
+ wetCapable: isWetCapable(b),
4538
+ pickup: b.behavior.pickup,
4539
+ dirty: b.behavior.dirty,
4540
+ multicolor: b.behavior.multicolor.mode,
4541
+ builtin: Boolean(b.builtin)
4542
+ };
3853
4543
  }
3854
4544
  function createPaintEditorToolProvider(controller, options = {}) {
3855
4545
  const c = controller;
@@ -4138,7 +4828,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4138
4828
  add({
4139
4829
  name: "list_brushes",
4140
4830
  title: "List brushes",
4141
- description: "Lists the brush library: built-in dry media (pencils, pastels, charcoal, airbrush, marker, ink, erasers, blender, smudge, Mixer Brush) and saved user brushes, with id, name, category, engine, size, grain response and rotation mode, plus the active brush id.",
4831
+ description: "Lists the brush library: built-in dry media (pencils, pastels, charcoal, airbrush, marker, ink, erasers), paint brushes (dirty round, multi-colour flat, palette dabber), blending tools (blender, smudge, pigment smudge, Mixer Brush) and saved user brushes, with id, name, category, engine, size, grain response, rotation mode, whether it can pick up paint (wetCapable), its pick-up ratio, dirty and multi-colour mode, plus the active brush id.",
4142
4832
  inputSchema: EMPTY2,
4143
4833
  annotations: { title: "List brushes", ...READ2 },
4144
4834
  handler: () => {
@@ -4149,7 +4839,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4149
4839
  add({
4150
4840
  name: "get_brush",
4151
4841
  title: "Get a brush",
4152
- description: "Reads every setting of a brush (tip, grain, pressure smoothing, rotation, dynamics, mixer). Without id, the active brush as edited.",
4842
+ description: "Reads every setting of a brush (tip, grain, pressure smoothing, rotation, dynamics, mixer, behavior: pick-up / mix / dirty / multi-colour, blender, smudge). Without id, the active brush as edited.",
4153
4843
  inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brushes (default the active brush)." } }, additionalProperties: false },
4154
4844
  annotations: { title: "Get a brush", ...READ2 },
4155
4845
  handler: ({ id }) => {
@@ -4284,7 +4974,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4284
4974
  add({
4285
4975
  name: "get_mixer_brush",
4286
4976
  title: "Get the Mixer Brush load",
4287
- description: "Reads what the Mixer Brush carries between strokes: reservoir #rrggbb or null, amount 0..1 left, pickup #rrggbb or null. Call before painting with mixer-brush when its loadEachStroke is false.",
4977
+ description: "Reads what the brush carries between strokes: reservoir #rrggbb or null (the Mixer Brush's loaded paint), amount 0..1 left, pickup #rrggbb or null (paint picked up from the canvas, kept by dirty brushes and a Mixer Brush that does not clean each stroke). Call before painting with mixer-brush when its loadEachStroke is false, or with a dirty brush.",
4288
4978
  inputSchema: EMPTY2,
4289
4979
  annotations: { title: "Get the Mixer Brush load", ...READ2 },
4290
4980
  handler: () => loadResult(c.getState().mixerLoad)
@@ -4300,7 +4990,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4300
4990
  add({
4301
4991
  name: "clean_mixer_brush",
4302
4992
  title: "Clean the Mixer Brush",
4303
- description: "Empties the Mixer Brush pickup well (and with emptyReservoir its paint too). Editor state, not an undo step. Returns the load.",
4993
+ description: "Cleans the brush: empties the picked-up paint a dirty brush or the Mixer Brush carries (and with emptyReservoir the Mixer Brush paint too). Editor state, not an undo step. Returns the load.",
4304
4994
  inputSchema: { type: "object", properties: { emptyReservoir: { type: "boolean", description: "Also drop the loaded paint (default false)." } }, additionalProperties: false },
4305
4995
  annotations: { title: "Clean the Mixer Brush", ...SET2 },
4306
4996
  handler: ({ emptyReservoir }) => loadResult(c.cleanMixerBrush(emptyReservoir === true))
@@ -4308,7 +4998,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4308
4998
  add({
4309
4999
  name: "paint_stroke",
4310
5000
  title: "Paint a brush stroke",
4311
- description: "Paints one stroke through points (document pixels, with optional pressure, time, tilt and barrel rotation) on a pixel layer with a library brush or the active one, optionally changed for this stroke. Grain-responsive brushes follow the paper; the mixer engine (mixer-brush, blender, smudge) picks up and moves paint already on the layer. The symmetry guide adds its mirror copies; with a tracing image that samples colours, a stroke without color takes the image colour at its first point. With an agent cursor (a director), the stroke is painted live along the points at the pace of their times. One undo step. Returns the layer id, changed bounds, dab count, the recorded strokeId (for replay_strokes) and, for the mixer, its load after the stroke.",
5001
+ description: "Paints one stroke through points (document pixels, with optional pressure, time, tilt and barrel rotation) on a pixel layer with a library brush or the active one, optionally changed for this stroke. Grain-responsive brushes follow the paper; the mixer engine (mixer-brush) picks up and mixes paint already on the layer, the blender mixes nearby colours, the smudge drags paint, and dab brushes with behavior.pickup lift and carry paint. The symmetry guide adds its mirror copies; with a tracing image that samples colours, a stroke without color takes the image colour at its first point. With an agent cursor (a director), the stroke is painted live along the points at the pace of their times. One undo step. Returns the layer id, changed bounds, dab count, the recorded strokeId (for replay_strokes) and, for brushes that pick up paint, mixerLoad: what the brush carries after the stroke.",
4312
5002
  inputSchema: {
4313
5003
  type: "object",
4314
5004
  properties: {
@@ -4815,6 +5505,67 @@ var BLEND_MENU_GROUPS = [
4815
5505
  { label: "Standard", modes: BLEND_MODES.filter((m) => !m.painterly && m.id !== "pigment") },
4816
5506
  { label: "Paint", modes: BLEND_MODES.filter((m) => m.painterly || m.id === "pigment") }
4817
5507
  ];
5508
+ var PICKUP_SLIDERS = [
5509
+ { path: "behavior.pickup", label: "Pick-up", min: 0, max: 1, step: 0.01 },
5510
+ { path: "behavior.mix", label: "Mix", min: 0, max: 1, step: 0.01 }
5511
+ ];
5512
+ function brushEngineControls(brush) {
5513
+ switch (brush.engine) {
5514
+ case "blender":
5515
+ return {
5516
+ title: "Blender",
5517
+ sliders: [
5518
+ { path: "blender.strength", label: "Strength", min: 0, max: 1, step: 0.01 },
5519
+ { path: "blender.distance", label: "Blend distance", min: 0, max: 1, step: 0.01 }
5520
+ ],
5521
+ mixingPath: "blender.mixing",
5522
+ dirty: false,
5523
+ multicolor: false,
5524
+ load: false
5525
+ };
5526
+ case "smudge":
5527
+ return {
5528
+ title: "Smudge",
5529
+ sliders: [
5530
+ { path: "smudge.strength", label: "Strength", min: 0, max: 1, step: 0.01 },
5531
+ { path: "smudge.length", label: "Length", min: 0, max: 1, step: 0.01 }
5532
+ ],
5533
+ mixingPath: "smudge.mixing",
5534
+ dirty: false,
5535
+ multicolor: false,
5536
+ load: false
5537
+ };
5538
+ case "mixer":
5539
+ return { title: "Mixer Brush", sliders: [...PICKUP_SLIDERS, { path: "mixer.load", label: "Load", min: 0, max: 1, step: 0.01 }], mixingPath: "behavior.mixing", dirty: true, multicolor: true, load: true };
5540
+ case "dab":
5541
+ return { title: "Paint behaviour", sliders: PICKUP_SLIDERS, mixingPath: "behavior.mixing", dirty: true, multicolor: true, load: true };
5542
+ default:
5543
+ return null;
5544
+ }
5545
+ }
5546
+ function multiColorSliders(mode) {
5547
+ if (mode === "off") return [];
5548
+ const soft = { path: "behavior.multicolor.softness", label: "Colour softness", min: 0, max: 1, step: 0.01 };
5549
+ return mode === "dab" ? [soft, { path: "behavior.multicolor.length", label: "Dabs per colour", min: 1, max: 100, step: 1 }] : [soft];
5550
+ }
5551
+ function patchBrushValue(brush, path, value) {
5552
+ const set = (obj, keys) => {
5553
+ const [head2, ...rest2] = keys;
5554
+ if (!rest2.length) return { ...obj, [head2]: value };
5555
+ return { ...obj, [head2]: set(obj[head2] ?? {}, rest2) };
5556
+ };
5557
+ const [head, ...rest] = path.split(".");
5558
+ if (!rest.length) return { [head]: value };
5559
+ return { [head]: set({ ...brush[head] ?? {} }, rest) };
5560
+ }
5561
+ function brushLoadColor(load) {
5562
+ const c = load.pickup ?? load.reservoir;
5563
+ return c ? rgbToHexColor(Math.round(c[0] * 255), Math.round(c[1] * 255), Math.round(c[2] * 255)) : null;
5564
+ }
5565
+ var PAD_SAMPLE_SIZES = [1, 3, 5, 9, 15];
5566
+ function padSampleRadius(size) {
5567
+ return Math.max(0, Math.floor((size - 1) / 2));
5568
+ }
4818
5569
  export {
4819
5570
  BLEND_MENU_GROUPS,
4820
5571
  BLEND_MODES,
@@ -4822,9 +5573,11 @@ export {
4822
5573
  BRUSH_ROTATIONS,
4823
5574
  BRUSH_SLIDERS,
4824
5575
  BUILTIN_BRUSHES,
5576
+ DEFAULT_BEHAVIOR,
4825
5577
  DEFAULT_BRUSH_ID,
4826
5578
  DEFAULT_DPI,
4827
5579
  DEFAULT_DYNAMICS,
5580
+ DEFAULT_MULTI_COLOR,
4828
5581
  DEFAULT_PAPER,
4829
5582
  DEFAULT_SWATCHES,
4830
5583
  DEFAULT_VIEW,
@@ -4843,7 +5596,9 @@ export {
4843
5596
  MIN_ZOOM,
4844
5597
  MIXING_PAD_HEIGHT,
4845
5598
  MIXING_PAD_WIDTH,
5599
+ MULTI_COLOR_MODES,
4846
5600
  MixingPad,
5601
+ PAD_SAMPLE_SIZES,
4847
5602
  PAINTERLY_BLEND_MODES,
4848
5603
  PAINT_BRUSH_CATEGORIES,
4849
5604
  PAINT_BRUSH_ENGINES,
@@ -4855,6 +5610,7 @@ export {
4855
5610
  PAPER_NODE_ID,
4856
5611
  PAPER_SLIDERS,
4857
5612
  PaintCanvasPresenter,
5613
+ PaintDabStroke,
4858
5614
  PaintDirector,
4859
5615
  PaintDocumentError,
4860
5616
  PaintEditorController,
@@ -4872,6 +5628,9 @@ export {
4872
5628
  TileableNoise,
4873
5629
  affineCopy,
4874
5630
  applyAffine,
5631
+ brushEngineControls,
5632
+ brushLoadColor,
5633
+ brushLoadForStroke,
4875
5634
  brushToJSON,
4876
5635
  clonePaper,
4877
5636
  colorHarmony,
@@ -4903,26 +5662,35 @@ export {
4903
5662
  isBlendMode,
4904
5663
  isPainterlyBlendMode,
4905
5664
  isPalmTouch,
5665
+ isWetCapable,
4906
5666
  mapPenPoint,
4907
5667
  mix32,
4908
5668
  mixerLoadForStroke,
4909
5669
  mixerTip,
5670
+ multiColorForDab,
5671
+ multiColorGradient,
5672
+ multiColorPalette,
5673
+ multiColorSliders,
4910
5674
  newPaintLayerId,
5675
+ normalizeBehavior,
4911
5676
  normalizeBrushStroke,
4912
5677
  normalizeCanvas,
4913
5678
  normalizeDynamics,
5679
+ normalizeMultiColor,
4914
5680
  normalizePaintBrush,
4915
5681
  normalizePaper,
4916
5682
  normalizePerspective,
4917
5683
  normalizeRotation,
4918
5684
  normalizeSymmetry,
4919
5685
  normalizeView,
5686
+ padSampleRadius,
4920
5687
  paperGrain,
4921
5688
  paperKey,
4922
5689
  paperNode,
4923
5690
  paperPreset,
4924
5691
  parseBrushLibrary,
4925
5692
  parseHexColor,
5693
+ patchBrushValue,
4926
5694
  patchPath,
4927
5695
  penOrientation,
4928
5696
  perspectiveDirections,
@@ -4953,6 +5721,7 @@ export {
4953
5721
  thumbnailStroke,
4954
5722
  tiltToOrientation,
4955
5723
  toStrokePoint,
5724
+ usesPaintExchange,
4956
5725
  variationAffine,
4957
5726
  varyStroke,
4958
5727
  viewCssTransform,