@pixerate/editor 0.252.0 → 0.253.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 (45) hide show
  1. package/dist/brushes/index.cjs +229 -2014
  2. package/dist/brushes/index.d.cts +5 -1
  3. package/dist/brushes/index.d.ts +5 -1
  4. package/dist/brushes/index.js +236 -2021
  5. package/dist/brushes/patch.d.cts +23 -0
  6. package/dist/brushes/patch.d.ts +23 -0
  7. package/dist/brushes/ui.d.cts +105 -0
  8. package/dist/brushes/ui.d.ts +105 -0
  9. package/dist/chunk-34KLWUH4.cjs +2284 -0
  10. package/dist/chunk-QPHNVZHO.js +2284 -0
  11. package/dist/paint-editor/brush-controls.d.cts +57 -0
  12. package/dist/paint-editor/brush-controls.d.ts +57 -0
  13. package/dist/paint-editor/brush-library-tools.d.cts +15 -0
  14. package/dist/paint-editor/brush-library-tools.d.ts +15 -0
  15. package/dist/paint-editor/brush-library.d.cts +29 -0
  16. package/dist/paint-editor/brush-library.d.ts +29 -0
  17. package/dist/paint-editor/controller.d.cts +76 -17
  18. package/dist/paint-editor/controller.d.ts +76 -17
  19. package/dist/paint-editor/index.cjs +537 -852
  20. package/dist/paint-editor/index.d.cts +1 -0
  21. package/dist/paint-editor/index.d.ts +1 -0
  22. package/dist/paint-editor/index.js +327 -642
  23. package/dist/paint-editor/serialize.d.cts +7 -4
  24. package/dist/paint-editor/serialize.d.ts +7 -4
  25. package/dist/paint-editor/sticker-tools.d.cts +1 -1
  26. package/dist/paint-editor/sticker-tools.d.ts +1 -1
  27. package/dist/paint-editor/strokes.d.cts +10 -1
  28. package/dist/paint-editor/strokes.d.ts +10 -1
  29. package/dist/paint-editor/tools.d.cts +1 -1
  30. package/dist/paint-editor/tools.d.ts +1 -1
  31. package/dist/paint-editor/ui.d.cts +3 -51
  32. package/dist/paint-editor/ui.d.ts +3 -51
  33. package/dist/paint-editor/wet-tools.d.cts +1 -1
  34. package/dist/paint-editor/wet-tools.d.ts +1 -1
  35. package/package.json +1 -1
  36. package/skills/pixerate-brushes/SKILL.md +5 -3
  37. package/skills/pixerate-paint-editor/SKILL.md +26 -24
  38. package/skills/pixerate-paint-editor/references/brush-facets.md +30 -0
  39. package/skills/pixerate-paint-editor/references/gradient-paint.md +1 -1
  40. package/skills/pixerate-paint-editor/references/light-paint.md +2 -2
  41. package/skills/pixerate-paint-editor/references/oils.md +1 -1
  42. package/skills/pixerate-paint-editor/references/particle-brushes.md +8 -5
  43. package/skills/pixerate-paint-oil-impasto/SKILL.md +1 -1
  44. package/skills/pixerate-stickers/SKILL.md +1 -1
  45. package/skills/pixerate-video-editor/SKILL.md +1 -1
@@ -4,6 +4,24 @@ import {
4
4
  describeStickerRef,
5
5
  editorStickers
6
6
  } from "../chunk-WZOGDKCQ.js";
7
+ import {
8
+ BRUSH_PATCH_PROPERTIES,
9
+ BRUSH_PRESETS,
10
+ BRUSH_SLIDERS,
11
+ BRUSH_UNSET_PROPERTY,
12
+ BrushLibraryError,
13
+ PAINT_PROFILE,
14
+ brushEngineControls,
15
+ closest,
16
+ createBrushLibrary,
17
+ createBrushLibraryToolProvider,
18
+ mergeBrushPatch,
19
+ migrateBrushV1,
20
+ multiColorSliders,
21
+ normalizeBrushSpec,
22
+ patchBrushValue,
23
+ resolveBrush
24
+ } from "../chunk-QPHNVZHO.js";
7
25
  import {
8
26
  toVideoFfmpeg
9
27
  } from "../chunk-5E2Q7ZHI.js";
@@ -154,7 +172,6 @@ import {
154
172
  DEFAULT_PAINT_GRADIENT_MAPPING,
155
173
  EMPTY_MIXER_LOAD,
156
174
  MAX_BRUSH_SIZE,
157
- MIXING_MODELS,
158
175
  PAINT_COLOR_JSON_SCHEMA,
159
176
  PAINT_GRADIENT_FULL_SPEED,
160
177
  PAINT_GRADIENT_JSON_SCHEMA,
@@ -3326,6 +3343,19 @@ function readLoad(raw) {
3326
3343
  if (!reservoir && !pickup) return null;
3327
3344
  return { reservoir, amount: reservoir && finite2(r.amount) ? Math.min(1, Math.max(0, r.amount)) : 0, pickup };
3328
3345
  }
3346
+ function readStrokeBrush(raw) {
3347
+ if (raw && typeof raw === "object" && "kind" in raw && !("engine" in raw)) {
3348
+ try {
3349
+ return resolveBrush(normalizeBrushSpec(raw));
3350
+ } catch {
3351
+ }
3352
+ }
3353
+ return normalizePaintBrush(raw ?? {});
3354
+ }
3355
+ function strokeToJSON(stroke) {
3356
+ const { brush, ...rest } = stroke;
3357
+ return { ...JSON.parse(JSON.stringify(rest)), brush: migrateBrushV1(brush) };
3358
+ }
3329
3359
  function normalizeBrushStroke(raw) {
3330
3360
  if (!raw || typeof raw !== "object") return null;
3331
3361
  const r = raw;
@@ -3335,7 +3365,7 @@ function normalizeBrushStroke(raw) {
3335
3365
  const out = {
3336
3366
  id: r.id,
3337
3367
  layerId: r.layerId,
3338
- brush: normalizePaintBrush(r.brush ?? {}),
3368
+ brush: readStrokeBrush(r.brush),
3339
3369
  color: typeof r.color === "string" && /^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : "#000000",
3340
3370
  points,
3341
3371
  seed: finite2(r.seed) ? r.seed | 0 : 0
@@ -3515,7 +3545,7 @@ function downsampleRgba(data, width, height, tw, th) {
3515
3545
 
3516
3546
  // src/paint-editor/serialize.ts
3517
3547
  var PAINT_FORMAT = "pixerate-paint";
3518
- var PAINT_SCHEMA_VERSION = 1;
3548
+ var PAINT_SCHEMA_VERSION = 2;
3519
3549
  var KNOWN = /* @__PURE__ */ new Set(["format", "version", "width", "height", "dpi", "paper", "layers", "assets", "activeLayerId", "strokes", "guides", "swatches", "mixingPad", "wet", "wetEvents", "paintEvents", "origin"]);
3520
3550
  function documentExtensions(raw) {
3521
3551
  const out = {};
@@ -3533,7 +3563,7 @@ function header(state, options) {
3533
3563
  paper: { ...state.paper, dry: { ...state.paper.dry }, wet: { ...state.paper.wet } },
3534
3564
  activeLayerId: state.activeLayerId
3535
3565
  };
3536
- if (options.strokes !== false && state.strokes.length) out.strokes = JSON.parse(JSON.stringify(state.strokes));
3566
+ if (options.strokes !== false && state.strokes.length) out.strokes = state.strokes.map(strokeToJSON);
3537
3567
  if (options.guides !== void 0) out.guides = JSON.parse(JSON.stringify(options.guides));
3538
3568
  if (options.swatches !== void 0) out.swatches = JSON.parse(JSON.stringify(options.swatches));
3539
3569
  if (options.mixingPad !== void 0) out.mixingPad = JSON.parse(JSON.stringify(options.mixingPad));
@@ -4921,10 +4951,10 @@ var MixingPad = class {
4921
4951
  * mixed colour's scattering is re-estimated from its luminance, so repeated small mixes favour
4922
4952
  * the lighter paint (see GOTCHAS).
4923
4953
  */
4924
- mix(paints, options = {}) {
4925
- if (!paints.length || paints.length > 8) throw new Error(`Give 1 to 8 paints to mix; got ${paints.length}.`);
4926
- const total = paints.reduce((n, p) => n + p.parts, 0);
4927
- paints.forEach((p, i) => {
4954
+ mix(paints2, options = {}) {
4955
+ if (!paints2.length || paints2.length > 8) throw new Error(`Give 1 to 8 paints to mix; got ${paints2.length}.`);
4956
+ const total = paints2.reduce((n, p) => n + p.parts, 0);
4957
+ paints2.forEach((p, i) => {
4928
4958
  if (!(Number.isFinite(p.parts) && p.parts >= 0)) throw new Error(`paints[${i}].parts is ${p.parts}; use a number \u2265 0.`);
4929
4959
  });
4930
4960
  if (!(total > 0)) throw new Error("Every paint has 0 parts; give at least one paint parts above 0.");
@@ -4939,7 +4969,7 @@ var MixingPad = class {
4939
4969
  }
4940
4970
  const bounds = { x: Math.floor(cx - R), y: Math.floor(cy - R), width: Math.ceil(2 * R), height: Math.ceil(2 * R) };
4941
4971
  if (options.clear !== false) this.surface = writeRegion(this.surface, bounds, new Uint8ClampedArray(bounds.width * bounds.height * 4));
4942
- const used = paints.filter((p) => p.parts > 0);
4972
+ const used = paints2.filter((p) => p.parts > 0);
4943
4973
  const area = Math.PI * (0.7 * R) ** 2;
4944
4974
  used.forEach((p, i) => {
4945
4975
  const r = Math.sqrt(area * (p.parts / total) / Math.PI);
@@ -4962,8 +4992,8 @@ var MixingPad = class {
4962
4992
  this.run(knife, stir, "#000000");
4963
4993
  }
4964
4994
  const mixed = mixPigments(
4965
- paints.map((p) => hexToRgb8(p.color).map((v) => v / 255)),
4966
- paints.map((p) => p.parts)
4995
+ paints2.map((p) => hexToRgb8(p.color).map((v) => v / 255)),
4996
+ paints2.map((p) => p.parts)
4967
4997
  );
4968
4998
  const core = normalizePaintBrush({ tip: { ...PAD_BLOB.tip, size: R * 1.1, hardness: 0.55 } }, PAD_BLOB);
4969
4999
  const hex = "#" + mixed.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
@@ -5001,6 +5031,79 @@ var MixingPad = class {
5001
5031
  }
5002
5032
  };
5003
5033
 
5034
+ // src/paint-editor/brush-library.ts
5035
+ var copyJSON = (v) => JSON.parse(JSON.stringify(v));
5036
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5037
+ function libraryBrush(spec, builtin, resolved) {
5038
+ const brush = resolved ? normalizePaintBrush(resolved, resolved) : resolveBrush(spec);
5039
+ if (builtin) brush.builtin = true;
5040
+ else delete brush.builtin;
5041
+ return { spec, brush, builtin };
5042
+ }
5043
+ var builtins = null;
5044
+ function builtinLibraryBrushes() {
5045
+ return builtins ??= BRUSH_PRESETS.map((spec) => {
5046
+ const resolved = BUILTIN_BRUSHES.find((b) => b.id === spec.id);
5047
+ return libraryBrush(spec, true, resolved);
5048
+ });
5049
+ }
5050
+ function seededStorage(brushes) {
5051
+ if (!brushes.length) return void 0;
5052
+ let stored = JSON.stringify({ format: "pixerate-brushes", version: 1, brushes: brushes.map(brushToJSON) });
5053
+ return {
5054
+ get: async () => stored,
5055
+ set: async (_key, value) => {
5056
+ stored = value;
5057
+ }
5058
+ };
5059
+ }
5060
+ var paints = (b) => {
5061
+ const { category: _c, builtin: _b, ...rest } = b;
5062
+ return JSON.stringify(rest);
5063
+ };
5064
+ function brushSpecOf(next, from) {
5065
+ const plain = migrateBrushV1(next);
5066
+ const out = copyJSON(plain);
5067
+ if (from.description) out.description = from.description;
5068
+ if (from.tags?.length) out.tags = [...from.tags];
5069
+ if (out.kind === "mark" && from.kind === "mark") {
5070
+ if (from.medium) out.medium = from.medium;
5071
+ if (from.variety) out.variety = from.variety;
5072
+ }
5073
+ const colour = { ...from.colour ?? {} };
5074
+ if (plain.colour?.default !== void 0) colour.default = plain.colour.default;
5075
+ else if (isObject(colour.default) && colour.default.type === "gradient") delete colour.default;
5076
+ if (Object.keys(colour).length) out.colour = copyJSON(colour);
5077
+ else delete out.colour;
5078
+ if ((out.light?.amount ?? 0) > 0 && (from.light?.spill || from.light?.glow !== void 0)) {
5079
+ out.light = { ...out.light, ...from.light.spill ? { spill: { ...from.light.spill } } : {}, ...from.light.glow !== void 0 ? { glow: from.light.glow } : {} };
5080
+ }
5081
+ if (from.motion?.live !== void 0 || from.motion?.fps !== void 0) {
5082
+ out.motion = { ...out.motion ?? {}, ...from.motion.live !== void 0 ? { live: from.motion.live } : {}, ...from.motion.fps !== void 0 ? { fps: from.motion.fps } : {} };
5083
+ }
5084
+ try {
5085
+ const hinted = normalizeBrushSpec(out, []);
5086
+ if (paints(resolveBrush(hinted)) === paints(resolveBrush(plain))) return hinted;
5087
+ } catch {
5088
+ }
5089
+ return plain;
5090
+ }
5091
+ function engineChanges(base, next) {
5092
+ const diff = (a, b) => {
5093
+ if (isObject(a) && isObject(b)) {
5094
+ const out = {};
5095
+ for (const k of Object.keys(b)) {
5096
+ const d = diff(a[k], b[k]);
5097
+ if (d !== void 0) out[k] = d;
5098
+ }
5099
+ return Object.keys(out).length ? out : void 0;
5100
+ }
5101
+ return JSON.stringify(a) === JSON.stringify(b) ? void 0 : copyJSON(b);
5102
+ };
5103
+ const { id: _i, builtin: _b, ...rest } = diff(base, next) ?? {};
5104
+ return rest;
5105
+ }
5106
+
5004
5107
  // src/paint-editor/live-strokes.ts
5005
5108
  var LIVE_STROKE_ENGINES = ["dab", "particle", "bristle", "mixer"];
5006
5109
  function liveStrokeRefusal(brush) {
@@ -5583,6 +5686,13 @@ var PaintEditorError = class extends Error {
5583
5686
  this.name = "PaintEditorError";
5584
5687
  }
5585
5688
  };
5689
+ async function libraryCall(call) {
5690
+ try {
5691
+ return await call();
5692
+ } catch (e) {
5693
+ throw e instanceof BrushLibraryError ? new PaintEditorError(e.message) : e;
5694
+ }
5695
+ }
5586
5696
  var HEX3 = /^#[0-9a-f]{6}$/i;
5587
5697
  var MAX_ADD_STICKERS = 500;
5588
5698
  function parseColor(value, what = "color") {
@@ -5610,8 +5720,17 @@ var PaintEditorController = class _PaintEditorController {
5610
5720
  undoStack = [];
5611
5721
  redoStack = [];
5612
5722
  historyLimit;
5723
+ /** The brush library this editor lists, saves into and paints from (shared with other editors when the host passes one). */
5724
+ brushLibrary;
5613
5725
  brush;
5614
- userBrushes;
5726
+ /** The active brush as facets (what `getBrushSpec` and saving use). */
5727
+ brushSpec;
5728
+ /** The library's brushes as last read, each as stored (facets) and as the engine paints it. */
5729
+ brushEntries;
5730
+ /** The brush id asked for at construction, while the library had not listed it yet. */
5731
+ pendingBrushId = null;
5732
+ brushesLoaded = Promise.resolve();
5733
+ unsubscribeLibrary;
5615
5734
  /** The library list in the state (the same array until the user brushes change, so thumbnails are not redrawn). */
5616
5735
  library = null;
5617
5736
  color;
@@ -5693,9 +5812,17 @@ var PaintEditorController = class _PaintEditorController {
5693
5812
  this.rasterizeSticker = options.rasterizeSticker ?? defaultStickerRasterizer;
5694
5813
  if (options.stickers) this.stickers.set(options.stickers);
5695
5814
  this.doc = createPaintDocument({ width: options.width, height: options.height, dpi: options.dpi, paper: this.resolvePaper(options.paper) });
5696
- this.userBrushes = (options.brushes ?? []).map((b) => normalizePaintBrush({ ...b, builtin: false }));
5697
- const start = this.findBrush(options.brushId ?? DEFAULT_BRUSH_ID) ?? this.findBrush(DEFAULT_BRUSH_ID);
5698
- this.brush = normalizePaintBrush(start);
5815
+ const seed = (options.brushes ?? []).map((b) => normalizePaintBrush({ ...b, builtin: false }));
5816
+ this.brushLibrary = options.brushLibrary ?? createBrushLibrary({ storage: seededStorage(seed) });
5817
+ this.brushEntries = [...builtinLibraryBrushes(), ...seed.map((b) => libraryBrush(migrateBrushV1(b), false, b))];
5818
+ const wanted = options.brushId ?? DEFAULT_BRUSH_ID;
5819
+ const start = this.findEntry(wanted) ?? this.findEntry(DEFAULT_BRUSH_ID);
5820
+ this.brush = normalizePaintBrush(start.brush);
5821
+ this.brushSpec = copyJSON(start.spec);
5822
+ this.unsubscribeLibrary = this.brushLibrary.subscribe(() => void this.refreshBrushes().catch(() => void 0));
5823
+ if (options.brushLibrary && seed.length) void this.brushLibrary.importFile(seed.map(brushToJSON)).catch(() => void 0);
5824
+ void this.refreshBrushes().catch(() => void 0);
5825
+ this.pendingBrushId = start.brush.id === wanted ? null : wanted;
5699
5826
  this.color = options.color ? parseColor(options.color) : "#1f2937";
5700
5827
  this.hand = options.hand === "left" ? "left" : "right";
5701
5828
  this.encodeImage = options.encodeImage;
@@ -5748,7 +5875,7 @@ var PaintEditorController = class _PaintEditorController {
5748
5875
  this.cached = {
5749
5876
  document: this.doc,
5750
5877
  brush: this.brush,
5751
- brushes: this.library ??= [...BUILTIN_BRUSHES, ...this.userBrushes],
5878
+ brushes: this.library ??= this.brushEntries.map((e) => e.brush),
5752
5879
  color: this.color,
5753
5880
  secondaryColor: this.secondaryColor,
5754
5881
  paint: this.gradient ?? { type: "color", color: this.color },
@@ -6730,9 +6857,9 @@ var PaintEditorController = class _PaintEditorController {
6730
6857
  * Renders effect paints that are not rendered yet (strokes and replays need them rendered
6731
6858
  * first, since strokes are synchronous). Throws naming the effect and provider when one cannot be.
6732
6859
  */
6733
- async prepareEffectPaints(paints) {
6860
+ async prepareEffectPaints(paints2) {
6734
6861
  const seen = /* @__PURE__ */ new Set();
6735
- for (const p of paints) {
6862
+ for (const p of paints2) {
6736
6863
  const key = effectPaintKey(p);
6737
6864
  if (seen.has(key)) continue;
6738
6865
  seen.add(key);
@@ -6742,8 +6869,8 @@ var PaintEditorController = class _PaintEditorController {
6742
6869
  /** Renders the effect paints of recorded strokes (default all of them) before a replay. */
6743
6870
  async prepareStrokeEffects(strokeIds) {
6744
6871
  const ids = strokeIds ? new Set(strokeIds) : null;
6745
- const paints = this.doc.strokes.filter((s) => (!ids || ids.has(s.id)) && s.paint?.type === "effect").map((s) => s.paint);
6746
- await this.prepareEffectPaints(paints);
6872
+ const paints2 = this.doc.strokes.filter((s) => (!ids || ids.has(s.id)) && s.paint?.type === "effect").map((s) => s.paint);
6873
+ await this.prepareEffectPaints(paints2);
6747
6874
  }
6748
6875
  /** A small preview of the effect paint (a PNG data URL `size` px wide), or `null` without a rendered one. */
6749
6876
  effectPaintThumbnail(size = 48) {
@@ -6824,100 +6951,162 @@ var PaintEditorController = class _PaintEditorController {
6824
6951
  return { layerId: now.id, source: this.sourceOf(d, values, options.time), amount };
6825
6952
  }
6826
6953
  // --- Brushes -------------------------------------------------------------------
6954
+ findEntry(id) {
6955
+ return this.brushEntries.find((e) => e.brush.id === id);
6956
+ }
6827
6957
  findBrush(id) {
6828
- return BUILTIN_BRUSHES.find((b) => b.id === id) ?? this.userBrushes.find((b) => b.id === id);
6958
+ return this.findEntry(id)?.brush;
6959
+ }
6960
+ requireEntry(id) {
6961
+ const e = this.findEntry(id);
6962
+ if (!e) throw new PaintEditorError(`Unknown brush "${id}". ${closest(id, this.brushEntries.map((x) => x.brush.id))}Call list_brush_presets for the ids.`);
6963
+ return e;
6964
+ }
6965
+ requireBrush(id) {
6966
+ return this.requireEntry(id).brush;
6829
6967
  }
6830
- /** A library brush by id (`undefined` when unknown). */
6968
+ /** Reads the library again (after a change anywhere it is shared). */
6969
+ refreshBrushes() {
6970
+ const run = this.brushLibrary.list().then((entries) => {
6971
+ this.brushEntries = entries.map((e) => libraryBrush(e.brush, e.builtin));
6972
+ this.library = null;
6973
+ const pending = this.pendingBrushId;
6974
+ this.pendingBrushId = null;
6975
+ if (pending && this.findEntry(pending)) this.setBrush({ id: pending });
6976
+ else this.emit([]);
6977
+ });
6978
+ this.brushesLoaded = run.catch(() => void 0);
6979
+ return run;
6980
+ }
6981
+ /** Resolves once the brush library has been read (user brushes kept in storage are listed from then on). */
6982
+ whenBrushesLoaded() {
6983
+ return this.brushesLoaded;
6984
+ }
6985
+ /** A library brush by id, as the engine paints it (`undefined` when unknown). */
6831
6986
  getBrush(id) {
6832
6987
  const b = this.findBrush(id);
6833
6988
  return b ? normalizePaintBrush(b, b) : void 0;
6834
6989
  }
6835
- requireBrush(id) {
6836
- const b = this.findBrush(id);
6837
- if (!b) throw new PaintEditorError(`Unknown brush "${id}". Call list_brushes for the ids.`);
6838
- return b;
6990
+ /** A library brush by id as stored (its facets), or without an id the active brush as edited. */
6991
+ getBrushSpec(id) {
6992
+ const spec = id === void 0 ? this.brushSpec : this.findEntry(id)?.spec;
6993
+ return spec ? copyJSON(spec) : void 0;
6839
6994
  }
6840
6995
  /**
6841
- * Chooses the active brush (`id`: a library brush) and / or changes its settings. Editor state,
6842
- * not an undo step. Returns the active brush.
6996
+ * Chooses the active brush (`id`: a library brush) and / or changes its engine settings. Editor
6997
+ * state, not an undo step. Returns the active brush. `setBrushFacets` changes it by facets.
6843
6998
  */
6844
6999
  setBrush(update = {}) {
6845
7000
  const { id, ...changes } = update;
6846
- const base = id !== void 0 ? this.requireBrush(id) : this.brush;
7001
+ const entry = id !== void 0 ? this.requireEntry(id) : null;
7002
+ const base = entry ? entry.brush : this.brush;
7003
+ const baseSpec = entry ? entry.spec : this.brushSpec;
6847
7004
  if (changes.paint !== void 0 && changes.paint !== null) this.readStrokePaint(changes.paint);
7005
+ const unchanged = !Object.keys(changes).length;
6848
7006
  const next = normalizePaintBrush({ ...changes, id: base.id }, base);
6849
- if (base.builtin && !Object.keys(changes).length) next.builtin = true;
7007
+ this.useBrush(next, unchanged ? baseSpec : brushSpecOf(next, baseSpec), unchanged && base.builtin === true);
7008
+ return this.brush;
7009
+ }
7010
+ /**
7011
+ * Chooses the active brush (`id`: a library brush) and / or lays a facet patch over it (objects
7012
+ * merge, lists and values replace, as the library's `update_brush` does). Editor state, not an
7013
+ * undo step. `unset` removes whole facets first. Returns the active brush as the engine paints
7014
+ * it, its facets, and what was clamped or dropped.
7015
+ */
7016
+ setBrushFacets(patch = {}, id, unset = []) {
7017
+ const entry = id !== void 0 ? this.requireEntry(id) : null;
7018
+ const unchanged = !Object.keys(patch).length && !unset.length;
7019
+ const problems = [];
7020
+ const base = copyJSON(entry ? entry.spec : this.brushSpec);
7021
+ for (const k of unset) delete base[k];
7022
+ const spec = this.readBrushPatch(base, patch, problems);
7023
+ this.useBrush(unchanged && entry ? entry.brush : resolveBrush(spec), spec, unchanged && entry?.builtin === true);
7024
+ return { brush: this.brush, spec: copyJSON(this.brushSpec), problems };
7025
+ }
7026
+ /**
7027
+ * Makes `spec` (a whole facet brush, as the brush editor edits it) the active brush, keeping the
7028
+ * active brush's id. Editor state, not an undo step. Returns it as the engine paints it, its
7029
+ * facets, and what was clamped or dropped.
7030
+ */
7031
+ setBrushSpec(spec) {
7032
+ const problems = [];
7033
+ const paint = spec.colour?.default;
7034
+ if (paint && typeof paint === "object" && "type" in paint && paint.type === "gradient") this.readStrokePaint(paint);
7035
+ let next;
7036
+ try {
7037
+ next = normalizeBrushSpec({ ...spec, id: this.brush.id }, problems);
7038
+ } catch (e) {
7039
+ throw new PaintEditorError(e.message);
7040
+ }
7041
+ this.useBrush(resolveBrush(next), next, false);
7042
+ return { brush: this.brush, spec: copyJSON(this.brushSpec), problems };
7043
+ }
7044
+ /**
7045
+ * The engine settings a facet patch changes on a brush (`from`: a library brush id or a brush as
7046
+ * painted; default the active brush), as `paintStroke`'s and `updateStroke`'s `brush` take them.
7047
+ */
7048
+ brushChanges(patch, from) {
7049
+ const entry = typeof from === "string" ? this.requireEntry(from) : null;
7050
+ const base = entry ? entry.brush : typeof from === "object" ? normalizePaintBrush(from, from) : this.brush;
7051
+ const spec = entry ? entry.spec : typeof from === "object" ? migrateBrushV1(base) : this.brushSpec;
7052
+ return engineChanges(base, resolveBrush(this.readBrushPatch(spec, patch, [])));
7053
+ }
7054
+ readBrushPatch(base, patch, problems) {
7055
+ const { id: _id, name, ...rest } = patch;
7056
+ const paint = rest.colour?.default;
7057
+ if (paint && typeof paint === "object" && "type" in paint && paint.type === "gradient") this.readStrokePaint(paint);
7058
+ try {
7059
+ return normalizeBrushSpec(mergeBrushPatch(base, { ...rest, ...name !== void 0 ? { name } : {} }), problems);
7060
+ } catch (e) {
7061
+ throw new PaintEditorError(e.message);
7062
+ }
7063
+ }
7064
+ useBrush(next, spec, builtin) {
7065
+ next = normalizePaintBrush(next, next);
7066
+ if (builtin) next.builtin = true;
6850
7067
  else delete next.builtin;
6851
7068
  this.brush = next;
7069
+ this.brushSpec = { ...copyJSON(spec), id: next.id, name: next.name };
7070
+ this.pendingBrushId = null;
6852
7071
  this.emit([]);
6853
7072
  if (next.engine === "sticker" && next.sticker.stamps.some((r) => !editorStampOf(r))) void this.prepareStickers(next.sticker.stamps).catch(() => this.emit([]));
6854
- return this.brush;
6855
7073
  }
6856
- /** Saves the active brush (or `brush`) into the user library under `id` / `name`. */
6857
- saveBrush(options) {
7074
+ /**
7075
+ * Saves the active brush (or `brush`, engine settings on top of it) into the brush library under
7076
+ * `id` / `name`, replacing a user brush with that id. Built-in ids are refused. Resolves with the
7077
+ * saved brush.
7078
+ */
7079
+ async saveBrush(options) {
6858
7080
  if (!BRUSH_ID.test(options.id)) throw new PaintEditorError(`Brush id "${options.id}" is invalid; use lowercase letters, digits and dashes (e.g. "my-pencil").`);
6859
- if (BUILTIN_BRUSHES.some((b) => b.id === options.id)) throw new PaintEditorError(`"${options.id}" is a built-in brush; save under another id.`);
6860
- const source = options.brush ? normalizePaintBrush(options.brush, this.brush) : this.brush;
6861
- const saved = normalizePaintBrush({ ...source, id: options.id, name: options.name ?? source.name });
6862
- delete saved.builtin;
6863
- const i = this.userBrushes.findIndex((b) => b.id === options.id);
6864
- if (i >= 0) this.userBrushes[i] = saved;
6865
- else this.userBrushes.push(saved);
6866
- this.library = null;
6867
- this.emit([]);
6868
- return saved;
6869
- }
6870
- removeBrush(id) {
6871
- if (BUILTIN_BRUSHES.some((b) => b.id === id)) throw new PaintEditorError(`"${id}" is a built-in brush and cannot be removed.`);
6872
- const i = this.userBrushes.findIndex((b) => b.id === id);
6873
- if (i < 0) throw new PaintEditorError(`Unknown brush "${id}". Call list_brushes for the ids.`);
6874
- this.userBrushes.splice(i, 1);
6875
- this.library = null;
6876
- this.emit([]);
6877
- }
6878
- /** The user's saved brushes. */
7081
+ if (this.brushLibrary.isBuiltin(options.id)) throw new PaintEditorError(`"${options.id}" is a built-in brush; save under another id.`);
7082
+ const spec = options.brush ? brushSpecOf(normalizePaintBrush(options.brush, this.brush), this.brushSpec) : this.brushSpec;
7083
+ const { brush } = await libraryCall(() => this.brushLibrary.save({ ...spec, id: options.id, name: options.name ?? spec.name }, { overwrite: true }));
7084
+ await this.refreshBrushes();
7085
+ return this.requireBrush(brush.id);
7086
+ }
7087
+ /** Removes a user brush from the brush library (built-in brushes can't be removed). */
7088
+ async removeBrush(id) {
7089
+ if (this.brushLibrary.isBuiltin(id)) throw new PaintEditorError(`"${id}" is a built-in brush and cannot be removed.`);
7090
+ if (!await libraryCall(() => this.brushLibrary.remove(id))) throw new PaintEditorError(`Unknown brush "${id}". Call list_brush_presets for the ids.`);
7091
+ await this.refreshBrushes();
7092
+ }
7093
+ /** The user's saved brushes, as the engine paints them. */
6879
7094
  getUserBrushes() {
6880
- return this.userBrushes.map((b) => normalizePaintBrush(b, b));
7095
+ return this.brushEntries.filter((e) => !e.builtin).map((e) => normalizePaintBrush(e.brush, e.brush));
6881
7096
  }
6882
- /** A brush library file of the given brushes (default: the user's brushes). */
7097
+ /** A brush library file (version 2) of the given brushes (default: the user's brushes). */
6883
7098
  exportBrushes(ids) {
6884
- const list = ids ? ids.map((id) => this.requireBrush(id)) : this.userBrushes;
6885
- return { format: "pixerate-brushes", version: 1, brushes: list.map(brushToJSON) };
7099
+ return libraryCall(() => this.brushLibrary.exportFile(ids));
6886
7100
  }
6887
7101
  /**
6888
- * Adds the brushes of a library file to the user library. A brush whose id is taken is skipped
6889
- * unless `replace` (built-in ids are always skipped). Returns the ids added and the problems.
7102
+ * Adds the brushes of a library file (version 1 or 2) to the brush library. A brush whose id is
7103
+ * taken is skipped unless `replace` (built-in ids are always skipped). Resolves with the ids
7104
+ * added or replaced, the ids skipped and the problems.
6890
7105
  */
6891
- importBrushes(file, options = {}) {
6892
- const problems = [];
6893
- let list;
6894
- try {
6895
- list = parseBrushLibrary(file, problems);
6896
- } catch (e) {
6897
- throw new PaintEditorError(e.message);
6898
- }
6899
- const added = [];
6900
- const skipped = [];
6901
- for (const b of list) {
6902
- if (BUILTIN_BRUSHES.some((x) => x.id === b.id)) {
6903
- skipped.push(b.id);
6904
- problems.push(`"${b.id}" is a built-in brush id; rename it to import it.`);
6905
- continue;
6906
- }
6907
- const i = this.userBrushes.findIndex((x) => x.id === b.id);
6908
- if (i >= 0 && !options.replace) {
6909
- skipped.push(b.id);
6910
- continue;
6911
- }
6912
- if (i >= 0) this.userBrushes[i] = b;
6913
- else this.userBrushes.push(b);
6914
- added.push(b.id);
6915
- }
6916
- if (added.length) {
6917
- this.library = null;
6918
- this.emit([]);
6919
- }
6920
- return { added, skipped, problems };
7106
+ async importBrushes(file, options = {}) {
7107
+ const r = await libraryCall(() => this.brushLibrary.importFile(file, { overwrite: !!options.replace }));
7108
+ await this.refreshBrushes();
7109
+ return { added: [...r.added, ...r.replaced], skipped: r.skipped, problems: r.problems };
6921
7110
  }
6922
7111
  /** A thumbnail of a brush: a test stroke on the current paper. */
6923
7112
  brushThumbnail(id, options = {}) {
@@ -7076,7 +7265,7 @@ var PaintEditorController = class _PaintEditorController {
7076
7265
  const brush = this.strokeBrush(init);
7077
7266
  const effect = this.strokeEffect(init, brush);
7078
7267
  if (brush.light.amount > 0) {
7079
- if (brush.engine === "sticker") throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take light yet; paint light with a dab or particle light brush (list_brushes category light), or set light.amount to 0 (set_brush).`);
7268
+ if (brush.engine === "sticker") throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take light yet; paint light with a dab or particle light brush (glow-gel, neon-marker; list_brush_presets lists them), or set light.amount to 0 (set_brush).`);
7080
7269
  }
7081
7270
  if (doc.liveStrokes?.[layer.id]) {
7082
7271
  const why = liveStrokeRefusal(brush);
@@ -7507,7 +7696,8 @@ var PaintEditorController = class _PaintEditorController {
7507
7696
  const w = Math.round(width * scale);
7508
7697
  const h = Math.round(height * scale);
7509
7698
  if (w > 16384 || h > 16384) throw new PaintEditorError(`At scale ${scale} the canvas would be ${w}\xD7${h}; the limit is 16384 px per side. Use a smaller scale.`);
7510
- const target = new _PaintEditorController({ width: w, height: h, dpi: dpi * scale, brushes: this.userBrushes, wet: this.wetOptions });
7699
+ const target = new _PaintEditorController({ width: w, height: h, dpi: dpi * scale, brushLibrary: this.brushLibrary, wet: this.wetOptions });
7700
+ target.brushEntries = this.brushEntries;
7511
7701
  const paper = { ...this.doc.paper, scale: Math.min(8, this.doc.paper.scale * scale) };
7512
7702
  target.adopt({ state: { canvas: target.doc.canvas, paper, layers: emptiedLayers(this.doc.layers), activeLayerId: this.doc.activeLayerId, strokes: [] }, extensions: {}, problems: [] });
7513
7703
  target.lighting = { ...this.lighting };
@@ -7562,6 +7752,7 @@ var PaintEditorController = class _PaintEditorController {
7562
7752
  }
7563
7753
  /** Stops the wet simulation's worker threads (call when the editor goes away). */
7564
7754
  dispose() {
7755
+ this.unsubscribeLibrary();
7565
7756
  this.wet.destroy();
7566
7757
  this.effectsRenderer.dispose();
7567
7758
  this.effectPaintPixels.clear();
@@ -7942,7 +8133,7 @@ var PaintEditorController = class _PaintEditorController {
7942
8133
  if (Object.keys(stickers).length) ext.stickers = stickers;
7943
8134
  else delete ext.stickers;
7944
8135
  const liveStrokes = {};
7945
- for (const [id, list] of Object.entries(this.doc.liveStrokes ?? {})) if (findLayer(this.doc.layers, id)) liveStrokes[id] = list.map((x) => JSON.parse(JSON.stringify(x)));
8136
+ for (const [id, list] of Object.entries(this.doc.liveStrokes ?? {})) if (findLayer(this.doc.layers, id)) liveStrokes[id] = list.map(strokeToJSON);
7946
8137
  if (Object.keys(liveStrokes).length) ext.liveStrokes = liveStrokes;
7947
8138
  else delete ext.liveStrokes;
7948
8139
  const light = {};
@@ -8415,7 +8606,7 @@ var PaintEditorController = class _PaintEditorController {
8415
8606
  stickerLayer(layerId) {
8416
8607
  const l = this.wetLayer(layerId, "edit the stickers of");
8417
8608
  const stickers = this.doc.stickers?.[l.id];
8418
- if (!stickers) throw new PaintEditorError(`Layer "${l.name}" has no stickers; spray some with a sticker brush (list_brushes category sticker).`);
8609
+ if (!stickers) throw new PaintEditorError(`Layer "${l.name}" has no stickers; spray some with a sticker brush (sticker-stars; list_brush_presets lists them).`);
8419
8610
  return { layer: l, stickers };
8420
8611
  }
8421
8612
  // --- The sticker library ---------------------------------------------------------
@@ -9145,8 +9336,8 @@ var PaintEditorController = class _PaintEditorController {
9145
9336
  * and a fully mixed core of the Kubelka-Munk mix (see `MixingPad.mix`). With `setColor` the
9146
9337
  * mix becomes the paint colour. Returns the mixed colour and the pile's bounds.
9147
9338
  */
9148
- mixOnPad(paints, options = {}) {
9149
- const parsed = (paints ?? []).map((p, i) => ({ color: parseColor(p?.color, `paints[${i}].color`), parts: p?.parts }));
9339
+ mixOnPad(paints2, options = {}) {
9340
+ const parsed = (paints2 ?? []).map((p, i) => ({ color: parseColor(p?.color, `paints[${i}].color`), parts: p?.parts }));
9150
9341
  let r;
9151
9342
  try {
9152
9343
  r = this.pad.mix(parsed, options);
@@ -10140,7 +10331,7 @@ function createPaintGuideTools(c) {
10140
10331
  additionalProperties: false
10141
10332
  },
10142
10333
  annotations: { title: "Mix paints on the pad", ...WRITE2 },
10143
- handler: ({ paints, x, y, radius, passes, clear, setColor }) => c.mixOnPad(paints, { x, y, radius, passes, clear, setColor })
10334
+ handler: ({ paints: paints2, x, y, radius, passes, clear, setColor }) => c.mixOnPad(paints2, { x, y, radius, passes, clear, setColor })
10144
10335
  },
10145
10336
  {
10146
10337
  name: "save_mixing_pad",
@@ -10571,254 +10762,6 @@ var DESTRUCTIVE3 = { readOnlyHint: false, destructiveHint: true, idempotentHint:
10571
10762
  var EMPTY3 = { type: "object", properties: {}, additionalProperties: false };
10572
10763
  var COLOR3 = { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: 'Colour #rrggbb, e.g. "#2a4b8d".' };
10573
10764
  var LAYER_ID4 = { type: "string", description: "Layer id from list_layers (default the active layer)." };
10574
- var TIP = {
10575
- type: "object",
10576
- description: "The tip (the shared brush engine). Missing fields keep their current values.",
10577
- properties: {
10578
- size: { type: "number", minimum: 1, maximum: MAX_BRUSH_SIZE, description: "Diameter in pixels." },
10579
- hardness: { type: "number", minimum: 0, maximum: 1, description: "Edge hardness, 0 (soft) .. 1 (hard)." },
10580
- spacing: { type: "number", minimum: 0.01, maximum: 10, description: "Dab spacing as a fraction of the diameter (0.05 is smooth, 1 is dotted)." },
10581
- angle: { type: "number", minimum: -360, maximum: 360, description: "Tip angle in degrees, counter-clockwise; with rotation stroke or barrel it is added to the stroke direction or pen rotation." },
10582
- roundness: { type: "number", minimum: 0.01, maximum: 1, description: "Tip roundness: 1 round, lower is a flat ellipse (a pastel or chisel tip)." },
10583
- opacity: { type: "number", minimum: 0, maximum: 1, description: "Stroke opacity ceiling: overlapping dabs never exceed it." },
10584
- flow: { type: "number", minimum: 0, maximum: 1, description: "Build-up per dab towards the opacity (low flow builds up softly, like an airbrush)." },
10585
- smoothing: { type: "number", minimum: 0, maximum: 0.95, description: "Position smoothing of the input, 0..0.95." },
10586
- pressureSize: { type: "boolean", description: "Pressure scales the size (down to dynamics.minSize)." },
10587
- pressureOpacity: { type: "boolean", description: "Pressure scales the opacity." },
10588
- blend: {
10589
- type: "string",
10590
- enum: BLEND_MODES.map((m) => m.id),
10591
- description: "How paint combines with the layer. pigment mixes it in like real paint (blue into yellow gives green); multiply darkens like marker ink; watercolor glazes like a transparent wash; tint, highlight and shadow recolour, light and shade (see list_blend_modes)."
10592
- }
10593
- },
10594
- additionalProperties: false
10595
- };
10596
- var DYNAMICS = {
10597
- type: "object",
10598
- description: "Pen dynamics and jitter. Missing fields keep their current values.",
10599
- properties: {
10600
- minSize: { type: "number", minimum: 0, maximum: 1, description: "Size at zero pressure as a fraction of the full size (when pressureSize)." },
10601
- pressureFlow: { type: "number", minimum: 0, maximum: 1, description: "How much pressure scales flow." },
10602
- tiltSize: { type: "number", minimum: 0, maximum: 1, description: "Laying the pen down widens the tip (up to 2.5\xD7)." },
10603
- tiltRoundness: { type: "number", minimum: 0, maximum: 1, description: "Laying the pen down flattens the tip along the tilt (side-of-the-pencil shading)." },
10604
- tiltOpacity: { type: "number", minimum: 0, maximum: 1, description: "Laying the pen down lightens the mark." },
10605
- velocitySize: { type: "number", minimum: -1, maximum: 1, description: "Positive grows the tip on fast strokes, negative thins it (needs point times t)." },
10606
- velocityOpacity: { type: "number", minimum: -1, maximum: 1, description: "Positive darkens fast marks, negative lightens them (needs point times t)." },
10607
- sizeJitter: { type: "number", minimum: 0, maximum: 1, description: "Random size change per dab." },
10608
- angleJitter: { type: "number", minimum: 0, maximum: 1, description: "Random angle per dab, as a fraction of \xB1180\xB0." },
10609
- opacityJitter: { type: "number", minimum: 0, maximum: 1, description: "Random opacity drop per dab." },
10610
- scatter: { type: "number", minimum: 0, maximum: 4, description: "Random offset across the stroke, in diameters." }
10611
- },
10612
- additionalProperties: false
10613
- };
10614
- var MIXER = {
10615
- type: "object",
10616
- description: "Mixer engine settings (the Mixer Brush). Missing fields keep their current values.",
10617
- properties: {
10618
- wet: { type: "number", minimum: 0, maximum: 1, description: "Pick-up ratio: how much canvas colour each dab picks up, 0 (dry) .. 1." },
10619
- 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." },
10620
- 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." },
10621
- loadEachStroke: { type: "boolean", description: "Refill the reservoir with the colour before each stroke." },
10622
- cleanEachStroke: { type: "boolean", description: "Empty the pickup well after each stroke." },
10623
- mixing: { type: "string", enum: [...MIXING_MODELS], description: "rgb: RGB averages. pigment: mixes like paint (Kubelka-Munk)." }
10624
- },
10625
- additionalProperties: false
10626
- };
10627
- var MIXING = { type: "string", enum: [...MIXING_MODELS], description: "pigment: mixes like paint (Kubelka-Munk, blue and yellow make green); rgb: straight RGB averages." };
10628
- var BEHAVIOR = {
10629
- type: "object",
10630
- 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.",
10631
- properties: {
10632
- 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)." },
10633
- 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)." },
10634
- 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." },
10635
- mixing: MIXING,
10636
- multicolor: {
10637
- type: "object",
10638
- description: "Multi-coloured brush (the older form of paint, the brush's own gradient: tip is across-tip, dab is distance in dabs or random). Read back from paint when it has a multi-colour equivalent. Missing fields keep their current values.",
10639
- properties: {
10640
- 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." },
10641
- colors: { type: "array", maxItems: 8, items: { ...COLOR3 }, description: "2..8 colours #rrggbb; fewer than 2 uses the paint colour and the secondary colour." },
10642
- 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)." },
10643
- length: { type: "integer", minimum: 1, maximum: 500, description: "dab mode: dabs per colour (default 12)." },
10644
- softness: { type: "number", minimum: 0, maximum: 1, description: "How softly neighbouring colours blend: 0 hard stripes or steps, 1 a smooth gradient." }
10645
- },
10646
- additionalProperties: false
10647
- }
10648
- },
10649
- additionalProperties: false
10650
- };
10651
- var BRUSH_PAINT = {
10652
- description: `The brush's own gradient paint, used instead of the editor's paint (null: the editor's paint). It is what the multi-colour settings became; stops may be "paint" / "secondary" for the editor's two colours.`,
10653
- anyOf: [PAINT_GRADIENT_JSON_SCHEMA, { type: "null", description: "No gradient of its own: the editor's paint." }]
10654
- };
10655
- var BLENDER = {
10656
- type: "object",
10657
- description: "Blender engine settings. Missing fields keep their current values.",
10658
- properties: {
10659
- 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." },
10660
- 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)." },
10661
- mixing: MIXING
10662
- },
10663
- additionalProperties: false
10664
- };
10665
- var SMUDGE = {
10666
- type: "object",
10667
- description: "Smudge engine settings. Missing fields keep their current values.",
10668
- properties: {
10669
- strength: { type: "number", minimum: 0, maximum: 1, description: "How strongly the dragged paint covers what is under the tip, per dab." },
10670
- 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)." },
10671
- mixing: { ...MIXING, description: "rgb (default): smeared colours average; pigment: they mix like paint." }
10672
- },
10673
- additionalProperties: false
10674
- };
10675
- var WET_BRUSH = {
10676
- type: "object",
10677
- description: "Wet engine settings (watercolour, gouache and the wet tools). Missing fields keep their current values.",
10678
- properties: {
10679
- tool: {
10680
- type: "string",
10681
- enum: [...WET_TOOLS],
10682
- description: "paint: water and pigment (picks up and mixes wet paint by behavior.pickup / mix); water: clear water (with rewet it lifts dried paint); dry: a hair dryer, takes water; blow: pushes the water along the stroke; blend: evens out wet pigment; smudge: drags wet paint; blot: a tissue, takes wet paint up (erase wet)."
10683
- },
10684
- medium: { type: "string", enum: [...WET_MEDIA], description: "The medium: watercolor, gouache or oil. A layer takes the medium's wet settings the first time it holds wet paint (oil: thick, barely flows, stays wet long and blends into the wet paint it meets)." },
10685
- load: { type: "number", minimum: 0, maximum: 1, description: "Paint loading: 1 never runs out; lower, the brush runs dry along a long stroke (each stroke reloads)." },
10686
- thinner: { type: "number", minimum: 0, maximum: 1, description: "Thinner (water or solvent): more transparent, runnier paint." },
10687
- water: { type: "number", minimum: 0, maximum: 1, description: "Wetness: water laid per stroke, 0.2 a damp brush .. 1 a dripping one." },
10688
- pigment: { type: "number", minimum: 0, maximum: 1, description: "Pigment concentration: 0 clear water .. 1 straight from the tube." },
10689
- body: { type: "number", minimum: 0, maximum: 1, description: "Body: 0 a transparent watercolour (glazes) .. 1 opaque gouache (hides what is under it)." },
10690
- strength: { type: "number", minimum: 0, maximum: 1, description: "Strength of dry, blow, blend, smudge and blot." },
10691
- rewet: { type: "number", minimum: 0, maximum: 1, description: "How much dried paint the water lifts again: 0 none (staining) .. 1." }
10692
- },
10693
- additionalProperties: false
10694
- };
10695
- var IMPASTO_BRUSH = {
10696
- type: "object",
10697
- description: "Impasto engine settings (thick paint with relief, lit by set_lighting). Missing fields keep their current values.",
10698
- properties: {
10699
- tool: {
10700
- type: "string",
10701
- enum: [...IMPASTO_TOOLS],
10702
- description: "brush: thick paint with bristle ridges along the stroke; tube: blobs squeezed from the tube; knife: a palette knife that scrapes the relief flat and smears the paint; roller: a flat stippled band; gloop: a thick, glossy, rounded bead."
10703
- },
10704
- thickness: { type: "number", minimum: 0, maximum: 1, description: "How far the paint stands up." },
10705
- gloss: { type: "number", minimum: 0, maximum: 1, description: "Shine: 0 matte .. 1 wet-looking gloss." },
10706
- metallic: { type: "number", minimum: 0, maximum: 1, description: "Metallic paint: highlights take the paint colour (gold, copper, silver)." },
10707
- load: { type: "number", minimum: 0, maximum: 1, description: "Paint loading: 1 never runs out; lower, the stroke thins and fades as the brush empties." },
10708
- thinner: { type: "number", minimum: 0, maximum: 1, description: "Thinner: flatter, more transparent paint." },
10709
- drag: { type: "number", minimum: 0, maximum: 1, description: "Knife and brush: how much of the paint under the tool is dragged along." },
10710
- rim: { type: "number", minimum: 0, maximum: 1, description: "Light paint (with light.amount): the bead's light is stronger where its surface turns away, as on backlit glass (default 0)." },
10711
- clarity: { type: "number", minimum: 0, maximum: 1, description: "Glassy paint: the middle of a bead lets the layers below show through while its edges stay solid (default 0). flatten_relief clears it." }
10712
- },
10713
- additionalProperties: false
10714
- };
10715
- var LIGHT_BRUSH = {
10716
- type: "object",
10717
- description: "Light paint: the light the brush lays with its colour, glowing in the paint's own colour and spilling onto its surroundings (set_light_spill). Dab, particle, bristle, Mixer Brush, impasto and wet paint brushes take it (wet light flows, pools, granulates and blooms with the pigment until the paint dries, then is ordinary light paint); wet tools other than paint lay none, and sticker brushes refuse light above 0. Missing fields keep their current values.",
10718
- properties: {
10719
- amount: { type: "number", minimum: 0, maximum: 1, description: "Light each dab lays, scaled by its coverage and flow (0 none, default; 1 full)." },
10720
- pressure: { type: "boolean", description: "Pressure scales the light (default false)." }
10721
- },
10722
- additionalProperties: false
10723
- };
10724
- var PARTICLE_BRUSH = {
10725
- type: "object",
10726
- description: "Particle engine settings (procedural brushes). Missing fields keep their current values.",
10727
- properties: {
10728
- kind: {
10729
- type: "string",
10730
- enum: [...PARTICLE_KINDS],
10731
- description: "string: strands of masses on springs dragged behind the pen (hair, fur, wet string); spring: particles on springs to the pen, overshooting and wobbling; orbit: particles circling the pen (curls); flow: particles thrown off the pen into a swirling flow until they fade; elastic: strands whose links keep a rest length, stretching when the pen is fast and snapping back; fuzzy: winding strings, each strand waving out of step with the others (always waves by at least 0.12 of the tip size)."
10732
- },
10733
- render: {
10734
- type: "string",
10735
- enum: [...PARTICLE_RENDERS],
10736
- description: "dot: a mark at each step (round, or dotTextures); line: each particle draws a continuous line; sequence: beads spaced along each path (round, or dotTextures); ribbon: the band between neighbouring particles' paths filled (ribbonFill), with the paths drawn as its edges at the mark size (flame, silk, smoke)."
10737
- },
10738
- count: { type: "integer", minimum: 1, maximum: 64, description: "Particles (flow: how many are thrown per tip of travel)." },
10739
- size: { type: "number", minimum: 0, maximum: 1, description: "Mark size as a fraction of the tip size (ribbon: the width of its edges)." },
10740
- spread: { type: "number", minimum: 0, maximum: 1, description: "How far apart the particles start, as a fraction of the tip size (ribbon: how wide it is)." },
10741
- stiffness: { type: "number", minimum: 0, maximum: 1, description: "Spring, string and elastic stiffness: stiff follows closely (elastic: snaps back faster), soft swings wide." },
10742
- damping: { type: "number", minimum: 0, maximum: 1, description: "How quickly motion dies down." },
10743
- speed: { type: "number", minimum: 0, maximum: 1, description: "Orbit and flow speed." },
10744
- gravity: { type: "number", minimum: -1, maximum: 1, description: "Pull downwards (negative: upwards)." },
10745
- life: { type: "number", minimum: 0, maximum: 1, description: "Flow particles: how long they live." },
10746
- interval: { type: "number", minimum: 0, maximum: 1, description: "Sequence: spacing of the beads." },
10747
- ribbonFill: { type: "number", minimum: 0, maximum: 1, description: "Ribbon: opacity of the fill between the paths, relative to their edges (default 0.5; 0 draws only the edges)." },
10748
- ribbonPairs: { type: "string", enum: [...RIBBON_PAIRS], description: "Ribbon: adjacent (default) joins each particle to its neighbour around the pen; first-last makes one band between the outermost two." },
10749
- waveAmplitude: { type: "number", minimum: 0, maximum: 1, description: "Wave: how far each particle's drawn place swings sideways to the stroke, as a fraction of the tip size (default 0: no wave). Works with every kind and render." },
10750
- waveLength: { type: "number", minimum: 4, maximum: 2e3, description: "Wave: wavelength in px of pen travel (default 60)." },
10751
- waveSpeed: { type: "number", minimum: 0, maximum: 1, description: "Wave: how fast the waves run, by stroke time (0 default: they stand still on the path; 1: about four a second, so slow strokes wave more). Strokes without times run at a nominal 0.5 px/ms." },
10752
- wavePhaseJitter: { type: "number", minimum: 0, maximum: 1, description: "Wave: how far out of step the particles' waves are (default 0: all in step, a wavy band; 1: random phases, tangled strands)." },
10753
- dotTextures: {
10754
- type: "array",
10755
- maxItems: MAX_DOT_TEXTURES,
10756
- items: { type: "string", enum: [...STAMP_KINDS], description: "A stamp." },
10757
- description: `Dot and sequence renders: up to ${MAX_DOT_TEXTURES} sticker stamps (list_stickers) drawn instead of round marks; repeats are allowed (they weight the order). Empty (default): round marks.`
10758
- },
10759
- dotTextureOrder: { type: "string", enum: [...DOT_TEXTURE_ORDERS], description: "Textured dots: sequence (default) uses the stamps in turn; random picks one per dot (seeded, so replays are exact)." },
10760
- dotRotation: { type: "string", enum: [...DOT_ROTATIONS], description: "Textured dots: follow turns each with its particle's motion; random (default) turns each at random; fixed uses the tip angle." }
10761
- },
10762
- additionalProperties: false
10763
- };
10764
- var BRISTLE_BRUSH = {
10765
- type: "object",
10766
- description: "Bristle engine settings: a tuft of bristles, each its own line. Missing fields keep their current values.",
10767
- properties: {
10768
- count: { type: "integer", minimum: 3, maximum: 128, description: "Bristles in the tuft." },
10769
- stiffness: { type: "number", minimum: 0, maximum: 1, description: "Stiff bristles follow the tip; soft ones lag and bend into curves at turns." },
10770
- splay: { type: "number", minimum: 0, maximum: 1, description: "How far pressure spreads the tuft." },
10771
- thickness: { type: "number", minimum: 0, maximum: 1, description: "Each bristle's line width as a fraction of the tip size." },
10772
- load: { type: "number", minimum: 0, maximum: 1, description: "Paint per bristle: 1 never runs dry; lower, bristles run out along the stroke (dry brush)." },
10773
- jitter: { type: "number", minimum: 0, maximum: 1, description: "Random variation between bristles." }
10774
- },
10775
- additionalProperties: false
10776
- };
10777
- var STICKER_BRUSH = {
10778
- type: "object",
10779
- description: "Sticker engine settings: stamps sprayed along the stroke as editable stickers. Missing fields keep their current values.",
10780
- properties: {
10781
- stamps: {
10782
- type: "array",
10783
- minItems: 1,
10784
- maxItems: MAX_STICKER_STAMPS,
10785
- items: { type: "string", minLength: 1, description: "A sticker ref provider:id from find_stickers (e.g. editor:star); a bare editor stamp (star) means editor:star." },
10786
- description: `The stickers to spray, one picked at random per sticker: refs from find_stickers (the editor's own: ${STAMP_KINDS.map((k) => `editor:${k}`).join(", ")}). Each is checked against the sticker library and an unknown ref is refused with the closest ones. A provider's sticker is drawn from its art (tinted to the paint colour when tintable), loaded before the stroke.`
10787
- },
10788
- density: { type: "number", minimum: 0, maximum: 1, description: "How closely stickers follow each other: about density \xD7 4 stickers per tip diameter of travel (0.25 \u2248 one per diameter, 1 \u2248 four). Default 0.4." },
10789
- sizeJitter: { type: "number", minimum: 0, maximum: 1, description: "Random size change." },
10790
- rotationJitter: { type: "number", minimum: 0, maximum: 1, description: "Random turn as a fraction of \xB1180\xB0." },
10791
- scatter: { type: "number", minimum: 0, maximum: 2, description: "How far from the path stickers land, in tip diameters." },
10792
- colorJitter: { type: "number", minimum: 0, maximum: 1, description: "Random colour change around the paint colour: at 1, up to \xB140\xB0 of hue and \xB10.15 OKLab lightness, kept in gamut. Default 0.2." }
10793
- },
10794
- additionalProperties: false
10795
- };
10796
- var BRUSH_FIELDS = {
10797
- name: { type: "string", minLength: 1, maxLength: 80, description: "Display name." },
10798
- category: { type: "string", enum: [...PAINT_BRUSH_CATEGORIES], description: "Library category." },
10799
- engine: {
10800
- type: "string",
10801
- enum: [...PAINT_BRUSH_ENGINES],
10802
- description: "dab: paints dabs (with behavior.pickup or gradient paint 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; wet: watercolour, gouache, oil and the wet tools on a simulated wet layer (see wet); impasto: thick paint with relief (see impasto); particle: procedural particle brushes (see particle); bristle: a tuft of bristles that bend and splay (see bristle); sticker: sprays editable sticker stamps (see sticker)."
10803
- },
10804
- tip: TIP,
10805
- 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." },
10806
- pressureSmoothing: { type: "number", minimum: 0, maximum: 0.95, description: "Pressure smoothing, separate from the tip position smoothing." },
10807
- 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)." },
10808
- dynamics: DYNAMICS,
10809
- mixer: MIXER,
10810
- loadsColor: { type: "boolean", description: "Mixer engine: load the colour each stroke (false for a mixer that only moves paint)." },
10811
- behavior: BEHAVIOR,
10812
- blender: BLENDER,
10813
- smudge: SMUDGE,
10814
- wet: WET_BRUSH,
10815
- impasto: IMPASTO_BRUSH,
10816
- particle: PARTICLE_BRUSH,
10817
- bristle: BRISTLE_BRUSH,
10818
- sticker: STICKER_BRUSH,
10819
- light: LIGHT_BRUSH,
10820
- paint: BRUSH_PAINT
10821
- };
10822
10765
  var TIME = { type: "number", minimum: 0, description: "Draw live effect paint (strokes on stroke layers painted with live effect paint) this many ms into its animation (default: as painted, its own time)." };
10823
10766
  var POINTS = {
10824
10767
  type: "array",
@@ -10846,26 +10789,6 @@ function loadResult(load) {
10846
10789
  const hex = (c) => c ? "#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("") : null;
10847
10790
  return { reservoir: hex(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex(load.pickup) };
10848
10791
  }
10849
- function brushSummary(b) {
10850
- return {
10851
- gradient: b.paint !== null,
10852
- id: b.id,
10853
- name: b.name,
10854
- category: b.category,
10855
- engine: b.engine,
10856
- size: b.tip.size,
10857
- grain: b.grain,
10858
- rotation: b.rotation,
10859
- wetCapable: isWetCapable(b),
10860
- pickup: b.behavior.pickup,
10861
- dirty: b.behavior.dirty,
10862
- multicolor: b.behavior.multicolor.mode,
10863
- ...b.engine === "wet" ? { wetTool: b.wet.tool, medium: b.wet.medium } : {},
10864
- ...b.engine === "impasto" ? { impastoTool: b.impasto.tool } : {},
10865
- ...b.light.amount > 0 ? { light: b.light.amount } : {},
10866
- builtin: Boolean(b.builtin)
10867
- };
10868
- }
10869
10792
  function createPaintEditorToolProvider(controller, options = {}) {
10870
10793
  const c = typeof controller === "function" ? lazyController(controller) : controller;
10871
10794
  const tools = [];
@@ -11180,121 +11103,31 @@ function createPaintEditorToolProvider(controller, options = {}) {
11180
11103
  coordinates: { out: ["bounds"] },
11181
11104
  handler: ({ layerId }) => c.clearLayer(layerId)
11182
11105
  });
11183
- add({
11184
- name: "list_brushes",
11185
- title: "List brushes",
11186
- 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), light paint (category light: glow gel, neon marker, light airbrush, spark trail, light ribbon, flame ribbon, neon strand, smoke ribbon, bokeh dots, twinkle sparks, glow wash, highlighter; light is the amount they lay) 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.",
11187
- inputSchema: EMPTY3,
11188
- annotations: { title: "List brushes", ...READ6 },
11189
- handler: () => {
11190
- const s = c.getState();
11191
- return { activeBrushId: s.brush.id, activeEdited: !s.brush.builtin && Boolean(c.getBrush(s.brush.id)?.builtin), brushes: s.brushes.map(brushSummary) };
11192
- }
11193
- });
11194
11106
  add({
11195
11107
  name: "get_brush",
11196
11108
  title: "Get a brush",
11197
- description: "Reads every setting of a brush (tip, grain, pressure smoothing, rotation, dynamics, mixer, behavior: pick-up / mix / dirty, blender, smudge, its own gradient paint as paint, null when it paints the editor's paint). Without id, the active brush as edited. Also returns strokePaint: what a stroke with it would paint now (its gradient, else the editor's paint from set_paint).",
11198
- inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brushes (default the active brush)." } }, additionalProperties: false },
11109
+ description: "Reads a brush as facets (kind, medium, variety or tool, tip, marks, dynamics, behaviour, light, motion, colour: the brush library's form, as describe_brush returns it), with the engine it paints with and whether it is an unchanged built-in preset. Without id, the active brush as edited. Also returns strokePaint: what a stroke with it would paint now (its gradient colour, else the editor's paint from set_paint). Changes nothing.",
11110
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." } }, additionalProperties: false },
11199
11111
  annotations: { title: "Get a brush", ...READ6 },
11200
11112
  handler: ({ id }) => {
11201
11113
  const st = c.getState();
11202
- if (id === void 0) return { ...brushToJSON(st.brush), strokePaint: st.brush.paint ?? st.paint };
11203
- const b = c.getBrush(id);
11204
- if (!b) throw new Error(`Unknown brush "${id}". Call list_brushes for the ids.`);
11205
- return { ...brushToJSON(b), builtin: Boolean(b.builtin), strokePaint: b.paint ?? st.paint };
11114
+ const b = id === void 0 ? st.brush : c.getBrush(id);
11115
+ if (!b) c.setBrushFacets({}, id);
11116
+ const brush = b;
11117
+ return { brush: c.getBrushSpec(id), engine: brush.engine, category: brush.category, builtin: Boolean(brush.builtin), strokePaint: brush.paint ?? st.paint };
11206
11118
  }
11207
11119
  });
11208
11120
  add({
11209
11121
  name: "set_brush",
11210
11122
  title: "Set the brush",
11211
- description: "Chooses the active brush (id) and / or changes its settings; strokes without a brush use it. Editor state, not an undo step. Changes stay on the active brush until save_brush stores them. Returns the active brush.",
11212
- inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brushes to start from." }, ...BRUSH_FIELDS }, additionalProperties: false },
11123
+ description: "Chooses the active brush (id, from list_brush_presets) and / or changes it with facets laid over it (objects merge, lists and values replace, marks of another kind replace the marks; the brush library's create_brush fields); unset removes whole facets first (unset colour drops a brush's own gradient). Strokes without a brush use it. Editor state, not an undo step: the changes stay on the active brush until the library's create_brush or update_brush stores a brush. Returns the active brush as facets, the engine it paints with, and problems (values clamped or dropped).",
11124
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brush_presets to start from (default the active brush)." }, name: { type: "string", minLength: 1, maxLength: 80, description: "Display name." }, ...BRUSH_PATCH_PROPERTIES, unset: BRUSH_UNSET_PROPERTY }, additionalProperties: false },
11213
11125
  annotations: { title: "Set the brush", ...SET4 },
11214
- handler: async (a) => {
11215
- const stamps = a.sticker?.stamps;
11126
+ handler: async ({ id, unset, ...patch }) => {
11127
+ const stamps = patch.tip?.stamps;
11216
11128
  if (stamps) await c.prepareStickers(stamps);
11217
- return brushToJSON(c.setBrush(a));
11218
- }
11219
- });
11220
- add({
11221
- name: "save_brush",
11222
- title: "Save a brush",
11223
- description: "Saves the active brush (with its changes) into the user library under a new or existing user id. Built-in ids are refused. Library state, not an undo step. Returns the saved brush.",
11224
- inputSchema: {
11225
- type: "object",
11226
- properties: {
11227
- id: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,63}$", description: "Library id: lowercase letters, digits and dashes." },
11228
- name: { type: "string", minLength: 1, maxLength: 80, description: "Display name (default the brush name)." }
11229
- },
11230
- required: ["id"],
11231
- additionalProperties: false
11232
- },
11233
- annotations: { title: "Save a brush", ...SET4 },
11234
- handler: ({ id, name }) => brushToJSON(c.saveBrush({ id, name }))
11235
- });
11236
- add({
11237
- name: "remove_brush",
11238
- title: "Remove a saved brush",
11239
- description: "Removes a user brush from the library (built-in brushes cannot be removed). Library state, not an undo step. Returns the removed id.",
11240
- inputSchema: { type: "object", properties: { id: { type: "string", description: "User brush id from list_brushes." } }, required: ["id"], additionalProperties: false },
11241
- annotations: { title: "Remove a saved brush", ...DESTRUCTIVE3 },
11242
- handler: ({ id }) => {
11243
- c.removeBrush(id);
11244
- return { removed: id };
11245
- }
11246
- });
11247
- add({
11248
- name: "export_brushes",
11249
- title: "Export brushes",
11250
- description: "Returns a brush library file (JSON: format pixerate-brushes, version 1, brushes) of the given brushes, or of every user brush. Changes nothing.",
11251
- inputSchema: {
11252
- type: "object",
11253
- properties: { ids: { type: "array", items: { type: "string", description: "Brush id." }, description: "Brush ids from list_brushes (default every user brush)." } },
11254
- additionalProperties: false
11255
- },
11256
- annotations: { title: "Export brushes", ...READ6 },
11257
- handler: ({ ids }) => c.exportBrushes(ids)
11258
- });
11259
- add({
11260
- name: "import_brushes",
11261
- title: "Import brushes",
11262
- description: "Adds the brushes of a brush library file (as export_brushes returns) to the user library. Taken ids are skipped unless replace is true; built-in ids are always skipped. Library state, not an undo step. Returns the ids added and skipped and any problems.",
11263
- inputSchema: {
11264
- type: "object",
11265
- properties: {
11266
- library: { type: "object", description: 'The library file: {format: "pixerate-brushes", version: 1, brushes: [...]}.' },
11267
- replace: { type: "boolean", description: "Replace user brushes with the same id (default false)." }
11268
- },
11269
- required: ["library"],
11270
- additionalProperties: false
11271
- },
11272
- annotations: { title: "Import brushes", ...WRITE5 },
11273
- handler: ({ library, replace }) => c.importBrushes(library, { replace })
11274
- });
11275
- add({
11276
- name: "render_brush_thumbnail",
11277
- title: "Render a brush thumbnail",
11278
- description: "Renders a brush as a PNG: a test stroke (an S-curve swelling and fading in pressure) on the current paper, so you can see what it does before painting. Changes nothing.",
11279
- inputSchema: {
11280
- type: "object",
11281
- properties: {
11282
- id: { type: "string", description: "Brush id from list_brushes (default the active brush)." },
11283
- width: { type: "integer", minimum: 16, maximum: 512, description: "Width in pixels (default 160)." },
11284
- height: { type: "integer", minimum: 16, maximum: 512, description: "Height in pixels (default 48)." },
11285
- color: { ...COLOR3, description: "Stroke colour (default the paint colour)." }
11286
- },
11287
- additionalProperties: false
11288
- },
11289
- annotations: { title: "Render a brush thumbnail", ...READ6 },
11290
- handler: ({ id, width, height, color }) => {
11291
- const t = c.brushThumbnail(id ?? c.getState().brush.id, { width, height, color });
11292
- return {
11293
- content: [
11294
- { type: "image", data: bytesToBase64(encodePng(t.width, t.height, t.data)), mimeType: "image/png" },
11295
- { type: "text", text: `Brush ${id ?? c.getState().brush.id} ${t.width}\xD7${t.height}` }
11296
- ]
11297
- };
11129
+ const r = c.setBrushFacets(patch, id, unset);
11130
+ return { brush: r.spec, engine: r.brush.engine, problems: r.problems };
11298
11131
  }
11299
11132
  });
11300
11133
  add({
@@ -11371,14 +11204,14 @@ function createPaintEditorToolProvider(controller, options = {}) {
11371
11204
  add({
11372
11205
  name: "paint_stroke",
11373
11206
  title: "Paint a brush stroke",
11374
- 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, and wet brushes (engine wet: watercolour, gouache, add water, dry, blow, wet blend, wet smudge, blot) paint on the layer\u2019s wet layer, where the paint keeps moving until it dries (the result says wet: true while it is; see advance_wet_paint and dry_paint; a dry brush on wet paint dries it first; wet oil brushes blend into the wet oil they meet; with light.amount, as glow-wash has, their light flows with the paint), impasto brushes (engine impasto: impasto brush, paint tube, palette knife, roller, gloop pen, metallic paint) lay thick paint whose relief is lit by set_lighting, particle and bristle brushes draw procedural marks, and sticker brushes spray editable stickers onto an empty or sticker layer (list_stickers). Guide stencils make strokes that start near them follow them; mask stencils keep paint inside them. 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), for wet brushes wet (paint still wet) and, for brushes that pick up paint, mixerLoad: what the brush carries after the stroke.",
11207
+ description: "Paints one stroke through points (document pixels, with optional pressure, time, tilt and barrel rotation) on a pixel layer of the canvas with a library brush or the active one, optionally changed (by facets) 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, and wet brushes (engine wet: watercolour, gouache, add water, dry, blow, wet blend, wet smudge, blot) paint on the layer\u2019s wet layer, where the paint keeps moving until it dries (the result says wet: true while it is; see advance_wet_paint and dry_paint; a dry brush on wet paint dries it first; wet oil brushes blend into the wet oil they meet; with light.amount, as glow-wash has, their light flows with the paint), impasto brushes (engine impasto: impasto brush, paint tube, palette knife, roller, gloop pen, metallic paint) lay thick paint whose relief is lit by set_lighting, particle and bristle brushes draw procedural marks, and sticker brushes spray editable stickers onto an empty or sticker layer (list_stickers). Guide stencils make strokes that start near them follow them; mask stencils keep paint inside them. 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), for wet brushes wet (paint still wet) and, for brushes that pick up paint, mixerLoad: what the brush carries after the stroke.",
11375
11208
  inputSchema: {
11376
11209
  type: "object",
11377
11210
  properties: {
11378
11211
  points: POINTS,
11379
11212
  layerId: LAYER_ID4,
11380
- brushId: { type: "string", description: "Brush id from list_brushes (default the active brush)." },
11381
- brush: { type: "object", description: "Brush changes for this stroke only.", properties: BRUSH_FIELDS, additionalProperties: false },
11213
+ brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
11214
+ brush: { type: "object", description: "Facets to change for this stroke only, laid over brushId or the active brush (as set_brush takes them).", properties: BRUSH_PATCH_PROPERTIES, additionalProperties: false },
11382
11215
  color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
11383
11216
  paint: { ...PAINT_SOURCE_JSON_SCHEMA, description: "What this stroke paints with instead of the editor's paint: a colour, or gradient paint that changes colour as the stroke goes (by distance, pressure, tilt, speed, direction, time, across the tip or at random). Wins over the brush's own gradient." },
11384
11217
  seed: { type: "integer", minimum: 0, description: "Jitter seed (default a new one); the same seed and points give the same marks." },
@@ -11392,9 +11225,9 @@ function createPaintEditorToolProvider(controller, options = {}) {
11392
11225
  handler: async (a) => {
11393
11226
  await c.wetIdle();
11394
11227
  const base = a.brushId ? c.getBrush(a.brushId) : c.getState().brush;
11395
- const over = a.brush;
11396
- if ((over?.engine ?? base?.engine) === "sticker") await c.prepareStickers(over?.sticker?.stamps ?? base?.sticker.stamps ?? []);
11397
- const stroke = { points: a.points, layerId: a.layerId, brushId: a.brushId, brush: a.brush, color: a.color, paint: a.paint, seed: a.seed, snapToPerspective: a.snapToPerspective };
11228
+ const brush = a.brush ? c.brushChanges(a.brush, a.brushId) : void 0;
11229
+ if ((brush?.engine ?? base?.engine) === "sticker") await c.prepareStickers(brush?.sticker?.stamps ?? base?.sticker.stamps ?? []);
11230
+ const stroke = { points: a.points, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed, snapToPerspective: a.snapToPerspective };
11398
11231
  const r = await act({ kind: "stroke", stroke }, () => c.paintStroke(stroke));
11399
11232
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11400
11233
  }
@@ -11457,8 +11290,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
11457
11290
  type: "object",
11458
11291
  properties: {
11459
11292
  id: { type: "string", description: "Stroke id from list_strokes {live: true}." },
11460
- brushId: { type: "string", description: "Brush preset id from list_brushes; the stroke keeps its size." },
11461
- brush: { type: "object", properties: BRUSH_FIELDS, additionalProperties: false, description: "Brush settings to change on top of brushId or the stroke\u2019s brush; missing fields keep their values." },
11293
+ brushId: { type: "string", description: "Brush id from list_brush_presets; the stroke keeps its size." },
11294
+ brush: { type: "object", properties: BRUSH_PATCH_PROPERTIES, additionalProperties: false, description: "Facets to change on top of brushId or the stroke\u2019s brush (as set_brush takes them); missing fields keep their values." },
11462
11295
  color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: "New colour #rrggbb (replaces gradient paint)." },
11463
11296
  paint: { anyOf: [PAINT_SOURCE_JSON_SCHEMA, { type: "null", description: "Back to the stroke\u2019s plain colour." }], description: "A colour or gradient paint for the stroke; null drops its gradient." },
11464
11297
  seed: { type: "integer", minimum: 0, description: "Another jitter seed: particles, scatter and texture fall differently." }
@@ -11470,6 +11303,11 @@ function createPaintEditorToolProvider(controller, options = {}) {
11470
11303
  coordinates: { out: ["bounds", "stroke.bounds"] },
11471
11304
  handler: ({ id, ...update }) => {
11472
11305
  if (!Object.keys(update).length) throw new Error("Pass at least one change: brushId, brush, color, paint or seed.");
11306
+ if (update.brush) {
11307
+ const stroke = Object.values(c.document.liveStrokes ?? {}).flat().find((s) => s.id === id);
11308
+ if (!stroke) throw new Error(`Unknown stroke "${id}" on a stroke layer. Call list_strokes {live: true} for the ids.`);
11309
+ update.brush = c.brushChanges(update.brush, update.brushId ?? stroke.brush);
11310
+ }
11473
11311
  const r = c.updateStroke(id, update);
11474
11312
  return { stroke: strokeSummary(r.stroke, false, true), bounds: r.bounds };
11475
11313
  }
@@ -11574,7 +11412,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11574
11412
  },
11575
11413
  pressureScale: { type: "number", minimum: 0, maximum: 4, description: "Multiply every pressure (default 1)." },
11576
11414
  color: { ...COLOR3, description: "Recolour (default each stroke\u2019s colour)." },
11577
- brushId: { type: "string", description: "Replay with this brush from list_brushes (default each stroke\u2019s brush)." },
11415
+ brushId: { type: "string", description: "Replay with this brush from list_brush_presets (default each stroke\u2019s brush)." },
11578
11416
  layerId: { type: "string", description: "Paint on this layer (default each stroke\u2019s layer)." },
11579
11417
  repeat: { type: "integer", minimum: 1, maximum: 100, description: "How many copies (default 1)." }
11580
11418
  },
@@ -11761,6 +11599,18 @@ function strokeSummary(s, withPoints, live) {
11761
11599
  };
11762
11600
  }
11763
11601
 
11602
+ // src/paint-editor/brush-library-tools.ts
11603
+ function createPaintBrushLibraryToolProvider(controller, options = {}) {
11604
+ return createBrushLibraryToolProvider(controller.brushLibrary, {
11605
+ profile: PAINT_PROFILE,
11606
+ ...options,
11607
+ renderPreview: (brush, { width, height, color }) => {
11608
+ const t = renderBrushThumbnail(resolveBrush(brush), { paper: controller.getState().document.paper, width, height, color: color.toLowerCase() });
11609
+ return { data: bytesToBase64(encodePng(t.width, t.height, t.data)), mimeType: "image/png" };
11610
+ }
11611
+ });
11612
+ }
11613
+
11764
11614
  // src/paint-editor/ui.ts
11765
11615
  var PAINT_TOOLS = [
11766
11616
  { id: "brush", label: "Brush", key: "b" },
@@ -12087,21 +11937,6 @@ function guideOverlay(controller, drag) {
12087
11937
  if (s.guides.perspective.enabled) for (const [from, to] of perspectiveGuideLines(s.guides.perspective, size, 16)) out.push({ from, to, kind: "perspective" });
12088
11938
  return out;
12089
11939
  }
12090
- var BRUSH_SLIDERS = [
12091
- { path: "tip.size", label: "Size", min: 1, max: 400, step: 1 },
12092
- { path: "tip.opacity", label: "Opacity", min: 0, max: 1, step: 0.01 },
12093
- { path: "tip.flow", label: "Flow", min: 0, max: 1, step: 0.01 },
12094
- { path: "tip.hardness", label: "Hardness", min: 0, max: 1, step: 0.01 },
12095
- { path: "tip.spacing", label: "Spacing", min: 0.01, max: 1, step: 0.01 },
12096
- { path: "tip.roundness", label: "Roundness", min: 0.05, max: 1, step: 0.01 },
12097
- { path: "tip.angle", label: "Angle", min: -180, max: 180, step: 1 },
12098
- { path: "grain", label: "Grain", min: 0, max: 1, step: 0.01 },
12099
- { path: "tip.smoothing", label: "Smoothing", min: 0, max: 0.95, step: 0.01 },
12100
- { path: "pressureSmoothing", label: "Pressure smoothing", min: 0, max: 0.95, step: 0.01 },
12101
- { path: "dynamics.minSize", label: "Min size", min: 0, max: 1, step: 0.01 },
12102
- { path: "dynamics.tiltSize", label: "Tilt size", min: 0, max: 1, step: 0.01 },
12103
- { path: "dynamics.velocitySize", label: "Speed size", min: -1, max: 1, step: 0.01 }
12104
- ];
12105
11940
  var PAPER_SLIDERS = [
12106
11941
  { path: "depth", label: "Grain depth", min: 0, max: 1, step: 0.01 },
12107
11942
  { path: "scale", label: "Grain scale", min: 0.25, max: 8, step: 0.05 },
@@ -12160,158 +11995,6 @@ var BLEND_MENU_GROUPS = [
12160
11995
  { label: "Standard", modes: BLEND_MODES.filter((m) => !m.painterly && m.id !== "pigment") },
12161
11996
  { label: "Paint", modes: BLEND_MODES.filter((m) => m.painterly || m.id === "pigment") }
12162
11997
  ];
12163
- var LIGHT_SLIDER = { path: "light.amount", label: "Light", min: 0, max: 1, step: 0.01 };
12164
- var PICKUP_SLIDERS = [
12165
- { path: "behavior.pickup", label: "Pick-up", min: 0, max: 1, step: 0.01 },
12166
- { path: "behavior.mix", label: "Mix", min: 0, max: 1, step: 0.01 }
12167
- ];
12168
- function brushEngineControls(brush) {
12169
- const c = engineControls(brush);
12170
- const wetPaint = brush.engine === "wet" && brush.wet?.tool === "paint";
12171
- if (!c || !(wetPaint || ["dab", "mixer", "particle", "bristle", "impasto"].includes(brush.engine))) return c;
12172
- const extra = brush.engine === "impasto" ? [{ path: "impasto.rim", label: "Rim", min: 0, max: 1, step: 0.01 }, { path: "impasto.clarity", label: "Clarity", min: 0, max: 1, step: 0.01 }] : [];
12173
- return { ...c, sliders: [...c.sliders, LIGHT_SLIDER, ...extra], light: true };
12174
- }
12175
- function engineControls(brush) {
12176
- switch (brush.engine) {
12177
- case "particle":
12178
- return {
12179
- title: "Particles",
12180
- sliders: [
12181
- { path: "particle.count", label: "Particles", min: 1, max: 64, step: 1 },
12182
- { path: "particle.size", label: "Mark size", min: 0, max: 1, step: 0.01 },
12183
- { path: "particle.spread", label: "Spread", min: 0, max: 1, step: 0.01 },
12184
- { path: "particle.stiffness", label: "Stiffness", min: 0, max: 1, step: 0.01 },
12185
- { path: "particle.damping", label: "Damping", min: 0, max: 1, step: 0.01 },
12186
- { path: "particle.speed", label: "Speed", min: 0, max: 1, step: 0.01 },
12187
- { path: "particle.gravity", label: "Gravity", min: -1, max: 1, step: 0.01 },
12188
- { path: "particle.life", label: "Life", min: 0, max: 1, step: 0.01 },
12189
- { path: "particle.interval", label: "Bead spacing", min: 0, max: 1, step: 0.01 },
12190
- { path: "particle.ribbonFill", label: "Ribbon fill", min: 0, max: 1, step: 0.01 },
12191
- { path: "particle.waveAmplitude", label: "Wave", min: 0, max: 1, step: 0.01 },
12192
- { path: "particle.waveLength", label: "Wavelength", min: 4, max: 400, step: 1 },
12193
- { path: "particle.waveSpeed", label: "Wave speed", min: 0, max: 1, step: 0.01 },
12194
- { path: "particle.wavePhaseJitter", label: "Wave scatter", min: 0, max: 1, step: 0.01 }
12195
- ],
12196
- mixingPath: null,
12197
- dirty: false,
12198
- multicolor: false,
12199
- load: false,
12200
- particleMenus: true
12201
- };
12202
- case "bristle":
12203
- return {
12204
- title: "Bristles",
12205
- sliders: [
12206
- { path: "bristle.count", label: "Bristles", min: 3, max: 128, step: 1 },
12207
- { path: "bristle.stiffness", label: "Stiffness", min: 0, max: 1, step: 0.01 },
12208
- { path: "bristle.splay", label: "Splay", min: 0, max: 1, step: 0.01 },
12209
- { path: "bristle.thickness", label: "Bristle width", min: 0, max: 1, step: 0.01 },
12210
- { path: "bristle.load", label: "Load", min: 0, max: 1, step: 0.01 },
12211
- { path: "bristle.jitter", label: "Variation", min: 0, max: 1, step: 0.01 }
12212
- ],
12213
- mixingPath: null,
12214
- dirty: false,
12215
- multicolor: false,
12216
- load: false
12217
- };
12218
- case "sticker":
12219
- return {
12220
- title: "Stickers",
12221
- sliders: [
12222
- { path: "sticker.density", label: "Density", min: 0, max: 1, step: 0.01 },
12223
- { path: "sticker.sizeJitter", label: "Size jitter", min: 0, max: 1, step: 0.01 },
12224
- { path: "sticker.rotationJitter", label: "Turn jitter", min: 0, max: 1, step: 0.01 },
12225
- { path: "sticker.scatter", label: "Scatter", min: 0, max: 2, step: 0.01 },
12226
- { path: "sticker.colorJitter", label: "Colour jitter", min: 0, max: 1, step: 0.01 }
12227
- ],
12228
- mixingPath: null,
12229
- dirty: false,
12230
- multicolor: false,
12231
- load: false,
12232
- stickerStamps: true
12233
- };
12234
- case "impasto":
12235
- return {
12236
- title: "Thick paint",
12237
- sliders: [
12238
- { path: "impasto.thickness", label: "Thickness", min: 0, max: 1, step: 0.01 },
12239
- { path: "impasto.gloss", label: "Gloss", min: 0, max: 1, step: 0.01 },
12240
- { path: "impasto.metallic", label: "Metallic", min: 0, max: 1, step: 0.01 },
12241
- { path: "impasto.load", label: "Load", min: 0, max: 1, step: 0.01 },
12242
- { path: "impasto.thinner", label: "Thinner", min: 0, max: 1, step: 0.01 },
12243
- { path: "impasto.drag", label: "Drag", min: 0, max: 1, step: 0.01 },
12244
- ...PICKUP_SLIDERS
12245
- ],
12246
- mixingPath: "behavior.mixing",
12247
- dirty: true,
12248
- multicolor: false,
12249
- load: true,
12250
- impastoTool: true
12251
- };
12252
- case "wet": {
12253
- const tool = brush.wet?.tool ?? "paint";
12254
- const sliders = tool === "paint" ? [
12255
- { path: "wet.water", label: "Water", min: 0, max: 1, step: 0.01 },
12256
- { path: "wet.pigment", label: "Pigment", min: 0, max: 1, step: 0.01 },
12257
- { path: "wet.body", label: "Body (opacity)", min: 0, max: 1, step: 0.01 },
12258
- { path: "wet.rewet", label: "Re-wet", min: 0, max: 1, step: 0.01 },
12259
- { path: "wet.load", label: "Load", min: 0, max: 1, step: 0.01 },
12260
- { path: "wet.thinner", label: "Thinner", min: 0, max: 1, step: 0.01 },
12261
- ...PICKUP_SLIDERS
12262
- ] : tool === "water" ? [
12263
- { path: "wet.water", label: "Water", min: 0, max: 1, step: 0.01 },
12264
- { path: "wet.rewet", label: "Re-wet", min: 0, max: 1, step: 0.01 }
12265
- ] : [{ path: "wet.strength", label: "Strength", min: 0, max: 1, step: 0.01 }];
12266
- return { title: "Wet brush", sliders, mixingPath: tool === "paint" ? "behavior.mixing" : null, dirty: tool === "paint", multicolor: false, load: tool === "paint", wetTool: true };
12267
- }
12268
- case "blender":
12269
- return {
12270
- title: "Blender",
12271
- sliders: [
12272
- { path: "blender.strength", label: "Strength", min: 0, max: 1, step: 0.01 },
12273
- { path: "blender.distance", label: "Blend distance", min: 0, max: 1, step: 0.01 }
12274
- ],
12275
- mixingPath: "blender.mixing",
12276
- dirty: false,
12277
- multicolor: false,
12278
- load: false
12279
- };
12280
- case "smudge":
12281
- return {
12282
- title: "Smudge",
12283
- sliders: [
12284
- { path: "smudge.strength", label: "Strength", min: 0, max: 1, step: 0.01 },
12285
- { path: "smudge.length", label: "Length", min: 0, max: 1, step: 0.01 }
12286
- ],
12287
- mixingPath: "smudge.mixing",
12288
- dirty: false,
12289
- multicolor: false,
12290
- load: false
12291
- };
12292
- case "mixer":
12293
- 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 };
12294
- case "dab":
12295
- return { title: "Paint behaviour", sliders: PICKUP_SLIDERS, mixingPath: "behavior.mixing", dirty: true, multicolor: true, load: true };
12296
- default:
12297
- return null;
12298
- }
12299
- }
12300
- function multiColorSliders(mode) {
12301
- if (mode === "off") return [];
12302
- const soft = { path: "behavior.multicolor.softness", label: "Colour softness", min: 0, max: 1, step: 0.01 };
12303
- return mode === "dab" ? [soft, { path: "behavior.multicolor.length", label: "Dabs per colour", min: 1, max: 100, step: 1 }] : [soft];
12304
- }
12305
- function patchBrushValue(brush, path, value) {
12306
- const set = (obj, keys) => {
12307
- const [head2, ...rest2] = keys;
12308
- if (!rest2.length) return { ...obj, [head2]: value };
12309
- return { ...obj, [head2]: set(obj[head2] ?? {}, rest2) };
12310
- };
12311
- const [head, ...rest] = path.split(".");
12312
- if (!rest.length) return { [head]: value };
12313
- return { [head]: set({ ...brush[head] ?? {} }, rest) };
12314
- }
12315
11998
  function brushLoadColor(load) {
12316
11999
  const c = load.pickup ?? load.reservoir;
12317
12000
  return c ? rgbToHexColor(Math.round(c[0] * 255), Math.round(c[1] * 255), Math.round(c[2] * 255)) : null;
@@ -12881,6 +12564,7 @@ export {
12881
12564
  colorHarmony2 as colorHarmony,
12882
12565
  composeAffine,
12883
12566
  contactSheet,
12567
+ createPaintBrushLibraryToolProvider,
12884
12568
  createPaintCompositor,
12885
12569
  createPaintDocument,
12886
12570
  createPaintEditorDocument,
@@ -13037,6 +12721,7 @@ export {
13037
12721
  strokeBounds,
13038
12722
  strokeDuration,
13039
12723
  strokeSchedule,
12724
+ strokeToJSON,
13040
12725
  sweepPath,
13041
12726
  symmetryAxisAngles,
13042
12727
  symmetryAxisSegments,