@pixerate/editor 0.109.0 → 0.111.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 (66) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -0
  2. package/dist/blending-DXl06NSx.d.ts +486 -0
  3. package/dist/blending-aOE6Y3UE.d.cts +486 -0
  4. package/dist/chunk-7VOHOPLW.cjs +1136 -0
  5. package/dist/chunk-7VOHOPLW.cjs.map +1 -0
  6. package/dist/chunk-FTBZPI77.cjs +1 -0
  7. package/dist/chunk-FTBZPI77.cjs.map +1 -0
  8. package/dist/{chunk-RE77QYUF.js → chunk-HHZ6ZJTW.js} +880 -86
  9. package/dist/chunk-HHZ6ZJTW.js.map +1 -0
  10. package/dist/{chunk-WMBIJCHA.js → chunk-HNS7UHCM.js} +5 -1
  11. package/dist/{chunk-WMBIJCHA.js.map → chunk-HNS7UHCM.js.map} +1 -1
  12. package/dist/{chunk-CFMWALBM.js → chunk-LQEEDHOP.js} +2 -2
  13. package/dist/{chunk-D37BX2ZA.cjs → chunk-PHUMLSCW.cjs} +1141 -347
  14. package/dist/chunk-PHUMLSCW.cjs.map +1 -0
  15. package/dist/{chunk-AZTZRLDI.cjs → chunk-POGBUYWE.cjs} +92 -92
  16. package/dist/{chunk-AZTZRLDI.cjs.map → chunk-POGBUYWE.cjs.map} +1 -1
  17. package/dist/chunk-VKK5VS2T.js +1 -0
  18. package/dist/{chunk-SZ6KMZNM.cjs → chunk-XIOMMGPD.cjs} +6 -2
  19. package/dist/chunk-XIOMMGPD.cjs.map +1 -0
  20. package/dist/{chunk-3PHA3UKI.js → chunk-Z4BC6M74.js} +523 -121
  21. package/dist/chunk-Z4BC6M74.js.map +1 -0
  22. package/dist/{export-BLPLaE6Z.d.ts → export-DBNVwb6h.d.ts} +521 -12
  23. package/dist/{export-DSUc4fQD.d.cts → export-DoDhCszr.d.cts} +521 -12
  24. package/dist/image-editor/index.cjs +1630 -900
  25. package/dist/image-editor/index.cjs.map +1 -1
  26. package/dist/image-editor/index.d.cts +104 -16
  27. package/dist/image-editor/index.d.ts +104 -16
  28. package/dist/image-editor/index.js +776 -46
  29. package/dist/image-editor/index.js.map +1 -1
  30. package/dist/paint/index.cjs +28 -4
  31. package/dist/paint/index.cjs.map +1 -1
  32. package/dist/paint/index.d.cts +3 -89
  33. package/dist/paint/index.d.ts +3 -89
  34. package/dist/paint/index.js +27 -3
  35. package/dist/paint-editor/index.cjs +1052 -286
  36. package/dist/paint-editor/index.cjs.map +1 -1
  37. package/dist/paint-editor/index.d.cts +302 -23
  38. package/dist/paint-editor/index.d.ts +302 -23
  39. package/dist/paint-editor/index.js +848 -82
  40. package/dist/paint-editor/index.js.map +1 -1
  41. package/dist/raster/index.cjs +5 -3
  42. package/dist/raster/index.cjs.map +1 -1
  43. package/dist/raster/index.d.cts +2 -2
  44. package/dist/raster/index.d.ts +2 -2
  45. package/dist/raster/index.js +4 -2
  46. package/dist/{render-C-fO_mes.d.cts → render-BEriFuqK.d.cts} +1 -1
  47. package/dist/{render-OLIMk_v0.d.ts → render-tjzKaik-.d.ts} +1 -1
  48. package/dist/{surface-CkKOHGPm.d.cts → surface-C8RRg_Bh.d.cts} +109 -1
  49. package/dist/{surface-CkKOHGPm.d.ts → surface-C8RRg_Bh.d.ts} +109 -1
  50. package/package.json +1 -1
  51. package/skills/pixerate-image-editor/SKILL.md +27 -30
  52. package/skills/pixerate-image-editor/references/shapes-and-type.md +60 -0
  53. package/skills/pixerate-paint-editor/SKILL.md +49 -13
  54. package/dist/chunk-3PHA3UKI.js.map +0 -1
  55. package/dist/chunk-6BU5WU4R.cjs +0 -1
  56. package/dist/chunk-6BU5WU4R.cjs.map +0 -1
  57. package/dist/chunk-D37BX2ZA.cjs.map +0 -1
  58. package/dist/chunk-HUFQD4CB.cjs +0 -734
  59. package/dist/chunk-HUFQD4CB.cjs.map +0 -1
  60. package/dist/chunk-RE77QYUF.js.map +0 -1
  61. package/dist/chunk-SZ6KMZNM.cjs.map +0 -1
  62. package/dist/chunk-XYBJKUAU.js +0 -1
  63. package/dist/mixer-5ziHU8OS.d.cts +0 -282
  64. package/dist/mixer-DNqiYXYQ.d.ts +0 -282
  65. /package/dist/{chunk-CFMWALBM.js.map → chunk-LQEEDHOP.js.map} +0 -0
  66. /package/dist/{chunk-XYBJKUAU.js.map → chunk-VKK5VS2T.js.map} +0 -0
@@ -4,9 +4,10 @@ import {
4
4
  resolveExternalAssets,
5
5
  serializeLayers,
6
6
  serializeLayersExternal
7
- } from "../chunk-RE77QYUF.js";
7
+ } from "../chunk-HHZ6ZJTW.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-CFMWALBM.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,13 +59,16 @@ 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,
@@ -438,8 +453,11 @@ function createPaintDocument(options = {}) {
438
453
  }
439
454
 
440
455
  // src/paint-editor/brushes.ts
441
- var PAINT_BRUSH_ENGINES = ["dab", "eraser", "mixer"];
442
- 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 };
443
461
  var BRUSH_ROTATIONS = ["fixed", "stroke", "barrel"];
444
462
  var DEFAULT_DYNAMICS = {
445
463
  minSize: 0,
@@ -472,21 +490,62 @@ function normalizeDynamics(raw = {}, base = DEFAULT_DYNAMICS) {
472
490
  };
473
491
  }
474
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
+ }
475
519
  function normalizePaintBrush(raw = {}, base) {
476
520
  const b = base ?? BASE_BRUSH;
477
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 };
478
534
  return {
479
535
  id,
480
536
  name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, 80) : b.name,
481
537
  category: PAINT_BRUSH_CATEGORIES.includes(raw.category) ? raw.category : b.category,
482
- engine: PAINT_BRUSH_ENGINES.includes(raw.engine) ? raw.engine : b.engine,
538
+ engine,
483
539
  tip: normalizeBrush({ ...b.tip, ...raw.tip ?? {} }),
484
540
  grain: unit2(raw.grain, b.grain),
485
541
  pressureSmoothing: range(raw.pressureSmoothing, 0, 0.95, b.pressureSmoothing),
486
542
  rotation: BRUSH_ROTATIONS.includes(raw.rotation) ? raw.rotation : b.rotation,
487
543
  dynamics: normalizeDynamics(raw.dynamics ?? {}, b.dynamics),
488
- mixer: normalizeMixer({ ...b.mixer, ...raw.mixer ?? {} }),
544
+ mixer,
489
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),
490
549
  ...raw.builtin ? { builtin: true } : {}
491
550
  };
492
551
  }
@@ -501,7 +560,10 @@ var BASE_BRUSH = {
501
560
  rotation: "fixed",
502
561
  dynamics: { ...DEFAULT_DYNAMICS },
503
562
  mixer: { ...DEFAULT_MIXER },
504
- loadsColor: true
563
+ loadsColor: true,
564
+ behavior: { ...DEFAULT_BEHAVIOR, multicolor: { ...DEFAULT_MULTI_COLOR, colors: [] } },
565
+ blender: { ...DEFAULT_BLENDER },
566
+ smudge: { ...DEFAULT_SMUDGE }
505
567
  };
506
568
  var builtin = (init) => ({ ...normalizePaintBrush(init), builtin: true });
507
569
  var BUILTIN_BRUSHES = [
@@ -615,19 +677,54 @@ var BUILTIN_BRUSHES = [
615
677
  id: "blender",
616
678
  name: "Blender",
617
679
  category: "blender",
618
- engine: "mixer",
680
+ engine: "blender",
619
681
  loadsColor: false,
620
- tip: { size: 34, hardness: 0.4, spacing: 0.08, opacity: 1, flow: 0.35, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
621
- 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" }
622
684
  }),
623
685
  builtin({
624
686
  id: "smudge",
625
687
  name: "Smudge",
626
688
  category: "smudge",
627
- engine: "mixer",
689
+ engine: "smudge",
628
690
  loadsColor: false,
629
- tip: { size: 26, hardness: 0.6, spacing: 0.06, opacity: 1, flow: 0.8, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
630
- 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 }
631
728
  }),
632
729
  builtin({
633
730
  id: "mixer-brush",
@@ -807,7 +904,9 @@ var PenDabWalker = class {
807
904
  opacity: Math.min(1, Math.max(0, opacity)),
808
905
  angle,
809
906
  roundness: Math.min(1, Math.max(0.01, tip.roundness * (1 - dyn.tiltRoundness * s.lean * 0.8))),
810
- pressure: s.pressure
907
+ pressure: s.pressure,
908
+ direction: this.dir,
909
+ index: i
811
910
  };
812
911
  }
813
912
  push(p, out) {
@@ -938,6 +1037,248 @@ function toStrokePoint(p) {
938
1037
  return { x: p.x, y: p.y, pressure: pressureOf(p), ...typeof p.t === "number" ? { t: p.t } : {} };
939
1038
  }
940
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
+
941
1282
  // src/paint-editor/stroke.ts
942
1283
  function defaultTile(surface) {
943
1284
  const data = new Uint8ClampedArray(TILE * TILE * surface.channels);
@@ -976,10 +1317,18 @@ var PaintStrokeSession = class {
976
1317
  selection: null,
977
1318
  offset: init.offset
978
1319
  };
979
- if (brush.engine === "mixer") {
980
- this.mixerSmoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
981
- const load = init.mixerLoad;
982
- 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
+ });
983
1332
  } else {
984
1333
  this.dry = new DryStroke(brush, { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies });
985
1334
  }
@@ -994,15 +1343,14 @@ var PaintStrokeSession = class {
994
1343
  touched = /* @__PURE__ */ new Set();
995
1344
  fresh = /* @__PURE__ */ new Set();
996
1345
  dry = null;
997
- mixers = [];
998
- mixerSmoother = null;
1346
+ wet = null;
999
1347
  apply;
1000
1348
  copies;
1001
1349
  bounds = null;
1002
1350
  lastRaw = null;
1003
1351
  closed = false;
1004
1352
  result = null;
1005
- mixerTarget() {
1353
+ tileTarget() {
1006
1354
  return {
1007
1355
  offset: this.init.offset,
1008
1356
  lockTransparency: this.init.lockTransparency,
@@ -1030,15 +1378,7 @@ var PaintStrokeSession = class {
1030
1378
  if (filter) points = points.map(filter);
1031
1379
  for (const p of points) this.points.push(p);
1032
1380
  this.lastRaw = points[points.length - 1];
1033
- if (this.dry) this.update(this.dry.push(points));
1034
- else {
1035
- const rects = [];
1036
- for (const raw of points) {
1037
- const p = toStrokePoint(this.mixerSmoother.push(raw));
1038
- this.copies.forEach((c, i) => rects.push(...this.mixers[i].push([toStrokePoint(c.map(p))])));
1039
- }
1040
- this.update(rects);
1041
- }
1381
+ this.update(this.dry ? this.dry.push(points) : this.wet.push(points));
1042
1382
  }
1043
1383
  update(rects) {
1044
1384
  if (!rects.length) return;
@@ -1071,13 +1411,7 @@ var PaintStrokeSession = class {
1071
1411
  /** Ends the stroke (one undo step) and returns what changed. */
1072
1412
  end() {
1073
1413
  if (this.closed) throw new Error("This stroke has already ended.");
1074
- if (this.dry) this.update(this.dry.finish());
1075
- else {
1076
- const rects = [];
1077
- const last = this.lastRaw ? toStrokePoint(this.lastRaw) : void 0;
1078
- this.copies.forEach((c, i) => rects.push(...this.mixers[i].finish(last ? toStrokePoint(c.map(last)) : void 0)));
1079
- this.update(rects);
1080
- }
1414
+ this.update(this.dry ? this.dry.finish() : this.wet.finish());
1081
1415
  this.closed = true;
1082
1416
  const ch = this.base.channels;
1083
1417
  const tiles = new Map(this.base.tiles);
@@ -1090,14 +1424,10 @@ var PaintStrokeSession = class {
1090
1424
  if (!sameTile(prev, tile)) changed = true;
1091
1425
  }
1092
1426
  const surface = changed ? new TiledSurface(ch, tiles, this.base.defaultValue) : null;
1093
- 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;
1094
1428
  this.result = { layerId: this.layerId, bounds: changed ? this.bounds : null, dabs, strokeId: null };
1095
- if (this.mixers.length) {
1096
- const brush = this.init.brush;
1097
- let load = this.mixers[0].load;
1098
- if (brush.mixer.cleanEachStroke) load = cleanMixer(load);
1099
- this.result.mixerLoad = load;
1100
- }
1429
+ const carried = this.wet?.load;
1430
+ if (carried) this.result.mixerLoad = this.init.brush.behavior.dirty ? carried : cleanMixer(carried);
1101
1431
  this.host.commit(this, surface, false);
1102
1432
  return this.result;
1103
1433
  }
@@ -1113,6 +1443,11 @@ function mixerLoadForStroke(brush, current, color) {
1113
1443
  if (brush.mixer.loadEachStroke || !current.reservoir) return loadMixer(current, color);
1114
1444
  return current;
1115
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
+ }
1116
1451
 
1117
1452
  // src/paint-editor/strokes.ts
1118
1453
  var IDENTITY_AFFINE = [1, 0, 0, 1, 0, 0];
@@ -1158,6 +1493,17 @@ function readPoint(raw) {
1158
1493
  function readAffine(raw) {
1159
1494
  return Array.isArray(raw) && raw.length === 6 && raw.every(finite) ? raw : null;
1160
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
+ }
1161
1507
  function normalizeBrushStroke(raw) {
1162
1508
  if (!raw || typeof raw !== "object") return null;
1163
1509
  const r = raw;
@@ -1174,6 +1520,8 @@ function normalizeBrushStroke(raw) {
1174
1520
  };
1175
1521
  const copies = Array.isArray(r.copies) ? r.copies.map(readAffine).filter((m) => m !== null) : [];
1176
1522
  if (copies.length) out.copies = copies;
1523
+ const load = readLoad(r.load);
1524
+ if (load) out.load = load;
1177
1525
  return out;
1178
1526
  }
1179
1527
  function strokeBounds(points) {
@@ -1325,7 +1673,7 @@ function downsampleRgba(data, width, height, tw, th) {
1325
1673
  // src/paint-editor/serialize.ts
1326
1674
  var PAINT_FORMAT = "pixerate-paint";
1327
1675
  var PAINT_SCHEMA_VERSION = 1;
1328
- 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"]);
1329
1677
  function documentExtensions(raw) {
1330
1678
  const out = {};
1331
1679
  for (const [k, v] of Object.entries(raw)) if (!KNOWN.has(k)) out[k] = v;
@@ -1345,6 +1693,7 @@ function header(state, options) {
1345
1693
  if (options.strokes !== false && state.strokes.length) out.strokes = JSON.parse(JSON.stringify(state.strokes));
1346
1694
  if (options.guides !== void 0) out.guides = JSON.parse(JSON.stringify(options.guides));
1347
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));
1348
1697
  return out;
1349
1698
  }
1350
1699
  function serializePaintDocument(state, options = {}) {
@@ -1392,7 +1741,7 @@ function deserializePaintDocument(raw) {
1392
1741
  const layers = paintLayersOnly(deserializeLayers(r.layers, { assets, problems }), problems);
1393
1742
  const wanted = typeof r.activeLayerId === "string" ? r.activeLayerId : null;
1394
1743
  const activeLayerId = wanted && findLayer(layers, wanted) ? wanted : flattenLayers(layers).filter((l) => l.type === "pixel").pop()?.id ?? null;
1395
- 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 };
1396
1745
  }
1397
1746
  async function deserializePaintDocumentAsync(raw, store) {
1398
1747
  if (!raw || typeof raw !== "object") throw new Error("Not a paint document: expected a JSON object.");
@@ -1577,7 +1926,7 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
1577
1926
  colorHex: color,
1578
1927
  affineCopies: [],
1579
1928
  copies: [IDENTITY_COPY],
1580
- 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])
1581
1930
  }
1582
1931
  );
1583
1932
  session.push(thumbnailStroke(width, height));
@@ -1593,7 +1942,7 @@ function renderBrushThumbnail(brush, options) {
1593
1942
  let surface = emptySurface(4);
1594
1943
  if (b.engine === "eraser") {
1595
1944
  surface = fillRect(surface, { x: 0, y: 0, width, height }, rgba);
1596
- } 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) {
1597
1946
  const half = Math.ceil(height / 2);
1598
1947
  surface = fillRect(surface, { x: 0, y: 0, width, height: half }, rgba);
1599
1948
  surface = fillRect(surface, { x: 0, y: half, width, height: height - half }, [232, 196, 64, 255]);
@@ -1786,9 +2135,9 @@ function perspectiveGuideLines(g, size, count = 12) {
1786
2135
  }
1787
2136
 
1788
2137
  // src/paint-editor/color.ts
1789
- var HEX2 = /^#?([0-9a-f]{6})$/i;
2138
+ var HEX3 = /^#?([0-9a-f]{6})$/i;
1790
2139
  function parseHexColor(value, what = "color") {
1791
- const m = typeof value === "string" ? HEX2.exec(value.trim()) : null;
2140
+ const m = typeof value === "string" ? HEX3.exec(value.trim()) : null;
1792
2141
  if (!m) throw new Error(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
1793
2142
  const n = parseInt(m[1], 16);
1794
2143
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
@@ -1916,10 +2265,18 @@ var PAD_KNIFE = normalizePaintBrush({
1916
2265
  id: "pad-knife",
1917
2266
  name: "Palette knife",
1918
2267
  category: "smudge",
1919
- engine: "mixer",
2268
+ engine: "smudge",
1920
2269
  loadsColor: false,
1921
- tip: { size: 34, hardness: 0.9, spacing: 0.08, roundness: 0.35, angle: 90, opacity: 1, flow: 0.85, pressureSize: false, pressureOpacity: false },
1922
- 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 }
1923
2280
  });
1924
2281
  var MixingPad = class {
1925
2282
  width = MIXING_PAD_WIDTH;
@@ -1937,7 +2294,11 @@ var MixingPad = class {
1937
2294
  }
1938
2295
  let brush = options.tool === "knife" ? PAD_KNIFE : PAD_PAINT;
1939
2296
  if (options.size !== void 0) brush = normalizePaintBrush({ tip: { ...brush.tip, size: options.size } }, brush);
1940
- 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);
1941
2302
  let result = null;
1942
2303
  const session = new PaintStrokeSession(
1943
2304
  { update: () => {
@@ -1953,10 +2314,10 @@ var MixingPad = class {
1953
2314
  grain: null,
1954
2315
  paperDepth: 0,
1955
2316
  seed: 1,
1956
- colorHex: options.color,
2317
+ colorHex: color,
1957
2318
  affineCopies: [],
1958
2319
  copies: [IDENTITY_COPY],
1959
- 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])
1960
2321
  }
1961
2322
  );
1962
2323
  session.push(points);
@@ -1995,6 +2356,82 @@ var MixingPad = class {
1995
2356
  this.surface = emptySurface(4);
1996
2357
  this.version++;
1997
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
+ }
1998
2435
  /** The pad over white, `width × height` RGBA. */
1999
2436
  pixels() {
2000
2437
  const px = readRegion(this.surface, { x: 0, y: 0, width: this.width, height: this.height });
@@ -2016,9 +2453,9 @@ var PaintEditorError = class extends Error {
2016
2453
  this.name = "PaintEditorError";
2017
2454
  }
2018
2455
  };
2019
- var HEX3 = /^#[0-9a-f]{6}$/i;
2456
+ var HEX4 = /^#[0-9a-f]{6}$/i;
2020
2457
  function parseColor(value, what = "color") {
2021
- 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".`);
2022
2459
  return value.trim().toLowerCase();
2023
2460
  }
2024
2461
  function rgbaOf(hex) {
@@ -2044,6 +2481,8 @@ var PaintEditorController = class {
2044
2481
  historyLimit;
2045
2482
  brush;
2046
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;
2047
2486
  color;
2048
2487
  secondaryColor = "#ffffff";
2049
2488
  mixerLoad = EMPTY_MIXER_LOAD;
@@ -2062,6 +2501,8 @@ var PaintEditorController = class {
2062
2501
  guides;
2063
2502
  swatches = DEFAULT_SWATCHES.map((s) => ({ ...s }));
2064
2503
  pad = new MixingPad();
2504
+ savedPads = [];
2505
+ nextPadId = 1;
2065
2506
  tracingVersion = 1;
2066
2507
  tracingCache = null;
2067
2508
  imagePixels = /* @__PURE__ */ new Map();
@@ -2099,7 +2540,7 @@ var PaintEditorController = class {
2099
2540
  this.cached = {
2100
2541
  document: this.doc,
2101
2542
  brush: this.brush,
2102
- brushes: [...BUILTIN_BRUSHES, ...this.userBrushes],
2543
+ brushes: this.library ??= [...BUILTIN_BRUSHES, ...this.userBrushes],
2103
2544
  color: this.color,
2104
2545
  secondaryColor: this.secondaryColor,
2105
2546
  mixerLoad: this.mixerLoad,
@@ -2112,6 +2553,7 @@ var PaintEditorController = class {
2112
2553
  guides: this.guides,
2113
2554
  swatches: this.swatches,
2114
2555
  mixingPadVersion: this.pad.version,
2556
+ savedMixingPads: this.savedPads.map(({ id, name }) => ({ id, name })),
2115
2557
  stroking: Boolean(this.session),
2116
2558
  version: this.version
2117
2559
  };
@@ -2481,6 +2923,7 @@ var PaintEditorController = class {
2481
2923
  const i = this.userBrushes.findIndex((b) => b.id === options.id);
2482
2924
  if (i >= 0) this.userBrushes[i] = saved;
2483
2925
  else this.userBrushes.push(saved);
2926
+ this.library = null;
2484
2927
  this.emit([]);
2485
2928
  return saved;
2486
2929
  }
@@ -2489,6 +2932,7 @@ var PaintEditorController = class {
2489
2932
  const i = this.userBrushes.findIndex((b) => b.id === id);
2490
2933
  if (i < 0) throw new PaintEditorError(`Unknown brush "${id}". Call list_brushes for the ids.`);
2491
2934
  this.userBrushes.splice(i, 1);
2935
+ this.library = null;
2492
2936
  this.emit([]);
2493
2937
  }
2494
2938
  /** The user's saved brushes. */
@@ -2529,7 +2973,10 @@ var PaintEditorController = class {
2529
2973
  else this.userBrushes.push(b);
2530
2974
  added.push(b.id);
2531
2975
  }
2532
- if (added.length) this.emit([]);
2976
+ if (added.length) {
2977
+ this.library = null;
2978
+ this.emit([]);
2979
+ }
2533
2980
  return { added, skipped, problems };
2534
2981
  }
2535
2982
  /** A thumbnail of a brush: a test stroke on the current paper. */
@@ -2603,6 +3050,14 @@ var PaintEditorController = class {
2603
3050
  if (init.brush) brush = normalizePaintBrush({ ...init.brush, id: brush.id }, brush);
2604
3051
  return brush;
2605
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
+ }
2606
3061
  /** Extra copies every new stroke paints (the painting guides add mirror and kaleidoscope copies). */
2607
3062
  strokeCopies() {
2608
3063
  return symmetryCopies(this.guides.symmetry);
@@ -2643,7 +3098,8 @@ var PaintEditorController = class {
2643
3098
  affineCopies: copies,
2644
3099
  copies: [IDENTITY_AFFINE, ...copies].map(affineCopy),
2645
3100
  inputFilter: snapper ? (p) => snapper.snap(p) : void 0,
2646
- 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)
2647
3103
  });
2648
3104
  return session;
2649
3105
  }
@@ -2658,6 +3114,7 @@ var PaintEditorController = class {
2658
3114
  seed: init.seed
2659
3115
  };
2660
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));
2661
3118
  return stroke;
2662
3119
  }
2663
3120
  /**
@@ -2676,7 +3133,7 @@ var PaintEditorController = class {
2676
3133
  this.live = null;
2677
3134
  this.liveLayers = null;
2678
3135
  const brush = s.init.brush;
2679
- if (!cancelled && s.result?.mixerLoad && brush.loadsColor) this.mixerLoad = s.result.mixerLoad;
3136
+ if (!cancelled && s.result?.mixerLoad) this.keepLoad(brush, s.result.mixerLoad);
2680
3137
  if (cancelled || !surface) {
2681
3138
  this.emit();
2682
3139
  return;
@@ -2720,7 +3177,7 @@ var PaintEditorController = class {
2720
3177
  this.requireIdle("painting");
2721
3178
  const points = this.checkPoints(init.points);
2722
3179
  const { doc, result, brush } = this.runStroke(this.doc, { ...init, start: init.start ?? points[0] }, points);
2723
- if (result.mixerLoad && brush.loadsColor) this.mixerLoad = result.mixerLoad;
3180
+ if (result.mixerLoad) this.keepLoad(brush, result.mixerLoad);
2724
3181
  if (doc !== this.doc) this.commit(`${brush.name} stroke`, doc);
2725
3182
  else this.emit([]);
2726
3183
  return result;
@@ -2780,7 +3237,12 @@ var PaintEditorController = class {
2780
3237
  }
2781
3238
  const layerId = options.layerId ?? s.layerId;
2782
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.`);
2783
- 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);
2784
3246
  doc = run.doc;
2785
3247
  bounds = unionRect(bounds, run.result.bounds);
2786
3248
  if (run.result.strokeId) ids.push(run.result.strokeId);
@@ -2859,14 +3321,15 @@ var PaintEditorController = class {
2859
3321
  // --- Save, load and export -------------------------------------------------------
2860
3322
  /** The document as JSON with inline PNG assets. */
2861
3323
  toJSON(options = {}) {
2862
- 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() });
2863
3325
  }
2864
3326
  /** The document as JSON with each PNG stored in `store`. */
2865
3327
  toJSONExternal(store, options = {}) {
2866
- 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() });
2867
3329
  }
2868
3330
  adopt(loaded) {
2869
3331
  this.extensions = loaded.extensions;
3332
+ this.readMixingPad(loaded.mixingPad, loaded.problems);
2870
3333
  this.readGuides(loaded.guides, loaded.state, loaded.problems);
2871
3334
  if (Array.isArray(loaded.swatches)) {
2872
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() });
@@ -3221,6 +3684,99 @@ var PaintEditorController = class {
3221
3684
  this.pad.clear();
3222
3685
  this.emit([]);
3223
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
+ }
3224
3780
  /** A layer's tile surface (live while stroking), for thumbnails. */
3225
3781
  layerSurface(id) {
3226
3782
  const l = findLayer(this.currentLayers(), id)?.layer;
@@ -3678,7 +4234,7 @@ function createPaintGuideTools(c) {
3678
4234
  {
3679
4235
  name: "mixing_pad_stroke",
3680
4236
  title: "Paint on the mixing pad",
3681
- 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.",
3682
4238
  inputSchema: {
3683
4239
  type: "object",
3684
4240
  properties: {
@@ -3733,6 +4289,73 @@ function createPaintGuideTools(c) {
3733
4289
  return { cleared: true };
3734
4290
  }
3735
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
+ },
3736
4359
  {
3737
4360
  name: "render_mixing_pad",
3738
4361
  title: "Render the mixing pad",
@@ -3802,28 +4425,79 @@ var DYNAMICS = {
3802
4425
  };
3803
4426
  var MIXER = {
3804
4427
  type: "object",
3805
- 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.",
3806
4429
  properties: {
3807
- 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." },
3808
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." },
3809
- 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." },
3810
4433
  loadEachStroke: { type: "boolean", description: "Refill the reservoir with the colour before each stroke." },
3811
4434
  cleanEachStroke: { type: "boolean", description: "Empty the pickup well after each stroke." },
3812
4435
  mixing: { type: "string", enum: [...MIXING_MODELS], description: "rgb: RGB averages. pigment: mixes like paint (Kubelka-Munk)." }
3813
4436
  },
3814
4437
  additionalProperties: false
3815
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
+ };
3816
4483
  var BRUSH_FIELDS = {
3817
4484
  name: { type: "string", minLength: 1, maxLength: 80, description: "Display name." },
3818
4485
  category: { type: "string", enum: [...PAINT_BRUSH_CATEGORIES], description: "Library category." },
3819
- 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
+ },
3820
4491
  tip: TIP,
3821
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." },
3822
4493
  pressureSmoothing: { type: "number", minimum: 0, maximum: 0.95, description: "Pressure smoothing, separate from the tip position smoothing." },
3823
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)." },
3824
4495
  dynamics: DYNAMICS,
3825
4496
  mixer: MIXER,
3826
- 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
3827
4501
  };
3828
4502
  var POINTS = {
3829
4503
  type: "array",
@@ -3852,7 +4526,20 @@ function loadResult(load) {
3852
4526
  return { reservoir: hex(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex(load.pickup) };
3853
4527
  }
3854
4528
  function brushSummary(b) {
3855
- 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
+ };
3856
4543
  }
3857
4544
  function createPaintEditorToolProvider(controller, options = {}) {
3858
4545
  const c = controller;
@@ -4141,7 +4828,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4141
4828
  add({
4142
4829
  name: "list_brushes",
4143
4830
  title: "List brushes",
4144
- 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.",
4145
4832
  inputSchema: EMPTY2,
4146
4833
  annotations: { title: "List brushes", ...READ2 },
4147
4834
  handler: () => {
@@ -4152,7 +4839,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4152
4839
  add({
4153
4840
  name: "get_brush",
4154
4841
  title: "Get a brush",
4155
- 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.",
4156
4843
  inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brushes (default the active brush)." } }, additionalProperties: false },
4157
4844
  annotations: { title: "Get a brush", ...READ2 },
4158
4845
  handler: ({ id }) => {
@@ -4287,7 +4974,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4287
4974
  add({
4288
4975
  name: "get_mixer_brush",
4289
4976
  title: "Get the Mixer Brush load",
4290
- 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.",
4291
4978
  inputSchema: EMPTY2,
4292
4979
  annotations: { title: "Get the Mixer Brush load", ...READ2 },
4293
4980
  handler: () => loadResult(c.getState().mixerLoad)
@@ -4303,7 +4990,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4303
4990
  add({
4304
4991
  name: "clean_mixer_brush",
4305
4992
  title: "Clean the Mixer Brush",
4306
- 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.",
4307
4994
  inputSchema: { type: "object", properties: { emptyReservoir: { type: "boolean", description: "Also drop the loaded paint (default false)." } }, additionalProperties: false },
4308
4995
  annotations: { title: "Clean the Mixer Brush", ...SET2 },
4309
4996
  handler: ({ emptyReservoir }) => loadResult(c.cleanMixerBrush(emptyReservoir === true))
@@ -4311,7 +4998,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
4311
4998
  add({
4312
4999
  name: "paint_stroke",
4313
5000
  title: "Paint a brush stroke",
4314
- 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.",
4315
5002
  inputSchema: {
4316
5003
  type: "object",
4317
5004
  properties: {
@@ -4818,6 +5505,67 @@ var BLEND_MENU_GROUPS = [
4818
5505
  { label: "Standard", modes: BLEND_MODES.filter((m) => !m.painterly && m.id !== "pigment") },
4819
5506
  { label: "Paint", modes: BLEND_MODES.filter((m) => m.painterly || m.id === "pigment") }
4820
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
+ }
4821
5569
  export {
4822
5570
  BLEND_MENU_GROUPS,
4823
5571
  BLEND_MODES,
@@ -4825,9 +5573,11 @@ export {
4825
5573
  BRUSH_ROTATIONS,
4826
5574
  BRUSH_SLIDERS,
4827
5575
  BUILTIN_BRUSHES,
5576
+ DEFAULT_BEHAVIOR,
4828
5577
  DEFAULT_BRUSH_ID,
4829
5578
  DEFAULT_DPI,
4830
5579
  DEFAULT_DYNAMICS,
5580
+ DEFAULT_MULTI_COLOR,
4831
5581
  DEFAULT_PAPER,
4832
5582
  DEFAULT_SWATCHES,
4833
5583
  DEFAULT_VIEW,
@@ -4846,7 +5596,9 @@ export {
4846
5596
  MIN_ZOOM,
4847
5597
  MIXING_PAD_HEIGHT,
4848
5598
  MIXING_PAD_WIDTH,
5599
+ MULTI_COLOR_MODES,
4849
5600
  MixingPad,
5601
+ PAD_SAMPLE_SIZES,
4850
5602
  PAINTERLY_BLEND_MODES,
4851
5603
  PAINT_BRUSH_CATEGORIES,
4852
5604
  PAINT_BRUSH_ENGINES,
@@ -4858,6 +5610,7 @@ export {
4858
5610
  PAPER_NODE_ID,
4859
5611
  PAPER_SLIDERS,
4860
5612
  PaintCanvasPresenter,
5613
+ PaintDabStroke,
4861
5614
  PaintDirector,
4862
5615
  PaintDocumentError,
4863
5616
  PaintEditorController,
@@ -4875,6 +5628,9 @@ export {
4875
5628
  TileableNoise,
4876
5629
  affineCopy,
4877
5630
  applyAffine,
5631
+ brushEngineControls,
5632
+ brushLoadColor,
5633
+ brushLoadForStroke,
4878
5634
  brushToJSON,
4879
5635
  clonePaper,
4880
5636
  colorHarmony,
@@ -4906,26 +5662,35 @@ export {
4906
5662
  isBlendMode,
4907
5663
  isPainterlyBlendMode,
4908
5664
  isPalmTouch,
5665
+ isWetCapable,
4909
5666
  mapPenPoint,
4910
5667
  mix32,
4911
5668
  mixerLoadForStroke,
4912
5669
  mixerTip,
5670
+ multiColorForDab,
5671
+ multiColorGradient,
5672
+ multiColorPalette,
5673
+ multiColorSliders,
4913
5674
  newPaintLayerId,
5675
+ normalizeBehavior,
4914
5676
  normalizeBrushStroke,
4915
5677
  normalizeCanvas,
4916
5678
  normalizeDynamics,
5679
+ normalizeMultiColor,
4917
5680
  normalizePaintBrush,
4918
5681
  normalizePaper,
4919
5682
  normalizePerspective,
4920
5683
  normalizeRotation,
4921
5684
  normalizeSymmetry,
4922
5685
  normalizeView,
5686
+ padSampleRadius,
4923
5687
  paperGrain,
4924
5688
  paperKey,
4925
5689
  paperNode,
4926
5690
  paperPreset,
4927
5691
  parseBrushLibrary,
4928
5692
  parseHexColor,
5693
+ patchBrushValue,
4929
5694
  patchPath,
4930
5695
  penOrientation,
4931
5696
  perspectiveDirections,
@@ -4956,6 +5721,7 @@ export {
4956
5721
  thumbnailStroke,
4957
5722
  tiltToOrientation,
4958
5723
  toStrokePoint,
5724
+ usesPaintExchange,
4959
5725
  variationAffine,
4960
5726
  varyStroke,
4961
5727
  viewCssTransform,