@pixerate/editor 0.254.1 → 0.255.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 (71) hide show
  1. package/dist/agent/index.cjs +2 -2
  2. package/dist/agent/index.js +1 -1
  3. package/dist/animation-editor/index.cjs +59 -59
  4. package/dist/animation-editor/index.js +3 -3
  5. package/dist/brushes/index.cjs +31 -29
  6. package/dist/brushes/index.d.cts +1 -1
  7. package/dist/brushes/index.d.ts +1 -1
  8. package/dist/brushes/index.js +27 -25
  9. package/dist/brushes/library.d.cts +17 -0
  10. package/dist/brushes/library.d.ts +17 -0
  11. package/dist/{brushstrokeRender-5PYSKZIJ.js → brushstrokeRender-HCOQUSWW.js} +53 -18
  12. package/dist/{brushstrokeRender-7FLLSQEC.cjs → brushstrokeRender-ZZOXI5O5.cjs} +55 -20
  13. package/dist/canvas/brushTypes.d.cts +22 -3
  14. package/dist/canvas/brushTypes.d.ts +22 -3
  15. package/dist/canvas/brushstroke.d.cts +34 -12
  16. package/dist/canvas/brushstroke.d.ts +34 -12
  17. package/dist/canvas/brushstrokeRender.d.cts +14 -8
  18. package/dist/canvas/brushstrokeRender.d.ts +14 -8
  19. package/dist/canvas/drawController.d.cts +11 -3
  20. package/dist/canvas/drawController.d.ts +11 -3
  21. package/dist/canvas/freehand.d.cts +6 -1
  22. package/dist/canvas/freehand.d.ts +6 -1
  23. package/dist/canvas/index.cjs +114 -102
  24. package/dist/canvas/index.js +16 -4
  25. package/dist/{chunk-XVIK73LS.js → chunk-5P4JEOIV.js} +4 -4
  26. package/dist/{chunk-JPEHXIB5.cjs → chunk-5YWWQ36H.cjs} +66 -21
  27. package/dist/{chunk-CHAWTXBE.cjs → chunk-6WCQ35GI.cjs} +4 -4
  28. package/dist/{chunk-GYMMNCFT.js → chunk-AF23QK5F.js} +56 -11
  29. package/dist/{chunk-JFHBXNR2.cjs → chunk-B7V34GIH.cjs} +86 -86
  30. package/dist/{chunk-QC3OXRE4.js → chunk-DMTEQ6SH.js} +1 -1
  31. package/dist/{chunk-TLOGFIIV.cjs → chunk-GCTUOTCW.cjs} +35 -35
  32. package/dist/{chunk-34KLWUH4.cjs → chunk-HIMGBZAM.cjs} +1133 -597
  33. package/dist/{chunk-7P6VF3FG.js → chunk-HYWRU5VX.js} +1 -1
  34. package/dist/{chunk-QPHNVZHO.js → chunk-JVRLGV3C.js} +1118 -582
  35. package/dist/{chunk-LFMKWGFL.cjs → chunk-LRNUZKWV.cjs} +22 -8
  36. package/dist/chunk-PGPFWC35.cjs +517 -0
  37. package/dist/{chunk-QLSGTCYT.cjs → chunk-TBAVCLEQ.cjs} +23 -23
  38. package/dist/{chunk-2ZJ4DT3M.js → chunk-VUKUTT2W.js} +1 -1
  39. package/dist/{chunk-5JNA6LLY.js → chunk-WYGSR3IJ.js} +22 -8
  40. package/dist/chunk-YHUMGBZD.js +517 -0
  41. package/dist/commands/builtins.d.cts +1 -1
  42. package/dist/commands/builtins.d.ts +1 -1
  43. package/dist/commands/index.cjs +2 -2
  44. package/dist/commands/index.js +1 -1
  45. package/dist/image-editor/brush-presets.d.cts +32 -0
  46. package/dist/image-editor/brush-presets.d.ts +32 -0
  47. package/dist/image-editor/controller.d.cts +15 -0
  48. package/dist/image-editor/controller.d.ts +15 -0
  49. package/dist/image-editor/index.cjs +848 -775
  50. package/dist/image-editor/index.d.cts +1 -0
  51. package/dist/image-editor/index.d.ts +1 -0
  52. package/dist/image-editor/index.js +81 -8
  53. package/dist/image-editor/types.d.cts +7 -0
  54. package/dist/image-editor/types.d.ts +7 -0
  55. package/dist/paint-editor/index.cjs +80 -80
  56. package/dist/paint-editor/index.js +13 -13
  57. package/dist/vector/brushstroke.d.cts +21 -5
  58. package/dist/vector/brushstroke.d.ts +21 -5
  59. package/dist/vector/controller.d.cts +14 -1
  60. package/dist/vector/controller.d.ts +14 -1
  61. package/dist/vector/index.cjs +1220 -1199
  62. package/dist/vector/index.js +34 -13
  63. package/dist/vector/types.d.cts +7 -0
  64. package/dist/vector/types.d.ts +7 -0
  65. package/package.json +1 -1
  66. package/skills/pixerate-brushes/SKILL.md +9 -3
  67. package/skills/pixerate-canvas/SKILL.md +10 -5
  68. package/skills/pixerate-image-editor/SKILL.md +2 -1
  69. package/skills/pixerate-vector/SKILL.md +1 -1
  70. package/dist/chunk-HS6QDWDF.cjs +0 -1029
  71. package/dist/chunk-MTKY6K3Q.js +0 -1029
@@ -1,17 +1,3 @@
1
- import {
2
- BRUSH_ID,
3
- BRUSH_ROTATIONS,
4
- DOT_ROTATIONS,
5
- DOT_TEXTURE_ORDERS,
6
- IMPASTO_TOOLS,
7
- MAX_DOT_TEXTURES,
8
- MAX_STICKER_STAMPS,
9
- PARTICLE_KINDS,
10
- PARTICLE_RENDERS,
11
- RIBBON_PAIRS,
12
- WET_MEDIA,
13
- normalizePaintBrush
14
- } from "./chunk-MTKY6K3Q.js";
15
1
  import {
16
2
  STAMP_KINDS,
17
3
  editorStampOf,
@@ -21,14 +7,989 @@ import {
21
7
  validateJsonSchema
22
8
  } from "./chunk-V2ZCJRDM.js";
23
9
  import {
10
+ DEFAULT_BLENDER,
11
+ DEFAULT_BRUSH,
12
+ DEFAULT_MIXER,
13
+ DEFAULT_SMUDGE,
24
14
  MAX_BRUSH_SIZE,
25
15
  PAINT_GRADIENT_JSON_SCHEMA,
16
+ normalizeBlender,
17
+ normalizeBrush,
18
+ normalizeMixer,
19
+ normalizeSmudge,
26
20
  readPaintSource
27
21
  } from "./chunk-GCVFEPJG.js";
28
22
  import {
29
23
  BLEND_MODES
30
24
  } from "./chunk-IRSTHLQQ.js";
31
25
 
26
+ // src/paint-editor/mark-settings.ts
27
+ var unit = (v, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d;
28
+ var oneOf = (v, all, d) => all.includes(v) ? v : d;
29
+ var MAX_STICKER_STAMPS = 16;
30
+ var DEFAULT_STICKER = { stamps: ["editor:star"], density: 0.4, sizeJitter: 0.4, rotationJitter: 1, scatter: 0.5, colorJitter: 0.2 };
31
+ function normalizeStickerSettings(raw = {}, base = DEFAULT_STICKER) {
32
+ const refs = (list2) => list2.flatMap((s) => {
33
+ try {
34
+ return [normalizeStickerRef(s)];
35
+ } catch {
36
+ return [];
37
+ }
38
+ });
39
+ const stamps = Array.isArray(raw.stamps) ? refs(raw.stamps) : refs(base.stamps);
40
+ return {
41
+ stamps: stamps.length ? [...new Set(stamps)].slice(0, MAX_STICKER_STAMPS) : refs(base.stamps.length ? base.stamps : DEFAULT_STICKER.stamps),
42
+ density: unit(raw.density, base.density),
43
+ sizeJitter: unit(raw.sizeJitter, base.sizeJitter),
44
+ rotationJitter: unit(raw.rotationJitter, base.rotationJitter),
45
+ scatter: typeof raw.scatter === "number" && Number.isFinite(raw.scatter) ? Math.min(2, Math.max(0, raw.scatter)) : base.scatter,
46
+ colorJitter: unit(raw.colorJitter, base.colorJitter)
47
+ };
48
+ }
49
+ var PARTICLE_KINDS = ["string", "spring", "orbit", "flow", "elastic", "fuzzy"];
50
+ var PARTICLE_RENDERS = ["dot", "line", "sequence", "ribbon"];
51
+ var RIBBON_PAIRS = ["adjacent", "first-last"];
52
+ var DOT_TEXTURE_ORDERS = ["sequence", "random"];
53
+ var DOT_ROTATIONS = ["follow", "random", "fixed"];
54
+ var MAX_DOT_TEXTURES = 4;
55
+ var DEFAULT_PARTICLE = {
56
+ kind: "spring",
57
+ render: "line",
58
+ count: 12,
59
+ size: 0.08,
60
+ spread: 0.5,
61
+ stiffness: 0.4,
62
+ damping: 0.25,
63
+ speed: 0.5,
64
+ gravity: 0,
65
+ life: 0.5,
66
+ interval: 0.3,
67
+ ribbonFill: 0.5,
68
+ ribbonPairs: "adjacent",
69
+ waveAmplitude: 0,
70
+ waveLength: 60,
71
+ waveSpeed: 0,
72
+ wavePhaseJitter: 0,
73
+ dotTextures: [],
74
+ dotTextureOrder: "sequence",
75
+ dotRotation: "random"
76
+ };
77
+ function normalizeParticle(raw = {}, base = DEFAULT_PARTICLE) {
78
+ const textures = Array.isArray(raw.dotTextures) ? raw.dotTextures.filter((t) => STAMP_KINDS.includes(t)).slice(0, MAX_DOT_TEXTURES) : [...base.dotTextures ?? []];
79
+ return {
80
+ kind: oneOf(raw.kind, PARTICLE_KINDS, base.kind),
81
+ render: oneOf(raw.render, PARTICLE_RENDERS, base.render),
82
+ count: typeof raw.count === "number" && Number.isFinite(raw.count) ? Math.min(64, Math.max(1, Math.round(raw.count))) : base.count,
83
+ size: unit(raw.size, base.size),
84
+ spread: unit(raw.spread, base.spread),
85
+ stiffness: unit(raw.stiffness, base.stiffness),
86
+ damping: unit(raw.damping, base.damping),
87
+ speed: unit(raw.speed, base.speed),
88
+ gravity: typeof raw.gravity === "number" && Number.isFinite(raw.gravity) ? Math.min(1, Math.max(-1, raw.gravity)) : base.gravity,
89
+ life: unit(raw.life, base.life),
90
+ interval: unit(raw.interval, base.interval),
91
+ ribbonFill: unit(raw.ribbonFill, base.ribbonFill ?? DEFAULT_PARTICLE.ribbonFill),
92
+ ribbonPairs: oneOf(raw.ribbonPairs, RIBBON_PAIRS, base.ribbonPairs ?? DEFAULT_PARTICLE.ribbonPairs),
93
+ waveAmplitude: unit(raw.waveAmplitude, base.waveAmplitude ?? 0),
94
+ waveLength: typeof raw.waveLength === "number" && Number.isFinite(raw.waveLength) ? Math.min(2e3, Math.max(4, raw.waveLength)) : base.waveLength ?? DEFAULT_PARTICLE.waveLength,
95
+ waveSpeed: unit(raw.waveSpeed, base.waveSpeed ?? 0),
96
+ wavePhaseJitter: unit(raw.wavePhaseJitter, base.wavePhaseJitter ?? 0),
97
+ dotTextures: textures,
98
+ dotTextureOrder: oneOf(raw.dotTextureOrder, DOT_TEXTURE_ORDERS, base.dotTextureOrder ?? DEFAULT_PARTICLE.dotTextureOrder),
99
+ dotRotation: oneOf(raw.dotRotation, DOT_ROTATIONS, base.dotRotation ?? DEFAULT_PARTICLE.dotRotation)
100
+ };
101
+ }
102
+ var DEFAULT_BRISTLE = { count: 32, stiffness: 0.5, splay: 0.5, thickness: 0.06, load: 0.6, jitter: 0.4 };
103
+ function normalizeBristle(raw = {}, base = DEFAULT_BRISTLE) {
104
+ return {
105
+ count: typeof raw.count === "number" && Number.isFinite(raw.count) ? Math.min(128, Math.max(3, Math.round(raw.count))) : base.count,
106
+ stiffness: unit(raw.stiffness, base.stiffness),
107
+ splay: unit(raw.splay, base.splay),
108
+ thickness: unit(raw.thickness, base.thickness),
109
+ load: unit(raw.load, base.load),
110
+ jitter: unit(raw.jitter, base.jitter)
111
+ };
112
+ }
113
+
114
+ // src/paint-editor/brushes.ts
115
+ var PAINT_BRUSH_ENGINES = ["dab", "eraser", "mixer", "blender", "smudge", "wet", "impasto", "particle", "bristle", "sticker"];
116
+ var PAINT_BRUSH_CATEGORIES = ["pencil", "pastel", "charcoal", "airbrush", "marker", "ink", "paint", "eraser", "blender", "smudge", "mixer", "watercolor", "wet-tool", "oil", "impasto", "procedural", "bristle", "sticker", "light", "custom"];
117
+ var WET_TOOLS = ["paint", "water", "dry", "blow", "blend", "smudge", "blot"];
118
+ var WET_MEDIA = ["watercolor", "gouache", "oil"];
119
+ var DEFAULT_WET_BRUSH = { tool: "paint", water: 0.5, pigment: 0.5, body: 0, strength: 0.5, rewet: 0, medium: "watercolor", load: 1, thinner: 0 };
120
+ function normalizeWetBrush(raw = {}, base = DEFAULT_WET_BRUSH) {
121
+ return {
122
+ tool: WET_TOOLS.includes(raw.tool) ? raw.tool : base.tool,
123
+ water: unit2(raw.water, base.water),
124
+ pigment: unit2(raw.pigment, base.pigment),
125
+ body: unit2(raw.body, base.body),
126
+ strength: unit2(raw.strength, base.strength),
127
+ rewet: unit2(raw.rewet, base.rewet),
128
+ medium: WET_MEDIA.includes(raw.medium) ? raw.medium : base.medium ?? "watercolor",
129
+ load: unit2(raw.load, base.load ?? 1),
130
+ thinner: unit2(raw.thinner, base.thinner ?? 0)
131
+ };
132
+ }
133
+ var IMPASTO_TOOLS = ["brush", "tube", "knife", "roller", "gloop"];
134
+ var DEFAULT_IMPASTO = { tool: "brush", thickness: 0.6, gloss: 0.4, metallic: 0, load: 0.9, thinner: 0, drag: 0.4, rim: 0, clarity: 0 };
135
+ var DEFAULT_LIGHT = { amount: 0, pressure: false };
136
+ function normalizeLight(raw = {}, base = DEFAULT_LIGHT) {
137
+ return { amount: unit2(raw.amount, base.amount), pressure: typeof raw.pressure === "boolean" ? raw.pressure : base.pressure };
138
+ }
139
+ function normalizeImpasto(raw = {}, base = DEFAULT_IMPASTO) {
140
+ return {
141
+ tool: IMPASTO_TOOLS.includes(raw.tool) ? raw.tool : base.tool,
142
+ thickness: unit2(raw.thickness, base.thickness),
143
+ gloss: unit2(raw.gloss, base.gloss),
144
+ metallic: unit2(raw.metallic, base.metallic),
145
+ load: unit2(raw.load, base.load),
146
+ thinner: unit2(raw.thinner, base.thinner),
147
+ drag: unit2(raw.drag, base.drag),
148
+ rim: unit2(raw.rim, base.rim ?? 0),
149
+ clarity: unit2(raw.clarity, base.clarity ?? 0)
150
+ };
151
+ }
152
+ var MULTI_COLOR_MODES = ["off", "tip", "dab"];
153
+ var DEFAULT_MULTI_COLOR = { mode: "off", colors: [], order: "sequence", length: 12, softness: 1 };
154
+ var DEFAULT_BEHAVIOR = { pickup: 0, mix: 0.5, dirty: false, mixing: "pigment", multicolor: DEFAULT_MULTI_COLOR };
155
+ var BRUSH_ROTATIONS = ["fixed", "stroke", "barrel"];
156
+ var DEFAULT_DYNAMICS = {
157
+ minSize: 0,
158
+ pressureFlow: 0,
159
+ tiltSize: 0,
160
+ tiltRoundness: 0,
161
+ tiltOpacity: 0,
162
+ velocitySize: 0,
163
+ velocityOpacity: 0,
164
+ sizeJitter: 0,
165
+ angleJitter: 0,
166
+ opacityJitter: 0,
167
+ scatter: 0
168
+ };
169
+ var unit2 = (v, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d;
170
+ var range = (v, lo, hi, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : d;
171
+ function normalizeDynamics(raw = {}, base = DEFAULT_DYNAMICS) {
172
+ return {
173
+ minSize: unit2(raw.minSize, base.minSize),
174
+ pressureFlow: unit2(raw.pressureFlow, base.pressureFlow),
175
+ tiltSize: unit2(raw.tiltSize, base.tiltSize),
176
+ tiltRoundness: unit2(raw.tiltRoundness, base.tiltRoundness),
177
+ tiltOpacity: unit2(raw.tiltOpacity, base.tiltOpacity),
178
+ velocitySize: range(raw.velocitySize, -1, 1, base.velocitySize),
179
+ velocityOpacity: range(raw.velocityOpacity, -1, 1, base.velocityOpacity),
180
+ sizeJitter: unit2(raw.sizeJitter, base.sizeJitter),
181
+ angleJitter: unit2(raw.angleJitter, base.angleJitter),
182
+ opacityJitter: unit2(raw.opacityJitter, base.opacityJitter),
183
+ scatter: range(raw.scatter, 0, 4, base.scatter)
184
+ };
185
+ }
186
+ var BRUSH_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
187
+ var HEX = /^#[0-9a-f]{6}$/i;
188
+ function normalizeMultiColor(raw = {}, base = DEFAULT_MULTI_COLOR) {
189
+ const colors = Array.isArray(raw.colors) ? raw.colors.filter((c) => typeof c === "string" && HEX.test(c.trim())).map((c) => c.trim().toLowerCase()).slice(0, 8) : base.colors;
190
+ return {
191
+ mode: MULTI_COLOR_MODES.includes(raw.mode) ? raw.mode : base.mode,
192
+ colors: [...colors],
193
+ order: raw.order === "random" || raw.order === "sequence" ? raw.order : base.order,
194
+ length: typeof raw.length === "number" && Number.isFinite(raw.length) ? Math.min(500, Math.max(1, Math.round(raw.length))) : base.length,
195
+ softness: unit2(raw.softness, base.softness)
196
+ };
197
+ }
198
+ function normalizeBehavior(raw = {}, base = DEFAULT_BEHAVIOR) {
199
+ return {
200
+ pickup: unit2(raw.pickup, base.pickup),
201
+ mix: unit2(raw.mix, base.mix),
202
+ dirty: typeof raw.dirty === "boolean" ? raw.dirty : base.dirty,
203
+ mixing: raw.mixing === "rgb" || raw.mixing === "pigment" ? raw.mixing : base.mixing,
204
+ multicolor: normalizeMultiColor(raw.multicolor ?? {}, base.multicolor)
205
+ };
206
+ }
207
+ function isWetCapable(brush) {
208
+ return brush.engine === "dab" || brush.engine === "mixer" || brush.engine === "wet" || brush.engine === "impasto";
209
+ }
210
+ function isWetBrush(brush) {
211
+ return brush.engine === "wet";
212
+ }
213
+ function usesPaintExchange(brush, gradient = brush.paint !== null) {
214
+ return brush.engine === "dab" && (brush.behavior.pickup > 0 || gradient);
215
+ }
216
+ function brushTakesPaint(brush) {
217
+ if (brush.engine === "eraser" || brush.engine === "blender" || brush.engine === "smudge") return false;
218
+ if (brush.engine === "wet") return brush.wet.tool === "paint";
219
+ if (brush.engine === "mixer") return brush.loadsColor;
220
+ return true;
221
+ }
222
+ function paintToMultiColor(g) {
223
+ if (!g || g.space === "oklab" || g.stops.some((s) => s.opacity !== void 0 || s.midpoint !== void 0)) return null;
224
+ const colors = g.stops.map((s) => s.color);
225
+ const own = (list2) => list2.length === 2 && list2[0] === "paint" && list2[1] === "secondary" ? [] : list2.some((c) => c === "paint" || c === "secondary") ? null : list2;
226
+ const uniform = (n) => g.stops.every((s, k) => s.offset === k / n);
227
+ const softness = g.softness ?? 1;
228
+ if (g.map.by === "across-tip" && colors.length <= 8 && uniform(colors.length - 1)) {
229
+ const c = own(colors);
230
+ return c ? { mode: "tip", colors: c, order: "sequence", length: DEFAULT_MULTI_COLOR.length, softness } : null;
231
+ }
232
+ if (g.map.by === "random" && colors.length % 2 === 0 && colors.length <= 16) {
233
+ const n = colors.length / 2;
234
+ const pairs = Array.from({ length: n }, (_, k) => colors[2 * k]);
235
+ const hard = g.stops.every((s, i) => s.offset === (Math.floor(i / 2) + i % 2) / n && s.color === pairs[Math.floor(i / 2)]);
236
+ const c = hard ? own(pairs) : null;
237
+ return c ? { mode: "dab", colors: c, order: "random", length: g.map.every ?? 1, softness } : null;
238
+ }
239
+ if (g.map.by === "distance" && g.map.unit === "dabs" && g.map.repeat === "repeat" && colors.length >= 3 && colors.length <= 9 && colors[0] === colors[colors.length - 1] && uniform(colors.length - 1)) {
240
+ const n = colors.length - 1;
241
+ const c = own(colors.slice(0, n));
242
+ const length = g.map.length / n;
243
+ return c && Number.isInteger(length) ? { mode: "dab", colors: c, order: "sequence", length, softness } : null;
244
+ }
245
+ return null;
246
+ }
247
+ function multiColorToPaint(mc, mixing) {
248
+ if (mc.mode === "off") return null;
249
+ const colors = mc.colors.length >= 2 ? mc.colors : ["paint", "secondary"];
250
+ const n = colors.length;
251
+ const space = mixing === "rgb" ? "srgb" : "pigment";
252
+ const soft = mc.softness < 1 ? { softness: mc.softness } : {};
253
+ if (mc.mode === "tip") return { type: "gradient", stops: colors.map((color, k) => ({ offset: k / (n - 1), color })), space, map: { by: "across-tip" }, ...soft };
254
+ if (mc.order === "random") {
255
+ const stops = colors.flatMap((color, k) => [
256
+ { offset: k / n, color },
257
+ { offset: (k + 1) / n, color }
258
+ ]);
259
+ return { type: "gradient", stops, space, map: { by: "random", every: mc.length } };
260
+ }
261
+ return { type: "gradient", stops: [...colors, colors[0]].map((color, k) => ({ offset: k / n, color })), space, map: { by: "distance", length: mc.length * n, repeat: "repeat", unit: "dabs" }, ...soft };
262
+ }
263
+ function normalizePaintBrush(raw = {}, base) {
264
+ const b = base ?? BASE_BRUSH;
265
+ const id = typeof raw.id === "string" && BRUSH_ID.test(raw.id) ? raw.id : b.id;
266
+ const engine = PAINT_BRUSH_ENGINES.includes(raw.engine) ? raw.engine : b.engine;
267
+ const rb = raw.behavior ?? {};
268
+ const mixerIn = { ...b.mixer, ...raw.mixer ?? {} };
269
+ if (engine === "mixer") {
270
+ if (rb.pickup !== void 0) mixerIn.wet = rb.pickup;
271
+ if (rb.mix !== void 0) mixerIn.mix = rb.mix;
272
+ if (rb.dirty !== void 0) mixerIn.cleanEachStroke = !rb.dirty;
273
+ if (rb.mixing !== void 0) mixerIn.mixing = rb.mixing;
274
+ }
275
+ const mixer = normalizeMixer(mixerIn);
276
+ let behavior = normalizeBehavior(rb, b.behavior);
277
+ if (engine === "mixer") behavior = { ...behavior, pickup: mixer.wet, mix: mixer.mix, dirty: !mixer.cleanEachStroke, mixing: mixer.mixing };
278
+ let paint = b.paint ?? null;
279
+ if (raw.paint !== void 0) {
280
+ const p = readPaintSource(raw.paint);
281
+ paint = p && p.type === "gradient" ? p : null;
282
+ } else if (rb.multicolor !== void 0) {
283
+ const mc = normalizeMultiColor(rb.multicolor, paintToMultiColor(paint) ?? DEFAULT_MULTI_COLOR);
284
+ paint = mc.mode === "off" ? paintToMultiColor(paint) ? null : paint : multiColorToPaint(mc, behavior.mixing);
285
+ }
286
+ behavior = { ...behavior, multicolor: paintToMultiColor(paint) ?? { ...DEFAULT_MULTI_COLOR, colors: [] } };
287
+ return {
288
+ id,
289
+ name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, 80) : b.name,
290
+ category: PAINT_BRUSH_CATEGORIES.includes(raw.category) ? raw.category : b.category,
291
+ engine,
292
+ tip: normalizeBrush({ ...b.tip, ...raw.tip ?? {} }),
293
+ grain: unit2(raw.grain, b.grain),
294
+ pressureSmoothing: range(raw.pressureSmoothing, 0, 0.95, b.pressureSmoothing),
295
+ rotation: BRUSH_ROTATIONS.includes(raw.rotation) ? raw.rotation : b.rotation,
296
+ dynamics: normalizeDynamics(raw.dynamics ?? {}, b.dynamics),
297
+ mixer,
298
+ loadsColor: typeof raw.loadsColor === "boolean" ? raw.loadsColor : b.loadsColor,
299
+ behavior,
300
+ blender: normalizeBlender(raw.blender ?? {}, b.blender),
301
+ smudge: normalizeSmudge(raw.smudge ?? {}, b.smudge),
302
+ wet: normalizeWetBrush(raw.wet ?? {}, b.wet ?? DEFAULT_WET_BRUSH),
303
+ impasto: normalizeImpasto(raw.impasto ?? {}, b.impasto ?? DEFAULT_IMPASTO),
304
+ particle: normalizeParticle(raw.particle ?? {}, b.particle ?? DEFAULT_PARTICLE),
305
+ bristle: normalizeBristle(raw.bristle ?? {}, b.bristle ?? DEFAULT_BRISTLE),
306
+ sticker: normalizeStickerSettings(raw.sticker ?? {}, b.sticker ?? DEFAULT_STICKER),
307
+ light: normalizeLight(raw.light ?? {}, b.light ?? DEFAULT_LIGHT),
308
+ paint,
309
+ ...raw.builtin ? { builtin: true } : {}
310
+ };
311
+ }
312
+ var BASE_BRUSH = {
313
+ id: "round",
314
+ name: "Round",
315
+ category: "custom",
316
+ engine: "dab",
317
+ tip: { ...DEFAULT_BRUSH },
318
+ grain: 0,
319
+ pressureSmoothing: 0,
320
+ rotation: "fixed",
321
+ dynamics: { ...DEFAULT_DYNAMICS },
322
+ mixer: { ...DEFAULT_MIXER },
323
+ loadsColor: true,
324
+ behavior: { ...DEFAULT_BEHAVIOR, multicolor: { ...DEFAULT_MULTI_COLOR, colors: [] } },
325
+ blender: { ...DEFAULT_BLENDER },
326
+ smudge: { ...DEFAULT_SMUDGE },
327
+ wet: { ...DEFAULT_WET_BRUSH },
328
+ impasto: { ...DEFAULT_IMPASTO },
329
+ particle: { ...DEFAULT_PARTICLE },
330
+ bristle: { ...DEFAULT_BRISTLE },
331
+ sticker: { ...DEFAULT_STICKER, stamps: [...DEFAULT_STICKER.stamps] },
332
+ light: { ...DEFAULT_LIGHT },
333
+ paint: null
334
+ };
335
+ var builtin = (init) => ({ ...normalizePaintBrush(init), builtin: true });
336
+ var BUILTIN_BRUSHES = [
337
+ builtin({
338
+ id: "pencil-hb",
339
+ name: "HB pencil",
340
+ category: "pencil",
341
+ tip: { size: 4, hardness: 0.7, spacing: 0.08, opacity: 0.85, flow: 0.55, smoothing: 0.25, pressureSize: true, pressureOpacity: true },
342
+ grain: 0.85,
343
+ pressureSmoothing: 0.3,
344
+ dynamics: { minSize: 0.55, tiltSize: 0.9, tiltRoundness: 0.7, tiltOpacity: 0.5 }
345
+ }),
346
+ builtin({
347
+ id: "pencil-6b",
348
+ name: "6B pencil",
349
+ category: "pencil",
350
+ tip: { size: 7, hardness: 0.55, spacing: 0.07, opacity: 1, flow: 0.75, smoothing: 0.2, pressureSize: true, pressureOpacity: true },
351
+ grain: 0.7,
352
+ pressureSmoothing: 0.3,
353
+ dynamics: { minSize: 0.5, tiltSize: 1, tiltRoundness: 0.75, tiltOpacity: 0.35 }
354
+ }),
355
+ builtin({
356
+ id: "soft-pastel",
357
+ name: "Soft pastel",
358
+ category: "pastel",
359
+ rotation: "stroke",
360
+ tip: { size: 32, hardness: 0.55, spacing: 0.05, angle: 90, roundness: 0.55, opacity: 1, flow: 0.7, smoothing: 0.15, pressureSize: false, pressureOpacity: true },
361
+ grain: 1,
362
+ pressureSmoothing: 0.25,
363
+ dynamics: { sizeJitter: 0.06, angleJitter: 0.02 }
364
+ }),
365
+ builtin({
366
+ id: "oil-pastel",
367
+ name: "Oil pastel",
368
+ category: "pastel",
369
+ rotation: "stroke",
370
+ tip: { size: 26, hardness: 0.8, spacing: 0.05, angle: 90, roundness: 0.7, opacity: 1, flow: 0.9, smoothing: 0.15, pressureSize: true, pressureOpacity: false, blend: "pigment" },
371
+ grain: 0.5,
372
+ pressureSmoothing: 0.25,
373
+ dynamics: { minSize: 0.6 }
374
+ }),
375
+ builtin({
376
+ id: "charcoal-stick",
377
+ name: "Charcoal stick",
378
+ category: "charcoal",
379
+ rotation: "stroke",
380
+ tip: { size: 26, hardness: 0.4, spacing: 0.05, angle: 90, roundness: 0.35, opacity: 0.95, flow: 0.5, smoothing: 0.15, pressureSize: false, pressureOpacity: true },
381
+ grain: 0.95,
382
+ pressureSmoothing: 0.2,
383
+ dynamics: { sizeJitter: 0.1, angleJitter: 0.03, tiltSize: 0.6 }
384
+ }),
385
+ builtin({
386
+ id: "vine-charcoal",
387
+ name: "Vine charcoal",
388
+ category: "charcoal",
389
+ tip: { size: 10, hardness: 0.3, spacing: 0.06, opacity: 0.75, flow: 0.45, smoothing: 0.2, pressureSize: true, pressureOpacity: true },
390
+ grain: 0.9,
391
+ pressureSmoothing: 0.2,
392
+ dynamics: { minSize: 0.4, tiltSize: 0.8, tiltRoundness: 0.5, tiltOpacity: 0.3, sizeJitter: 0.08 }
393
+ }),
394
+ builtin({
395
+ id: "airbrush",
396
+ name: "Airbrush",
397
+ category: "airbrush",
398
+ tip: { size: 90, hardness: 0, spacing: 0.04, opacity: 1, flow: 0.06, smoothing: 0.3, pressureSize: false, pressureOpacity: false },
399
+ grain: 0,
400
+ pressureSmoothing: 0.3,
401
+ dynamics: { pressureFlow: 1, tiltSize: 0.5, tiltRoundness: 0.6 }
402
+ }),
403
+ builtin({
404
+ id: "marker",
405
+ name: "Chisel marker",
406
+ category: "marker",
407
+ tip: { size: 24, hardness: 0.85, spacing: 0.04, angle: 45, roundness: 0.35, opacity: 0.6, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: false, blend: "multiply" },
408
+ grain: 0.1
409
+ }),
410
+ builtin({
411
+ id: "ink-pen",
412
+ name: "Ink pen",
413
+ category: "ink",
414
+ tip: { size: 4, hardness: 1, spacing: 0.05, opacity: 1, flow: 1, smoothing: 0.5, pressureSize: true, pressureOpacity: false },
415
+ pressureSmoothing: 0.4,
416
+ dynamics: { minSize: 0.25, velocitySize: -0.45 }
417
+ }),
418
+ builtin({
419
+ id: "brush-pen",
420
+ name: "Brush pen",
421
+ category: "ink",
422
+ rotation: "stroke",
423
+ tip: { size: 14, hardness: 0.9, spacing: 0.04, roundness: 0.8, opacity: 1, flow: 1, smoothing: 0.5, pressureSize: true, pressureOpacity: false },
424
+ pressureSmoothing: 0.45,
425
+ dynamics: { minSize: 0.05, velocitySize: -0.3 }
426
+ }),
427
+ builtin({
428
+ id: "eraser-soft",
429
+ name: "Soft eraser",
430
+ category: "eraser",
431
+ engine: "eraser",
432
+ tip: { size: 40, hardness: 0.2, spacing: 0.08, opacity: 1, flow: 0.6, pressureSize: false, pressureOpacity: true }
433
+ }),
434
+ builtin({
435
+ id: "eraser-hard",
436
+ name: "Hard eraser",
437
+ category: "eraser",
438
+ engine: "eraser",
439
+ tip: { size: 16, hardness: 0.9, spacing: 0.06, opacity: 1, flow: 1, pressureSize: true, pressureOpacity: false },
440
+ grain: 0.3,
441
+ dynamics: { minSize: 0.5 }
442
+ }),
443
+ builtin({
444
+ id: "blender",
445
+ name: "Blender",
446
+ category: "blender",
447
+ engine: "blender",
448
+ loadsColor: false,
449
+ tip: { size: 34, hardness: 0.4, spacing: 0.1, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
450
+ blender: { strength: 0.5, distance: 0.35, mixing: "pigment" }
451
+ }),
452
+ builtin({
453
+ id: "smudge",
454
+ name: "Smudge",
455
+ category: "smudge",
456
+ engine: "smudge",
457
+ loadsColor: false,
458
+ tip: { size: 26, hardness: 0.6, spacing: 0.06, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
459
+ smudge: { strength: 0.75, length: 0.85, mixing: "rgb" }
460
+ }),
461
+ builtin({
462
+ id: "wet-smudge",
463
+ name: "Pigment smudge",
464
+ category: "smudge",
465
+ engine: "smudge",
466
+ loadsColor: false,
467
+ tip: { size: 30, hardness: 0.5, spacing: 0.06, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: true, pressureOpacity: true },
468
+ smudge: { strength: 0.7, length: 0.9, mixing: "pigment" },
469
+ dynamics: { minSize: 0.6 }
470
+ }),
471
+ builtin({
472
+ id: "dirty-round",
473
+ name: "Dirty round",
474
+ category: "paint",
475
+ tip: { size: 22, hardness: 0.65, spacing: 0.06, opacity: 1, flow: 0.7, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
476
+ behavior: { pickup: 0.55, mix: 0.6, dirty: true, mixing: "pigment" },
477
+ dynamics: { minSize: 0.5 }
478
+ }),
479
+ builtin({
480
+ id: "multicolor-flat",
481
+ name: "Multi-colour flat",
482
+ category: "paint",
483
+ rotation: "stroke",
484
+ 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 },
485
+ behavior: { pickup: 0.15, mix: 0.4, mixing: "pigment" },
486
+ // The paint and second colours side by side across the tip.
487
+ paint: { type: "gradient", stops: [{ offset: 0, color: "paint" }, { offset: 1, color: "secondary" }], space: "pigment", map: { by: "across-tip" }, softness: 0.7 },
488
+ dynamics: { minSize: 0.6 }
489
+ }),
490
+ builtin({
491
+ id: "palette-dabber",
492
+ name: "Palette dabber",
493
+ category: "paint",
494
+ tip: { size: 18, hardness: 0.7, spacing: 0.35, opacity: 1, flow: 0.9, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
495
+ // From the paint colour to the second colour and back, every 6 dabs.
496
+ paint: { type: "gradient", stops: [{ offset: 0, color: "paint" }, { offset: 0.5, color: "secondary" }, { offset: 1, color: "paint" }], space: "pigment", map: { by: "distance", length: 12, repeat: "repeat", unit: "dabs" }, softness: 0.4 },
497
+ dynamics: { minSize: 0.5, sizeJitter: 0.15, scatter: 0.3 }
498
+ }),
499
+ builtin({
500
+ id: "gradient-round",
501
+ name: "Gradient round",
502
+ category: "paint",
503
+ tip: { size: 24, hardness: 0.8, spacing: 0.05, opacity: 1, flow: 0.9, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
504
+ // Changes colour as it is dragged out: paint → second colour over 300 px, and back.
505
+ paint: { type: "gradient", stops: [{ offset: 0, color: "paint" }, { offset: 1, color: "secondary" }], space: "pigment", map: { by: "distance", length: 300, repeat: "mirror" } },
506
+ dynamics: { minSize: 0.5 }
507
+ }),
508
+ builtin({
509
+ id: "pressure-ombre",
510
+ name: "Pressure ombr\xE9",
511
+ category: "paint",
512
+ tip: { size: 26, hardness: 0.6, spacing: 0.05, opacity: 1, flow: 0.8, smoothing: 0.2, pressureSize: false, pressureOpacity: false },
513
+ // Light pressure lays the second colour, full pressure the paint colour.
514
+ paint: { type: "gradient", stops: [{ offset: 0, color: "secondary" }, { offset: 1, color: "paint" }], space: "pigment", map: { by: "pressure" } }
515
+ }),
516
+ builtin({
517
+ id: "rainbow-marker",
518
+ name: "Rainbow marker",
519
+ category: "marker",
520
+ rotation: "stroke",
521
+ tip: { size: 18, hardness: 0.9, spacing: 0.04, angle: 90, roundness: 0.4, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: false, pressureOpacity: false },
522
+ paint: {
523
+ type: "gradient",
524
+ stops: [
525
+ { offset: 0, color: "#e11d48" },
526
+ { offset: 0.2, color: "#f59e0b" },
527
+ { offset: 0.4, color: "#facc15" },
528
+ { offset: 0.6, color: "#22c55e" },
529
+ { offset: 0.8, color: "#3b82f6" },
530
+ { offset: 1, color: "#8b5cf6" }
531
+ ],
532
+ space: "oklab",
533
+ map: { by: "distance", length: 600, repeat: "repeat" }
534
+ }
535
+ }),
536
+ builtin({
537
+ id: "mixer-brush",
538
+ name: "Mixer Brush",
539
+ category: "mixer",
540
+ engine: "mixer",
541
+ tip: { size: 30, hardness: 0.7, spacing: 0.1, opacity: 1, flow: 0.5, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
542
+ mixer: { wet: 0.5, load: 0.95, mix: 0.5, loadEachStroke: true, cleanEachStroke: true, mixing: "pigment" },
543
+ dynamics: { minSize: 0.5 }
544
+ }),
545
+ builtin({
546
+ id: "watercolor-round",
547
+ name: "Watercolour round",
548
+ category: "watercolor",
549
+ engine: "wet",
550
+ tip: { size: 28, hardness: 0.6, spacing: 0.12, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: true },
551
+ dynamics: { minSize: 0.35 },
552
+ behavior: { pickup: 0.55, mix: 0.5, mixing: "pigment" },
553
+ wet: { tool: "paint", water: 0.55, pigment: 0.5, body: 0, rewet: 0.2 }
554
+ }),
555
+ builtin({
556
+ id: "watercolor-wash",
557
+ name: "Wash brush",
558
+ category: "watercolor",
559
+ engine: "wet",
560
+ rotation: "stroke",
561
+ tip: { size: 90, hardness: 0.35, spacing: 0.1, angle: 90, roundness: 0.45, opacity: 1, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: true },
562
+ behavior: { pickup: 0.45, mix: 0.45, mixing: "pigment" },
563
+ wet: { tool: "paint", water: 0.8, pigment: 0.45, body: 0, rewet: 0.2 }
564
+ }),
565
+ builtin({
566
+ id: "gradient-wash",
567
+ name: "Gradient wash",
568
+ category: "watercolor",
569
+ engine: "wet",
570
+ rotation: "stroke",
571
+ tip: { size: 80, hardness: 0.35, spacing: 0.1, angle: 90, roundness: 0.5, opacity: 1, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: true },
572
+ behavior: { pickup: 0.4, mix: 0.4, mixing: "pigment" },
573
+ wet: { tool: "paint", water: 0.8, pigment: 0.45, body: 0, rewet: 0.2 },
574
+ // A graded wash: the paint colour runs into the second colour as the brush travels.
575
+ paint: { type: "gradient", stops: [{ offset: 0, color: "paint" }, { offset: 1, color: "secondary" }], space: "pigment", map: { by: "distance", length: 500, repeat: "mirror" } }
576
+ }),
577
+ builtin({
578
+ id: "watercolor-detail",
579
+ name: "Watercolour rigger",
580
+ category: "watercolor",
581
+ engine: "wet",
582
+ tip: { size: 7, hardness: 0.75, spacing: 0.1, opacity: 1, flow: 1, smoothing: 0.4, pressureSize: true, pressureOpacity: false },
583
+ dynamics: { minSize: 0.2 },
584
+ behavior: { pickup: 0.4, mix: 0.45, mixing: "pigment" },
585
+ wet: { tool: "paint", water: 0.4, pigment: 0.7, body: 0, rewet: 0.2 }
586
+ }),
587
+ builtin({
588
+ id: "gouache",
589
+ name: "Gouache",
590
+ category: "watercolor",
591
+ engine: "wet",
592
+ tip: { size: 26, hardness: 0.7, spacing: 0.1, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
593
+ dynamics: { minSize: 0.5 },
594
+ behavior: { pickup: 0.65, mix: 0.6, mixing: "pigment" },
595
+ wet: { tool: "paint", water: 0.35, pigment: 0.8, body: 1, medium: "gouache", rewet: 0.3 }
596
+ }),
597
+ builtin({
598
+ id: "oil-round",
599
+ name: "Wet oil round",
600
+ category: "oil",
601
+ engine: "wet",
602
+ tip: { size: 24, hardness: 0.75, spacing: 0.08, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
603
+ dynamics: { minSize: 0.5 },
604
+ behavior: { pickup: 0.6, mix: 0.55, mixing: "pigment" },
605
+ wet: { tool: "paint", water: 0.4, pigment: 0.9, body: 1, medium: "oil", load: 0.97 }
606
+ }),
607
+ builtin({
608
+ id: "oil-flat",
609
+ name: "Wet oil flat",
610
+ category: "oil",
611
+ engine: "wet",
612
+ rotation: "stroke",
613
+ tip: { size: 40, hardness: 0.85, spacing: 0.06, angle: 90, roundness: 0.35, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
614
+ dynamics: { minSize: 0.6 },
615
+ behavior: { pickup: 0.65, mix: 0.6, mixing: "pigment" },
616
+ wet: { tool: "paint", water: 0.45, pigment: 0.9, body: 1, medium: "oil", load: 0.95 }
617
+ }),
618
+ builtin({
619
+ id: "impasto-brush",
620
+ name: "Impasto brush",
621
+ category: "impasto",
622
+ engine: "impasto",
623
+ rotation: "stroke",
624
+ tip: { size: 36, hardness: 0.8, spacing: 0.06, angle: 90, roundness: 0.55, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
625
+ dynamics: { minSize: 0.55 },
626
+ behavior: { pickup: 0.4, mix: 0.5, mixing: "pigment" },
627
+ impasto: { tool: "brush", thickness: 0.6, gloss: 0.45, load: 0.92, drag: 0.4 }
628
+ }),
629
+ builtin({
630
+ id: "paint-tube",
631
+ name: "Paint tube",
632
+ category: "impasto",
633
+ engine: "impasto",
634
+ tip: { size: 22, hardness: 0.9, spacing: 0.05, opacity: 1, flow: 1, smoothing: 0.35, pressureSize: true, pressureOpacity: false },
635
+ dynamics: { minSize: 0.4 },
636
+ impasto: { tool: "tube", thickness: 0.9, gloss: 0.5, load: 1, drag: 0.1 }
637
+ }),
638
+ builtin({
639
+ id: "palette-knife",
640
+ name: "Palette knife",
641
+ category: "impasto",
642
+ engine: "impasto",
643
+ rotation: "stroke",
644
+ tip: { size: 48, hardness: 1, spacing: 0.05, angle: 90, roundness: 0.18, opacity: 1, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: false },
645
+ impasto: { tool: "knife", thickness: 0.3, gloss: 0.55, load: 1, drag: 0.85 }
646
+ }),
647
+ builtin({
648
+ id: "paint-roller",
649
+ name: "Roller",
650
+ category: "impasto",
651
+ engine: "impasto",
652
+ rotation: "stroke",
653
+ tip: { size: 70, hardness: 0.95, spacing: 0.04, angle: 90, roundness: 0.12, opacity: 1, flow: 1, smoothing: 0.4, pressureSize: false, pressureOpacity: false },
654
+ impasto: { tool: "roller", thickness: 0.25, gloss: 0.3, load: 0.9, drag: 0.15 }
655
+ }),
656
+ builtin({
657
+ id: "gloop-pen",
658
+ name: "Gloop pen",
659
+ category: "impasto",
660
+ engine: "impasto",
661
+ tip: { size: 14, hardness: 0.9, spacing: 0.05, opacity: 1, flow: 1, smoothing: 0.5, pressureSize: true, pressureOpacity: false },
662
+ dynamics: { minSize: 0.5 },
663
+ impasto: { tool: "gloop", thickness: 1, gloss: 0.9, load: 1, drag: 0 }
664
+ }),
665
+ builtin({
666
+ id: "metallic-paint",
667
+ name: "Metallic paint",
668
+ category: "impasto",
669
+ engine: "impasto",
670
+ rotation: "stroke",
671
+ tip: { size: 30, hardness: 0.8, spacing: 0.06, angle: 90, roundness: 0.6, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
672
+ dynamics: { minSize: 0.5 },
673
+ behavior: { pickup: 0.2, mix: 0.4, mixing: "pigment" },
674
+ impasto: { tool: "brush", thickness: 0.45, gloss: 0.8, metallic: 1, load: 0.95, drag: 0.3 }
675
+ }),
676
+ builtin({
677
+ id: "hair-strands",
678
+ name: "Hair strands",
679
+ category: "procedural",
680
+ engine: "particle",
681
+ tip: { size: 40, hardness: 0.8, opacity: 0.9, flow: 0.6, smoothing: 0.2, pressureSize: true, pressureOpacity: true },
682
+ particle: { kind: "string", render: "line", count: 22, size: 0.025, spread: 1, stiffness: 0.08, damping: 0.3 }
683
+ }),
684
+ builtin({
685
+ id: "spring-ink",
686
+ name: "Spring ink",
687
+ category: "procedural",
688
+ engine: "particle",
689
+ tip: { size: 30, hardness: 0.9, opacity: 1, flow: 0.8, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
690
+ particle: { kind: "spring", render: "line", count: 5, size: 0.08, spread: 0.6, stiffness: 0.2, damping: 0.1 }
691
+ }),
692
+ builtin({
693
+ id: "orbit-curls",
694
+ name: "Orbit curls",
695
+ category: "procedural",
696
+ engine: "particle",
697
+ tip: { size: 36, hardness: 0.85, opacity: 0.9, flow: 0.7, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
698
+ particle: { kind: "orbit", render: "line", count: 4, size: 0.05, spread: 0.6, speed: 0.45 }
699
+ }),
700
+ builtin({
701
+ id: "flow-fur",
702
+ name: "Flow fur",
703
+ category: "procedural",
704
+ engine: "particle",
705
+ tip: { size: 50, hardness: 0.7, opacity: 0.8, flow: 0.5, smoothing: 0.2, pressureSize: true, pressureOpacity: true },
706
+ particle: { kind: "flow", render: "line", count: 10, size: 0.03, spread: 0.6, speed: 0.5, life: 0.25 }
707
+ }),
708
+ builtin({
709
+ id: "bead-trail",
710
+ name: "Bead trail",
711
+ category: "procedural",
712
+ engine: "particle",
713
+ tip: { size: 34, hardness: 0.9, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
714
+ particle: { kind: "spring", render: "sequence", count: 3, size: 0.18, spread: 0.5, stiffness: 0.3, damping: 0.3, interval: 0.4 }
715
+ }),
716
+ builtin({
717
+ id: "sparkle-dust",
718
+ name: "Sparkle dust",
719
+ category: "procedural",
720
+ engine: "particle",
721
+ tip: { size: 60, hardness: 0.6, opacity: 1, flow: 0.9, smoothing: 0.1, pressureSize: true, pressureOpacity: true },
722
+ particle: { kind: "flow", render: "dot", count: 6, size: 0.05, spread: 0.9, speed: 0.7, life: 0.4 }
723
+ }),
724
+ builtin({
725
+ id: "bristle-round",
726
+ name: "Bristle round",
727
+ category: "bristle",
728
+ engine: "bristle",
729
+ rotation: "stroke",
730
+ tip: { size: 30, hardness: 0.85, opacity: 1, flow: 0.9, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
731
+ dynamics: { minSize: 0.4, tiltSize: 0.6, tiltRoundness: 0.6 },
732
+ bristle: { count: 28, stiffness: 0.45, splay: 0.6, thickness: 0.035, load: 0.7, jitter: 0.5 }
733
+ }),
734
+ builtin({
735
+ id: "bristle-flat",
736
+ name: "Bristle flat",
737
+ category: "bristle",
738
+ engine: "bristle",
739
+ rotation: "stroke",
740
+ tip: { size: 46, hardness: 0.85, angle: 90, roundness: 0.3, opacity: 1, flow: 0.9, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
741
+ dynamics: { minSize: 0.5, tiltSize: 0.5 },
742
+ bristle: { count: 40, stiffness: 0.6, splay: 0.4, thickness: 0.025, load: 0.65, jitter: 0.45 }
743
+ }),
744
+ builtin({
745
+ id: "dry-brush",
746
+ name: "Dry brush",
747
+ category: "bristle",
748
+ engine: "bristle",
749
+ rotation: "stroke",
750
+ tip: { size: 44, hardness: 0.9, angle: 90, roundness: 0.45, opacity: 1, flow: 0.8, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
751
+ grain: 0.6,
752
+ bristle: { count: 48, stiffness: 0.35, splay: 0.7, thickness: 0.05, load: 0.15, jitter: 0.8 }
753
+ }),
754
+ // Light paint: brushes that lay light with their colour (glow and spill; see light.ts).
755
+ builtin({
756
+ id: "glow-gel",
757
+ name: "Glow gel",
758
+ category: "light",
759
+ engine: "impasto",
760
+ tip: { size: 16, hardness: 0.9, spacing: 0.05, opacity: 1, flow: 1, smoothing: 0.5, pressureSize: true, pressureOpacity: false },
761
+ dynamics: { minSize: 0.6 },
762
+ impasto: { tool: "gloop", thickness: 1, gloss: 0.95, load: 1, drag: 0, rim: 0.8, clarity: 0.5 },
763
+ light: { amount: 0.85 }
764
+ }),
765
+ builtin({
766
+ id: "neon-marker",
767
+ name: "Neon marker",
768
+ category: "light",
769
+ tip: { size: 10, hardness: 0.9, spacing: 0.04, opacity: 1, flow: 1, smoothing: 0.45, pressureSize: false, pressureOpacity: false },
770
+ light: { amount: 1 }
771
+ }),
772
+ builtin({
773
+ id: "light-airbrush",
774
+ name: "Light airbrush",
775
+ category: "light",
776
+ tip: { size: 90, hardness: 0, spacing: 0.04, opacity: 1, flow: 0.06, smoothing: 0.3, pressureSize: false, pressureOpacity: false },
777
+ pressureSmoothing: 0.3,
778
+ dynamics: { pressureFlow: 1 },
779
+ light: { amount: 0.7, pressure: true }
780
+ }),
781
+ builtin({
782
+ id: "spark-trail",
783
+ name: "Spark trail",
784
+ category: "light",
785
+ engine: "particle",
786
+ tip: { size: 50, hardness: 0.7, opacity: 1, flow: 1, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
787
+ particle: { kind: "flow", render: "dot", count: 8, size: 0.06, spread: 0.7, speed: 0.9, life: 0.3 },
788
+ light: { amount: 1 }
789
+ }),
790
+ builtin({
791
+ // Silk of light: strands dragged behind the pen on slow waves, the bands between them filled.
792
+ id: "light-ribbon",
793
+ name: "Light ribbon",
794
+ category: "light",
795
+ engine: "particle",
796
+ tip: { size: 80, hardness: 0.8, opacity: 0.9, flow: 1, smoothing: 0.35, pressureSize: false, pressureOpacity: false },
797
+ particle: { kind: "string", render: "ribbon", count: 5, size: 0.015, spread: 1, stiffness: 0.05, damping: 0.3, ribbonFill: 0.45, waveAmplitude: 0.25, waveLength: 220, wavePhaseJitter: 0.25 },
798
+ light: { amount: 0.8 }
799
+ }),
800
+ builtin({
801
+ // Flames: short-lived bands thrown off the pen, rising and curling in the flow.
802
+ id: "flame-ribbon",
803
+ name: "Flame ribbon",
804
+ category: "light",
805
+ engine: "particle",
806
+ tip: { size: 50, hardness: 0.6, opacity: 0.85, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
807
+ particle: { kind: "flow", render: "ribbon", count: 4, size: 0.012, spread: 0.5, speed: 0.6, gravity: -0.6, life: 0.2, ribbonFill: 0.5 },
808
+ light: { amount: 1 }
809
+ }),
810
+ builtin({
811
+ // A neon tube that lags and snaps back, with a slow shimmer.
812
+ id: "neon-strand",
813
+ name: "Neon strand",
814
+ category: "light",
815
+ engine: "particle",
816
+ tip: { size: 40, hardness: 0.9, opacity: 1, flow: 1, smoothing: 0.4, pressureSize: false, pressureOpacity: false },
817
+ particle: { kind: "elastic", render: "line", count: 3, size: 0.05, spread: 0.25, stiffness: 0.6, damping: 0.3, waveAmplitude: 0.06, waveLength: 120 },
818
+ light: { amount: 1 }
819
+ }),
820
+ builtin({
821
+ // Smoke lit from within: thin bands thrown into a slow, rising flow.
822
+ id: "smoke-ribbon",
823
+ name: "Smoke ribbon",
824
+ category: "light",
825
+ engine: "particle",
826
+ tip: { size: 70, hardness: 0.3, opacity: 0.45, flow: 1, smoothing: 0.3, pressureSize: true, pressureOpacity: false },
827
+ particle: { kind: "flow", render: "ribbon", count: 3, size: 5e-3, spread: 0.5, speed: 0.15, gravity: -0.15, life: 0.35, ribbonFill: 0.25, waveAmplitude: 0.1, waveLength: 140, wavePhaseJitter: 1 },
828
+ light: { amount: 0.3 }
829
+ }),
830
+ builtin({
831
+ // Out-of-focus lights: soft discs and rings spaced along springy paths.
832
+ id: "bokeh-dots",
833
+ name: "Bokeh dots",
834
+ category: "light",
835
+ engine: "particle",
836
+ tip: { size: 80, hardness: 0.7, opacity: 0.6, flow: 1, smoothing: 0.3, pressureSize: true, pressureOpacity: false },
837
+ particle: { kind: "spring", render: "sequence", count: 3, size: 0.3, spread: 1, stiffness: 0.15, damping: 0.4, interval: 1, dotTextures: ["dot", "dot", "ring"], dotTextureOrder: "random" },
838
+ light: { amount: 0.9 }
839
+ }),
840
+ builtin({
841
+ // Sparks: twinkles and stars thrown into the flow, spaced along it.
842
+ id: "twinkle-sparks",
843
+ name: "Twinkle sparks",
844
+ category: "light",
845
+ engine: "particle",
846
+ tip: { size: 50, hardness: 0.8, opacity: 1, flow: 1, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
847
+ particle: { kind: "flow", render: "sequence", count: 3, size: 0.25, spread: 0.8, speed: 0.9, life: 0.35, interval: 0.6, dotTextures: ["sparkle", "sparkle", "star"], dotTextureOrder: "random", dotRotation: "random" },
848
+ light: { amount: 1 }
849
+ }),
850
+ builtin({
851
+ id: "glow-wash",
852
+ name: "Glow wash",
853
+ category: "light",
854
+ engine: "wet",
855
+ tip: { size: 40, hardness: 0.55, spacing: 0.12, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: true },
856
+ dynamics: { minSize: 0.35 },
857
+ behavior: { pickup: 0.4, mix: 0.5, mixing: "pigment" },
858
+ wet: { tool: "paint", water: 0.65, pigment: 0.75, body: 0, rewet: 0.1 },
859
+ light: { amount: 0.7 }
860
+ }),
861
+ builtin({
862
+ // Fluorescent ink: a transparent chisel marker that multiplies (text under it stays dark) and
863
+ // glows a little brighter than the paper. Turn the layer's spill off (set_light_spill radius 0).
864
+ id: "highlighter",
865
+ name: "Highlighter",
866
+ category: "light",
867
+ tip: { size: 30, hardness: 0.85, spacing: 0.04, angle: 45, roundness: 0.35, opacity: 0.6, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: false, blend: "multiply" },
868
+ grain: 0.05,
869
+ light: { amount: 0.5 }
870
+ }),
871
+ builtin({
872
+ id: "sticker-stars",
873
+ name: "Star stickers",
874
+ category: "sticker",
875
+ engine: "sticker",
876
+ tip: { size: 36, opacity: 1, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
877
+ dynamics: { minSize: 0.5 },
878
+ sticker: { stamps: ["star", "sparkle"], density: 0.35, sizeJitter: 0.5, rotationJitter: 1, scatter: 0.6, colorJitter: 0.3 }
879
+ }),
880
+ builtin({
881
+ id: "sticker-leaves",
882
+ name: "Leaf stickers",
883
+ category: "sticker",
884
+ engine: "sticker",
885
+ rotation: "stroke",
886
+ tip: { size: 40, opacity: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
887
+ dynamics: { minSize: 0.5 },
888
+ sticker: { stamps: ["leaf"], density: 0.5, sizeJitter: 0.4, rotationJitter: 0.35, scatter: 0.4, colorJitter: 0.35 }
889
+ }),
890
+ builtin({
891
+ id: "sticker-confetti",
892
+ name: "Confetti",
893
+ category: "sticker",
894
+ engine: "sticker",
895
+ tip: { size: 22, opacity: 1, smoothing: 0.1, pressureSize: false, pressureOpacity: false },
896
+ sticker: { stamps: ["confetti", "dot", "ring"], density: 0.7, sizeJitter: 0.5, rotationJitter: 1, scatter: 1.5, colorJitter: 1 }
897
+ }),
898
+ builtin({
899
+ id: "sticker-flowers",
900
+ name: "Flower stickers",
901
+ category: "sticker",
902
+ engine: "sticker",
903
+ tip: { size: 44, opacity: 1, smoothing: 0.2, pressureSize: true, pressureOpacity: false },
904
+ sticker: { stamps: ["flower", "heart"], density: 0.3, sizeJitter: 0.5, rotationJitter: 1, scatter: 0.7, colorJitter: 0.4 }
905
+ }),
906
+ builtin({
907
+ id: "sticker-splats",
908
+ name: "Paint splats",
909
+ category: "sticker",
910
+ engine: "sticker",
911
+ tip: { size: 50, opacity: 0.95, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
912
+ sticker: { stamps: ["splat"], density: 0.25, sizeJitter: 0.6, rotationJitter: 1, scatter: 0.8, colorJitter: 0.15 }
913
+ }),
914
+ builtin({
915
+ id: "add-water",
916
+ name: "Add water",
917
+ category: "wet-tool",
918
+ engine: "wet",
919
+ tip: { size: 40, hardness: 0.4, spacing: 0.12, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: true },
920
+ dynamics: { minSize: 0.5 },
921
+ wet: { tool: "water", water: 0.6, pigment: 0, rewet: 0.3 }
922
+ }),
923
+ builtin({
924
+ id: "hair-dryer",
925
+ name: "Dry",
926
+ category: "wet-tool",
927
+ engine: "wet",
928
+ tip: { size: 80, hardness: 0.1, spacing: 0.15, opacity: 1, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: true },
929
+ wet: { tool: "dry", strength: 0.5 }
930
+ }),
931
+ builtin({
932
+ id: "blow",
933
+ name: "Blow",
934
+ category: "wet-tool",
935
+ engine: "wet",
936
+ tip: { size: 50, hardness: 0.2, spacing: 0.1, opacity: 1, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: true },
937
+ wet: { tool: "blow", strength: 0.6 }
938
+ }),
939
+ builtin({
940
+ id: "wet-blend",
941
+ name: "Wet blend",
942
+ category: "wet-tool",
943
+ engine: "wet",
944
+ tip: { size: 36, hardness: 0.3, spacing: 0.1, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: false, pressureOpacity: true },
945
+ wet: { tool: "blend", strength: 0.4 }
946
+ }),
947
+ builtin({
948
+ id: "wet-drag",
949
+ name: "Wet smudge",
950
+ category: "wet-tool",
951
+ engine: "wet",
952
+ tip: { size: 30, hardness: 0.5, spacing: 0.08, opacity: 1, flow: 1, smoothing: 0.25, pressureSize: false, pressureOpacity: true },
953
+ wet: { tool: "smudge", strength: 0.6 }
954
+ }),
955
+ builtin({
956
+ id: "blot",
957
+ name: "Blot (erase wet)",
958
+ category: "wet-tool",
959
+ engine: "wet",
960
+ tip: { size: 40, hardness: 0.5, spacing: 0.12, opacity: 1, flow: 1, smoothing: 0.2, pressureSize: false, pressureOpacity: true },
961
+ wet: { tool: "blot", strength: 0.7 }
962
+ })
963
+ ];
964
+ var DEFAULT_BRUSH_ID = "pencil-6b";
965
+ function brushToJSON(brush) {
966
+ const { builtin: _b, ...rest } = brush;
967
+ return JSON.parse(JSON.stringify(rest));
968
+ }
969
+ function parseBrushLibrary(raw, problems = []) {
970
+ const list2 = Array.isArray(raw) ? raw : raw && typeof raw === "object" && Array.isArray(raw.brushes) ? raw.brushes : null;
971
+ if (!list2) throw new Error('Not a brush library: expected {"format":"pixerate-brushes","version":1,"brushes":[...]} or an array of brushes.');
972
+ const out = [];
973
+ list2.forEach((item, i) => {
974
+ if (!item || typeof item !== "object") {
975
+ problems.push(`brushes[${i}] is not an object; skipped.`);
976
+ return;
977
+ }
978
+ const r = item;
979
+ if (typeof r.id !== "string" || !BRUSH_ID.test(r.id)) {
980
+ problems.push(`brushes[${i}] has no valid id (lowercase letters, digits and dashes); skipped.`);
981
+ return;
982
+ }
983
+ if (typeof r.name !== "string" || !r.name.trim()) {
984
+ problems.push(`brushes[${i}] ("${r.id}") has no name; skipped.`);
985
+ return;
986
+ }
987
+ const { builtin: _b, ...rest } = r;
988
+ out.push(normalizePaintBrush(rest));
989
+ });
990
+ return out;
991
+ }
992
+
32
993
  // src/brushes/resolve.ts
33
994
  var ALL = ["dab", "eraser", "mixer", "blender", "smudge", "wet", "impasto", "particle", "bristle", "sticker"];
34
995
  var EXCHANGE = ["dab", "wet", "impasto"];
@@ -241,11 +1202,11 @@ var BRUSH_SURFACES = ["dry", "wet", "thick"];
241
1202
  // src/brushes/schema.ts
242
1203
  var MAX_BRUSH_PALETTE = 16;
243
1204
  var BRUSH_WORD = /^[a-z0-9][a-z0-9-]{0,39}$/;
244
- var unit = (description) => ({ type: "number", minimum: 0, maximum: 1, description });
1205
+ var unit3 = (description) => ({ type: "number", minimum: 0, maximum: 1, description });
245
1206
  var num = (minimum, maximum, description) => ({ type: "number", minimum, maximum, description });
246
1207
  var int = (minimum, maximum, description) => ({ type: "integer", minimum, maximum, description });
247
1208
  var bool = (description) => ({ type: "boolean", description });
248
- var oneOf = (values, description) => ({ type: "string", enum: [...values], description });
1209
+ var oneOf2 = (values, description) => ({ type: "string", enum: [...values], description });
249
1210
  var object = (description, properties, required) => ({
250
1211
  type: "object",
251
1212
  description,
@@ -253,7 +1214,7 @@ var object = (description, properties, required) => ({
253
1214
  ...required ? { required } : {},
254
1215
  additionalProperties: false
255
1216
  });
256
- var MIXING = oneOf(["pigment", "rgb"], "How colours mix: pigment (Kubelka-Munk, blue and yellow make green) or rgb (straight averages). Default pigment; a Mixer Brush and the smudge tool default to rgb.");
1217
+ var MIXING = oneOf2(["pigment", "rgb"], "How colours mix: pigment (Kubelka-Munk, blue and yellow make green) or rgb (straight averages). Default pigment; a Mixer Brush and the smudge tool default to rgb.");
257
1218
  var BRUSH_STAMP_PATTERN = `^(?:[^:\\s]+:\\S+|${STAMP_KINDS.join("|")})$`;
258
1219
  var STAMP = {
259
1220
  type: "string",
@@ -262,24 +1223,24 @@ var STAMP = {
262
1223
  };
263
1224
  var BRISTLES = object("Bristle tips: the tuft. Missing fields take their defaults.", {
264
1225
  count: int(3, 128, "Bristles in the tuft (default 32)."),
265
- stiffness: unit("Stiff bristles follow the tip; soft ones lag and bend into curves at turns (default 0.5)."),
266
- splay: unit("How far pressure spreads the tuft (default 0.5)."),
267
- thickness: unit("Each bristle's line width as a fraction of the tip size (default 0.06)."),
268
- load: unit("Paint per bristle: 1 never runs dry; lower, bristles run out along the stroke, a dry brush (default 0.6)."),
269
- jitter: unit("Random variation between bristles: length, paint, position (default 0.4).")
1226
+ stiffness: unit3("Stiff bristles follow the tip; soft ones lag and bend into curves at turns (default 0.5)."),
1227
+ splay: unit3("How far pressure spreads the tuft (default 0.5)."),
1228
+ thickness: unit3("Each bristle's line width as a fraction of the tip size (default 0.06)."),
1229
+ load: unit3("Paint per bristle: 1 never runs dry; lower, bristles run out along the stroke, a dry brush (default 0.6)."),
1230
+ jitter: unit3("Random variation between bristles: length, paint, position (default 0.4).")
270
1231
  });
271
1232
  var TIP = object(
272
1233
  "The tip: its shape and the dab it lays.",
273
1234
  {
274
- shape: oneOf(BRUSH_TIP_SHAPES, "round, flat, chisel: an ellipse set by roundness and angle (the name is for people); bristle: a tuft of bristles (see bristle); stamp: textured stamps (see stamps), laid as objects or particles."),
1235
+ shape: oneOf2(BRUSH_TIP_SHAPES, "round, flat, chisel: an ellipse set by roundness and angle (the name is for people); bristle: a tuft of bristles (see bristle); stamp: textured stamps (see stamps), laid as objects or particles."),
275
1236
  size: num(1, MAX_BRUSH_SIZE, "Diameter in px (default 20)."),
276
- hardness: unit("Edge hardness, 0 soft .. 1 hard (default 1)."),
1237
+ hardness: unit3("Edge hardness, 0 soft .. 1 hard (default 1)."),
277
1238
  spacing: num(0.01, 10, "Dab spacing as a fraction of the diameter: 0.05 smooth, 1 dotted (default 0.1)."),
278
1239
  angle: num(-360, 360, "Tip angle in degrees, counter-clockwise; added to the stroke direction or barrel with dynamics.rotation (default 0)."),
279
1240
  roundness: num(0.01, 1, "Roundness: 1 round (default), lower a flat ellipse (pastel, chisel)."),
280
- opacity: unit("Stroke opacity ceiling: overlapping dabs never exceed it (default 1)."),
281
- flow: unit("Build-up per dab towards the opacity: low flow builds softly, like an airbrush (default 1)."),
282
- blend: oneOf(
1241
+ opacity: unit3("Stroke opacity ceiling: overlapping dabs never exceed it (default 1)."),
1242
+ flow: unit3("Build-up per dab towards the opacity: low flow builds softly, like an airbrush (default 1)."),
1243
+ blend: oneOf2(
283
1244
  BLEND_MODES.map((m) => m.id),
284
1245
  "How the stroke paints onto the layer (default normal). pigment mixes like real paint; multiply darkens like marker ink; watercolor glazes; tint, highlight and shadow recolour, light and shade."
285
1246
  ),
@@ -299,27 +1260,27 @@ var PARTICLES = object(
299
1260
  "Particles that move with the pen and draw as they go. Missing fields take their defaults.",
300
1261
  {
301
1262
  kind: { type: "string", const: "particles", description: 'Always "particles".' },
302
- physics: oneOf(
1263
+ physics: oneOf2(
303
1264
  PARTICLE_KINDS,
304
1265
  "How the particles move (default spring). string: strands dragged behind the pen (hair, fur); spring: on springs to the pen, overshooting; orbit: circling the pen (curls); flow: thrown into a swirling flow until they fade; elastic: links that stretch and snap back; fuzzy: winding strings waving out of step."
305
1266
  ),
306
- render: oneOf(PARTICLE_RENDERS, "How they draw (default line). dot: a mark per step; line: a continuous line each; sequence: beads spaced along each path; ribbon: the band between neighbouring paths filled."),
1267
+ render: oneOf2(PARTICLE_RENDERS, "How they draw (default line). dot: a mark per step; line: a continuous line each; sequence: beads spaced along each path; ribbon: the band between neighbouring paths filled."),
307
1268
  count: int(1, 64, "Particles (flow: thrown per tip of travel). Default 12."),
308
- size: unit("Mark size as a fraction of the tip size; ribbon: its edge width (default 0.08)."),
309
- spread: unit("How far apart they start, as a fraction of the tip size; ribbon: its width (default 0.5)."),
310
- stiffness: unit("Spring, string and elastic stiffness: stiff follows closely, soft swings wide (default 0.4)."),
311
- damping: unit("How quickly motion dies down (default 0.25)."),
312
- speed: unit("Orbit and flow speed (default 0.5)."),
1269
+ size: unit3("Mark size as a fraction of the tip size; ribbon: its edge width (default 0.08)."),
1270
+ spread: unit3("How far apart they start, as a fraction of the tip size; ribbon: its width (default 0.5)."),
1271
+ stiffness: unit3("Spring, string and elastic stiffness: stiff follows closely, soft swings wide (default 0.4)."),
1272
+ damping: unit3("How quickly motion dies down (default 0.25)."),
1273
+ speed: unit3("Orbit and flow speed (default 0.5)."),
313
1274
  gravity: num(-1, 1, "Pull downwards; negative pulls up (default 0)."),
314
- life: unit("Flow particles: how long they live (default 0.5)."),
315
- interval: unit("Sequence: spacing of the beads (default 0.3)."),
316
- ribbonFill: unit("Ribbon: fill opacity between the paths, relative to their edges (default 0.5; 0 only edges)."),
317
- ribbonPairs: oneOf(RIBBON_PAIRS, "Ribbon: adjacent (default) joins each particle to its neighbour; first-last makes one band between the outermost two."),
318
- waveAmplitude: unit("Wave: sideways swing as a fraction of the tip size (default 0: no wave)."),
1275
+ life: unit3("Flow particles: how long they live (default 0.5)."),
1276
+ interval: unit3("Sequence: spacing of the beads (default 0.3)."),
1277
+ ribbonFill: unit3("Ribbon: fill opacity between the paths, relative to their edges (default 0.5; 0 only edges)."),
1278
+ ribbonPairs: oneOf2(RIBBON_PAIRS, "Ribbon: adjacent (default) joins each particle to its neighbour; first-last makes one band between the outermost two."),
1279
+ waveAmplitude: unit3("Wave: sideways swing as a fraction of the tip size (default 0: no wave)."),
319
1280
  waveLength: num(4, 2e3, "Wave: wavelength in px of pen travel (default 60)."),
320
- wavePhaseJitter: unit("Wave: how far out of step the particles' waves are; 0 in step, 1 random phases (default 0)."),
321
- stampOrder: oneOf(DOT_TEXTURE_ORDERS, "Stamp tips: sequence (default) uses the stamps in turn; random picks one per dot, seeded."),
322
- stampRotation: oneOf(DOT_ROTATIONS, "Stamp tips: follow turns each with its particle's motion; random (default); fixed uses the tip angle.")
1281
+ wavePhaseJitter: unit3("Wave: how far out of step the particles' waves are; 0 in step, 1 random phases (default 0)."),
1282
+ stampOrder: oneOf2(DOT_TEXTURE_ORDERS, "Stamp tips: sequence (default) uses the stamps in turn; random picks one per dot, seeded."),
1283
+ stampRotation: oneOf2(DOT_ROTATIONS, "Stamp tips: follow turns each with its particle's motion; random (default); fixed uses the tip angle.")
323
1284
  },
324
1285
  ["kind"]
325
1286
  );
@@ -327,11 +1288,11 @@ var OBJECTS = object(
327
1288
  "Each mark stays an editable object (a sticker) on a sticker layer; needs a stamp tip. Missing fields take their defaults.",
328
1289
  {
329
1290
  kind: { type: "string", const: "objects", description: 'Always "objects".' },
330
- density: unit("About density \xD7 4 marks per tip diameter of travel; 0.25 \u2248 one per diameter (default 0.4)."),
331
- sizeJitter: unit("Random size change (default 0.4)."),
332
- rotationJitter: unit("Random turn as a fraction of \xB1180\xB0 (default 1)."),
1291
+ density: unit3("About density \xD7 4 marks per tip diameter of travel; 0.25 \u2248 one per diameter (default 0.4)."),
1292
+ sizeJitter: unit3("Random size change (default 0.4)."),
1293
+ rotationJitter: unit3("Random turn as a fraction of \xB1180\xB0 (default 1)."),
333
1294
  scatter: num(0, 2, "How far from the path marks land, in tip diameters (default 0.5)."),
334
- colourJitter: unit("Random colour change: at 1 up to \xB140\xB0 of hue and \xB10.15 OKLab lightness (default 0.2).")
1295
+ colourJitter: unit3("Random colour change: at 1 up to \xB140\xB0 of hue and \xB10.15 OKLab lightness (default 0.2).")
335
1296
  },
336
1297
  ["kind"]
337
1298
  );
@@ -340,62 +1301,62 @@ var DYNAMICS = object("Pen feel: pressure, tilt, velocity, jitter, rotation and
340
1301
  pressureOpacity: bool("Pressure scales the opacity (default false)."),
341
1302
  smoothing: num(0, 0.95, "Position smoothing of the input (default 0)."),
342
1303
  pressureSmoothing: num(0, 0.95, "Pressure smoothing, separate from position smoothing (default 0)."),
343
- rotation: oneOf(BRUSH_ROTATIONS, "What turns the tip: fixed (tip.angle, default), stroke (direction of travel) or barrel (pen barrel rotation)."),
344
- minSize: unit("Size at zero pressure as a fraction of the full size, with pressureSize (default 0)."),
345
- pressureFlow: unit("How much pressure scales flow (default 0)."),
346
- tiltSize: unit("Laying the pen down widens the tip, up to 2.5\xD7 (default 0)."),
347
- tiltRoundness: unit("Laying the pen down flattens the tip along the tilt, side-of-the-pencil shading (default 0)."),
348
- tiltOpacity: unit("Laying the pen down lightens the mark (default 0)."),
1304
+ rotation: oneOf2(BRUSH_ROTATIONS, "What turns the tip: fixed (tip.angle, default), stroke (direction of travel) or barrel (pen barrel rotation)."),
1305
+ minSize: unit3("Size at zero pressure as a fraction of the full size, with pressureSize (default 0)."),
1306
+ pressureFlow: unit3("How much pressure scales flow (default 0)."),
1307
+ tiltSize: unit3("Laying the pen down widens the tip, up to 2.5\xD7 (default 0)."),
1308
+ tiltRoundness: unit3("Laying the pen down flattens the tip along the tilt, side-of-the-pencil shading (default 0)."),
1309
+ tiltOpacity: unit3("Laying the pen down lightens the mark (default 0)."),
349
1310
  velocitySize: num(-1, 1, "Positive grows the tip on fast strokes, negative thins it; needs timed points (default 0)."),
350
1311
  velocityOpacity: num(-1, 1, "Positive darkens fast marks, negative lightens them; needs timed points (default 0)."),
351
- sizeJitter: unit("Random size change per dab (default 0)."),
352
- angleJitter: unit("Random angle per dab, as a fraction of \xB1180\xB0 (default 0)."),
353
- opacityJitter: unit("Random opacity drop per dab (default 0)."),
1312
+ sizeJitter: unit3("Random size change per dab (default 0)."),
1313
+ angleJitter: unit3("Random angle per dab, as a fraction of \xB1180\xB0 (default 0)."),
1314
+ opacityJitter: unit3("Random opacity drop per dab (default 0)."),
354
1315
  scatter: num(0, 4, "Random offset across the stroke, in diameters (default 0).")
355
1316
  });
356
1317
  var EXCHANGE2 = object("Paint exchange with the canvas: picking up paint and mixing it into what the brush lays. Paint brushes on a dry, wet or thick surface and Mixer Brushes.", {
357
- pickup: unit("Pick-up ratio: how much of the paint under each dab the brush lifts and carries (default 0; with a reservoir 0.5)."),
358
- mix: unit("Mix ratio: how much of the carried paint goes into what the brush lays, against its own colour (default 0.5)."),
1318
+ pickup: unit3("Pick-up ratio: how much of the paint under each dab the brush lifts and carries (default 0; with a reservoir 0.5)."),
1319
+ mix: unit3("Mix ratio: how much of the carried paint goes into what the brush lays, against its own colour (default 0.5)."),
359
1320
  dirty: bool("Keep the carried paint after the stroke, so the next one starts with the last colour touched (default false)."),
360
1321
  reservoir: object("A paint reservoir makes this a Mixer Brush (an empty object takes every default).", {
361
- load: unit("How much paint the reservoir holds; below 1 it drains per dab and long strokes run dry (default 0.5)."),
1322
+ load: unit3("How much paint the reservoir holds; below 1 it drains per dab and long strokes run dry (default 0.5)."),
362
1323
  refill: bool("Refill with the paint colour before each stroke (default true)."),
363
1324
  loadsColour: bool("Load the current colour at all (default true; false: it only moves paint already down).")
364
1325
  })
365
1326
  });
366
1327
  var WET = object("Wet surface (watercolour, gouache, wet oil) and the wet tools. Missing fields take their defaults.", {
367
- medium: oneOf(WET_MEDIA, "The wet medium; a layer takes the medium's wet settings the first time it holds wet paint (default: the variety when it is watercolor, gouache or oil, else watercolor)."),
368
- water: unit("Wetness: water laid, 0.2 a damp brush .. 1 dripping (default 0.5)."),
369
- pigment: unit("Pigment concentration: 0 clear water .. 1 straight from the tube (default 0.5)."),
370
- body: unit("Body: 0 transparent watercolour .. 1 opaque gouache (default 0)."),
371
- rewet: unit("How much dried paint the water lifts again: 0 staining (default 0)."),
372
- load: unit("Paint loading: 1 never runs out; lower, the brush runs dry along a long stroke (default 1)."),
373
- thinner: unit("Thinner (water or solvent): more transparent, runnier paint (default 0).")
1328
+ medium: oneOf2(WET_MEDIA, "The wet medium; a layer takes the medium's wet settings the first time it holds wet paint (default: the variety when it is watercolor, gouache or oil, else watercolor)."),
1329
+ water: unit3("Wetness: water laid, 0.2 a damp brush .. 1 dripping (default 0.5)."),
1330
+ pigment: unit3("Pigment concentration: 0 clear water .. 1 straight from the tube (default 0.5)."),
1331
+ body: unit3("Body: 0 transparent watercolour .. 1 opaque gouache (default 0)."),
1332
+ rewet: unit3("How much dried paint the water lifts again: 0 staining (default 0)."),
1333
+ load: unit3("Paint loading: 1 never runs out; lower, the brush runs dry along a long stroke (default 1)."),
1334
+ thinner: unit3("Thinner (water or solvent): more transparent, runnier paint (default 0).")
374
1335
  });
375
1336
  var THICK = object("Thick surface (impasto: paint with relief, lit by the canvas lighting). Missing fields take their defaults.", {
376
- applicator: oneOf(IMPASTO_TOOLS, "What lays the paint (default brush). brush: bristle ridges; tube: blobs squeezed from the tube; knife: scrapes the relief flat and smears; roller: a flat stippled band; gloop: a thick glossy bead."),
377
- thickness: unit("How far the paint stands up (default 0.6)."),
378
- gloss: unit("Shine: 0 matte .. 1 wet-looking gloss (default 0.4)."),
379
- metallic: unit("Metallic: highlights take the paint colour, gold, copper, silver (default 0)."),
380
- load: unit("Paint loading: 1 never runs out; lower, the stroke thins as the brush empties (default 0.9)."),
381
- thinner: unit("Thinner: flatter, more transparent paint (default 0)."),
382
- drag: unit("Knife and brush: how much of the paint under the tool is dragged along (default 0.4)."),
383
- rim: unit("Light paint: light is stronger where the bead turns away, as on backlit glass (default 0)."),
384
- clarity: unit("Glassy paint: the middle of a bead lets the layers below show through (default 0).")
1337
+ applicator: oneOf2(IMPASTO_TOOLS, "What lays the paint (default brush). brush: bristle ridges; tube: blobs squeezed from the tube; knife: scrapes the relief flat and smears; roller: a flat stippled band; gloop: a thick glossy bead."),
1338
+ thickness: unit3("How far the paint stands up (default 0.6)."),
1339
+ gloss: unit3("Shine: 0 matte .. 1 wet-looking gloss (default 0.4)."),
1340
+ metallic: unit3("Metallic: highlights take the paint colour, gold, copper, silver (default 0)."),
1341
+ load: unit3("Paint loading: 1 never runs out; lower, the stroke thins as the brush empties (default 0.9)."),
1342
+ thinner: unit3("Thinner: flatter, more transparent paint (default 0)."),
1343
+ drag: unit3("Knife and brush: how much of the paint under the tool is dragged along (default 0.4)."),
1344
+ rim: unit3("Light paint: light is stronger where the bead turns away, as on backlit glass (default 0)."),
1345
+ clarity: unit3("Glassy paint: the middle of a bead lets the layers below show through (default 0).")
385
1346
  });
386
1347
  var BEHAVIOUR = object("What the material does on the surface, and how tools work. Missing fields take their defaults.", {
387
- surface: oneOf(BRUSH_SURFACES, "dry (default): stays where it lands; wet: flows, pools and dries (see wet); thick: stands up in relief (see thick)."),
388
- grain: unit("Paper grain response: 0 ignores the grain, 1 catches only its peaks at light pressure (pencil, pastel, charcoal). Default 0."),
1348
+ surface: oneOf2(BRUSH_SURFACES, "dry (default): stays where it lands; wet: flows, pools and dries (see wet); thick: stands up in relief (see thick)."),
1349
+ grain: unit3("Paper grain response: 0 ignores the grain, 1 catches only its peaks at light pressure (pencil, pastel, charcoal). Default 0."),
389
1350
  mixing: MIXING,
390
1351
  exchange: EXCHANGE2,
391
1352
  wet: WET,
392
1353
  thick: THICK,
393
- strength: unit("Tools: how strongly the blender, smudge or wet tool works per dab (default 0.5; smudge 0.7)."),
394
- distance: unit("Blender: how far away colours are mixed in, as a fraction of the tip diameter (default 0.35)."),
395
- length: unit("Smudge: how far paint is dragged, the share of carried paint kept per dab (default 0.85).")
1354
+ strength: unit3("Tools: how strongly the blender, smudge or wet tool works per dab (default 0.5; smudge 0.7)."),
1355
+ distance: unit3("Blender: how far away colours are mixed in, as a fraction of the tip diameter (default 0.35)."),
1356
+ length: unit3("Smudge: how far paint is dragged, the share of carried paint kept per dab (default 0.85).")
396
1357
  });
397
1358
  var LIGHT = object("Light the brush gives off with its colour (glow and spill). Give light brushes dark paper.", {
398
- amount: unit("Light per dab, scaled by its coverage and flow (default 0: none)."),
1359
+ amount: unit3("Light per dab, scaled by its coverage and flow (default 0: none)."),
399
1360
  pressure: bool("Pressure scales the light (default false)."),
400
1361
  spill: object("Suggested spill for the layer, applied when it has none of its own.", {
401
1362
  radius: num(0, 512, "How far the light reaches, px; 0 turns spill off (a highlighter)."),
@@ -406,7 +1367,7 @@ var LIGHT = object("Light the brush gives off with its colour (glow and spill).
406
1367
  var MOTION = object("Animation after painting.", {
407
1368
  live: bool("Effect paint keeps playing on stroke layers (default false)."),
408
1369
  fps: int(1, 60, "Suggested playback rate in frames per second."),
409
- waveSpeed: unit("Particle waves: how fast they run by stroke time; 0 they stand still (default 0), 1 about four a second.")
1370
+ waveSpeed: unit3("Particle waves: how fast they run by stroke time; 0 they stand still (default 0), 1 about four a second.")
410
1371
  });
411
1372
  var SOLID = {
412
1373
  type: "string",
@@ -427,7 +1388,7 @@ var EFFECT = object(
427
1388
  {
428
1389
  by: { type: "string", const: "stroke", description: 'Always "stroke".' },
429
1390
  length: num(16, 4096, "Strip length: px of stroke per repeat of the effect."),
430
- repeat: oneOf(["repeat", "mirror", "clamp"], "Past the strip end: start again, run back or hold.")
1391
+ repeat: oneOf2(["repeat", "mirror", "clamp"], "Past the strip end: start again, run back or hold.")
431
1392
  },
432
1393
  ["by", "length", "repeat"]
433
1394
  ),
@@ -454,10 +1415,10 @@ var BRUSH_JSON_SCHEMA = object(
454
1415
  name: { type: "string", minLength: 1, maxLength: 80, description: "Display name." },
455
1416
  description: { type: "string", maxLength: 500, description: "What the brush is for, in a sentence." },
456
1417
  tags: { type: "array", maxItems: 16, uniqueItems: true, items: { type: "string", pattern: BRUSH_WORD.source, description: "A tag: lowercase letters, digits and dashes." }, description: "Search tags." },
457
- kind: oneOf(BRUSH_KINDS, "mark: lays a material (see medium); tool: works on paint already down (see tool)."),
458
- medium: oneOf(BRUSH_MEDIA, "Marks: what it lays. paint (dry, wet or thick), ink, dry (pencil, pastel, charcoal) or spray (airbrush). Descriptive: the tip, marks and behaviour decide how it renders."),
1418
+ kind: oneOf2(BRUSH_KINDS, "mark: lays a material (see medium); tool: works on paint already down (see tool)."),
1419
+ medium: oneOf2(BRUSH_MEDIA, "Marks: what it lays. paint (dry, wet or thick), ink, dry (pencil, pastel, charcoal) or spray (airbrush). Descriptive: the tip, marks and behaviour decide how it renders."),
459
1420
  variety: { type: "string", pattern: BRUSH_WORD.source, description: "What kind of the medium: oil, watercolor, gouache, pencil, pastel, charcoal, airbrush, marker, highlighter\u2026 A wet brush whose variety is watercolor, gouache or oil paints that medium." },
460
- tool: oneOf(
1421
+ tool: oneOf2(
461
1422
  BRUSH_TOOLS,
462
1423
  "Tools: eraser removes paint; blender mixes the colours under the tip; smudge drags paint along; water lays clear water (lifts dried paint with behaviour.wet.rewet); dryer dries wet paint; blow pushes water along; blot takes wet paint up; wet-blend evens out wet pigment; wet-drag drags wet paint."
463
1424
  ),
@@ -1556,7 +2517,7 @@ var BrushLibraryError = class extends Error {
1556
2517
  };
1557
2518
  var isObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1558
2519
  var isV1 = (item) => "engine" in item || "category" in item || !("kind" in item);
1559
- function parseBrushLibrary(raw, problems = []) {
2520
+ function parseBrushLibrary2(raw, problems = []) {
1560
2521
  if (typeof raw === "string") {
1561
2522
  try {
1562
2523
  raw = JSON.parse(raw);
@@ -1624,7 +2585,7 @@ function createBrushLibrary(options = {}) {
1624
2585
  let brushes = [];
1625
2586
  if (raw) {
1626
2587
  try {
1627
- brushes = parseBrushLibrary(raw).filter((b) => !builtinIds.has(b.id));
2588
+ brushes = parseBrushLibrary2(raw).filter((b) => !builtinIds.has(b.id));
1628
2589
  } catch {
1629
2590
  }
1630
2591
  }
@@ -1701,7 +2662,7 @@ function createBrushLibrary(options = {}) {
1701
2662
  }),
1702
2663
  importFile: (raw, o = {}) => run(async () => {
1703
2664
  const problems = [];
1704
- const incoming = parseBrushLibrary(raw, problems);
2665
+ const incoming = parseBrushLibrary2(raw, problems);
1705
2666
  const result = { added: [], replaced: [], skipped: [], problems };
1706
2667
  let brushes = [...await load()];
1707
2668
  for (const brush of incoming) {
@@ -1746,496 +2707,81 @@ function closest(id, ids, max = 3) {
1746
2707
  const near = ids.map((c) => [c, score(c)]).filter(([, s]) => s >= 3).sort((a, b) => b[1] - a[1]).slice(0, max).map(([c]) => c);
1747
2708
  return near.length ? `Closest: ${near.join(", ")}. ` : "";
1748
2709
  }
1749
-
1750
- // src/brushes/patch.ts
1751
- var isObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1752
- var copy4 = (v) => JSON.parse(JSON.stringify(v));
1753
- var FACETS = BRUSH_JSON_SCHEMA.properties;
1754
- var BRUSH_PATCH_PROPERTIES = (() => {
1755
- const { id: _id, name: _name, ...rest } = FACETS;
1756
- const { required: _r, ...tip } = rest.tip;
1757
- return { ...rest, tip: { ...tip, description: `${tip.description} In a patch, shape may be left out to keep it.` } };
1758
- })();
1759
- var UNSETTABLE_BRUSH_FACETS = ["description", "tags", "medium", "variety", "marks", "dynamics", "behaviour", "light", "motion", "colour"];
1760
- var BRUSH_UNSET_PROPERTY = {
1761
- type: "array",
1762
- minItems: 1,
1763
- uniqueItems: true,
1764
- items: { type: "string", enum: [...UNSETTABLE_BRUSH_FACETS], description: "A facet to remove." },
1765
- description: "Facets to remove, back to their defaults (applied before the facets given)."
1766
- };
1767
- function mergeBrushPatch(base, patch, path = "") {
1768
- const out = { ...base };
1769
- for (const [k, v] of Object.entries(patch)) {
1770
- if (v === void 0) continue;
1771
- const old = out[k];
1772
- const replaceMarks = path === "" && k === "marks" && isObject3(old) && isObject3(v) && old.kind !== v.kind;
1773
- out[k] = isObject3(v) && isObject3(old) && !replaceMarks ? mergeBrushPatch(old, v, `${path}${k}.`) : copy4(v);
1774
- }
1775
- return out;
1776
- }
1777
- function changedBrushPaths(a, b, path = "") {
1778
- if (isObject3(a) && isObject3(b)) return [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].flatMap((k) => changedBrushPaths(a[k], b[k], path ? `${path}.${k}` : k));
1779
- return JSON.stringify(a) === JSON.stringify(b) ? [] : [path];
1780
- }
1781
-
1782
- // src/brushes/tools.ts
1783
- var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
1784
- var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
1785
- var FACETS2 = BRUSH_JSON_SCHEMA.properties;
1786
- var ID = { type: "string", pattern: String(FACETS2.id.pattern), description: "Brush id from list_brush_presets (lowercase letters, digits and dashes)." };
1787
- var FACET_PROPS = BRUSH_PATCH_PROPERTIES;
1788
- var HOSTS = Object.keys(BRUSH_HOST_PROFILES);
1789
- var copy5 = (v) => JSON.parse(JSON.stringify(v));
1790
- var merge = mergeBrushPatch;
1791
- var changedPaths = changedBrushPaths;
1792
- function summary(entry, profile, withCheck) {
1793
- const b = entry.brush;
1794
- const engine = resolveEngine(b);
1795
- const check = withCheck ? checkBrush(b, profile) : null;
1796
- return {
1797
- id: b.id,
1798
- name: b.name,
1799
- builtin: entry.builtin,
1800
- kind: b.kind,
1801
- ...b.medium ? { medium: b.medium } : {},
1802
- ...b.variety ? { variety: b.variety } : {},
1803
- ...b.tool ? { tool: b.tool } : {},
1804
- category: resolveCategory(b, engine),
1805
- engine,
1806
- tip: b.tip.shape,
1807
- ...b.tip.size !== void 0 ? { size: b.tip.size } : {},
1808
- marks: b.marks?.kind ?? "continuous",
1809
- surface: b.behaviour?.surface ?? "dry",
1810
- ...b.light?.amount ? { light: b.light.amount } : {},
1811
- ...b.tags?.length ? { tags: b.tags } : {},
1812
- ...check ? { renderable: check.ok } : {}
1813
- };
1814
- }
1815
- var refusal = (check, host) => `${host.name} can't use this brush: ${check.issues.filter((i) => i.severity === "error").map((i) => `${i.facet}: ${i.reason} ${i.fix}`).join(" ")} (check_brush lists every issue; nothing was saved.)`;
1816
- var warnings = (check) => check.issues.filter((i) => i.severity === "warning");
1817
- function createBrushLibraryTools(library, options = {}) {
1818
- const profile = options.profile ?? PAINT_PROFILE;
1819
- const hostOf = (id) => {
1820
- if (id === void 0) return profile;
1821
- const p = BRUSH_HOST_PROFILES[id];
1822
- if (!p) throw new BrushLibraryError("invalid", `No host "${id}"; use one of: ${HOSTS.join(", ")}.`);
1823
- return p;
1824
- };
1825
- const entry = async (id) => {
1826
- const found = await library.get(id);
1827
- if (found) return found;
1828
- const ids = (await library.list()).map((e) => e.brush.id);
1829
- throw new BrushLibraryError("unknown_brush", `No brush "${id}". ${closest(id, ids)}list_brush_presets lists them.`);
1830
- };
1831
- const store = async (raw, overwrite) => {
1832
- const problems = [];
1833
- let brush;
1834
- try {
1835
- brush = normalizeBrushSpec(raw, problems);
1836
- } catch (e) {
1837
- throw new BrushLibraryError("invalid", e.message);
1838
- }
1839
- const check2 = checkBrush(brush, profile);
1840
- if (!check2.ok) throw new BrushLibraryError("invalid", refusal(check2, profile));
1841
- const saved = await library.save(brush, { overwrite });
1842
- return { ...saved, problems, check: check2 };
1843
- };
1844
- const list2 = {
1845
- name: "list_brush_presets",
1846
- title: "List brushes",
1847
- description: `Lists the brush library (built-in presets and saved brushes) that this host (${profile.name}) can render, filtered by kind, medium, variety, tool, tag or words. Returns each brush's id (for describe_brush, create_brush basedOn, duplicate_brush and the editor's set_brush), name, builtin, kind, medium, variety or tool, category, engine, tip shape and size, marks, surface and light, plus total. Call it first to find the nearest brush to start from. Changes nothing.`,
1848
- inputSchema: {
1849
- type: "object",
1850
- properties: {
1851
- kind: { type: "string", enum: [...BRUSH_KINDS], description: "mark (lays a material) or tool (works on paint already down)." },
1852
- medium: { type: "string", enum: [...BRUSH_MEDIA], description: "Marks laying this medium." },
1853
- variety: { type: "string", maxLength: 40, description: "This variety (oil, watercolor, pencil, highlighter\u2026)." },
1854
- tool: { type: "string", enum: [...BRUSH_TOOLS], description: "Tools that do this." },
1855
- tag: { type: "string", maxLength: 40, description: "Brushes with this tag." },
1856
- text: { type: "string", maxLength: 200, description: "Words in the id, name, description, variety or tags (each word must match the start of one)." },
1857
- source: { type: "string", enum: ["all", "builtin", "user"], description: "all (default), builtin presets only, or user (saved) brushes only." },
1858
- all: { type: "boolean", description: "Also list brushes this host can't render, each with renderable false (default false)." },
1859
- offset: { type: "integer", minimum: 0, description: "Skip this many (default 0)." },
1860
- limit: { type: "integer", minimum: 1, maximum: 200, description: "At most this many (default 100)." }
1861
- },
1862
- additionalProperties: false
1863
- },
1864
- annotations: { title: "List brushes", ...READ },
1865
- handler: async (a) => {
1866
- const source = a.source === "all" ? void 0 : a.source;
1867
- const found = await library.list({ kind: a.kind, medium: a.medium, variety: a.variety, tool: a.tool, tag: a.tag, text: a.text, source, ...a.all ? {} : { profile } });
1868
- const offset = a.offset ?? 0;
1869
- const page = found.slice(offset, offset + (a.limit ?? 100));
1870
- return { host: profile.id, total: found.length, brushes: page.map((e) => summary(e, profile, !!a.all)) };
1871
- }
1872
- };
1873
- const describe = {
1874
- name: "describe_brush",
1875
- title: "Describe a brush",
1876
- description: `Reads one brush by id: its full facet spec (kind, medium, variety or tool, tip, marks, dynamics, behaviour, light, motion, colour), whether it is built in, the engine and category it resolves to, and check: what this host (${profile.name}) refuses or paints without, each with a fix. Unknown ids are refused with the closest ones. Changes nothing.`,
1877
- inputSchema: { type: "object", properties: { id: ID }, required: ["id"], additionalProperties: false },
1878
- annotations: { title: "Describe a brush", ...READ },
1879
- handler: async ({ id }) => {
1880
- const e = await entry(id);
1881
- const engine = resolveEngine(e.brush);
1882
- return { brush: e.brush, builtin: e.builtin, engine, category: resolveCategory(e.brush, engine), check: checkBrush(e.brush, profile) };
1883
- }
1884
- };
1885
- const check = {
1886
- name: "check_brush",
1887
- title: "Check a brush for a host",
1888
- description: `Checks a brush (by id, or a facet spec not yet saved) against a host profile: ${HOSTS.join(", ")} (default this host, ${profile.id}). Returns ok (no errors), issues (facet, severity error or warning, reason, fix) and, when it is refused, degraded: the brush with the refused facets stripped and what was dropped. Use it before create_brush, or to see whether a brush works in another editor. Changes nothing.`,
1889
- inputSchema: {
1890
- type: "object",
1891
- properties: {
1892
- id: { ...ID, description: "A brush in the library (give id or brush, not both)." },
1893
- brush: { type: "object", description: "A facet brush spec (the create_brush fields, with id and name)." },
1894
- host: { type: "string", enum: HOSTS, description: `The host profile (default ${profile.id}).` }
1895
- },
1896
- additionalProperties: false
1897
- },
1898
- annotations: { title: "Check a brush for a host", ...READ },
1899
- handler: async ({ id, brush, host }) => {
1900
- if (id === void 0 === (brush === void 0)) throw new BrushLibraryError("invalid", "Give either id (a library brush) or brush (a spec), not both or neither.");
1901
- const p = hostOf(host);
1902
- let b;
1903
- const problems = [];
1904
- if (id !== void 0) b = (await entry(id)).brush;
1905
- else {
1906
- try {
1907
- b = normalizeBrushSpec(brush, problems);
1908
- } catch (e) {
1909
- throw new BrushLibraryError("invalid", e.message);
1910
- }
1911
- }
1912
- const result = checkBrush(b, p);
1913
- return { host: p.id, ...result, ...problems.length ? { problems } : {}, ...!result.ok ? { degraded: degrade(b, p) } : {} };
1914
- }
1915
- };
1916
- const exportTool = {
1917
- name: "export_brushes",
1918
- title: "Export brushes",
1919
- description: "Writes a brush library file (format pixerate-brushes, version 2) of the saved brushes, or of the ids given (built-ins included). Returns the file as JSON, for import_brushes in another library. Changes nothing.",
1920
- inputSchema: { type: "object", properties: { ids: { type: "array", minItems: 1, maxItems: 500, uniqueItems: true, items: ID, description: "Only these brushes (default every saved brush)." } }, additionalProperties: false },
1921
- annotations: { title: "Export brushes", ...READ },
1922
- handler: async ({ ids }) => ({ file: await library.exportFile(ids) })
1923
- };
1924
- const tools = [list2, describe, check, exportTool];
1925
- if (options.renderPreview) {
1926
- const render = options.renderPreview;
1927
- tools.push({
1928
- name: "preview_brush",
1929
- title: "Preview a brush",
1930
- description: "Draws a sample S-stroke with a brush (by id) on a plain background and returns it as an image, to see a brush before painting with it or after create_brush / update_brush. Returns the image plus id, width and height. Changes nothing.",
1931
- inputSchema: {
1932
- type: "object",
1933
- properties: {
1934
- id: ID,
1935
- width: { type: "integer", minimum: 32, maximum: 1024, description: "Image width in px (default 256)." },
1936
- height: { type: "integer", minimum: 16, maximum: 512, description: "Image height in px (default 96)." },
1937
- color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: "Paint colour #rrggbb (default the brush's own default colour, else #1f2937)." }
1938
- },
1939
- required: ["id"],
1940
- additionalProperties: false
1941
- },
1942
- annotations: { title: "Preview a brush", ...READ },
1943
- handler: async ({ id, width, height, color }) => {
1944
- const { brush } = await entry(id);
1945
- const own = typeof brush.colour?.default === "string" && /^#[0-9a-f]{6}/i.test(brush.colour.default) ? brush.colour.default.slice(0, 7) : void 0;
1946
- const w = width ?? 256;
1947
- const h = height ?? 96;
1948
- const image = await render(brush, { width: w, height: h, color: color ?? own ?? "#1f2937" });
1949
- return { content: [{ type: "image", data: image.data, mimeType: image.mimeType }], structuredContent: { id, width: w, height: h } };
1950
- }
1951
- });
1952
- }
1953
- if (options.readOnly) return tools;
1954
- tools.push(
1955
- {
1956
- name: "create_brush",
1957
- title: "Save a new brush",
1958
- description: `Saves a new brush made of facets (to save an editor's current brush settings, pass the brush get_brush returns), optionally basedOn an existing brush (its facets are copied and the ones given here laid over them: objects merge, lists and values replace). Values are normalised (out-of-range numbers clamped, unused facets dropped, each reported in problems) and checked against this host (${profile.name}): a brush it can't render is refused with the facet and fix, and nothing is saved. Returns the new id, the stored brush, the engine it resolves to, problems and warnings (settings this host paints without). Start from the nearest preset (list_brush_presets) rather than from nothing.`,
1959
- inputSchema: {
1960
- type: "object",
1961
- properties: {
1962
- id: { ...FACETS2.id, description: "The new brush id: lowercase letters, digits and dashes, not taken (list_brush_presets)." },
1963
- name: FACETS2.name,
1964
- basedOn: { ...ID, description: "Copy this brush's facets first (a built-in preset or a saved brush)." },
1965
- ...FACET_PROPS
1966
- },
1967
- required: ["id", "name"],
1968
- additionalProperties: false
1969
- },
1970
- annotations: { title: "Save a new brush", ...WRITE },
1971
- handler: async ({ basedOn, ...spec }) => {
1972
- if (library.isBuiltin(spec.id)) throw new BrushLibraryError("builtin", `"${spec.id}" is a built-in brush id; pick another id (or basedOn "${spec.id}" with a new id).`);
1973
- if (await library.get(spec.id)) throw new BrushLibraryError("exists", `A brush "${spec.id}" exists already; pick another id, or change it with update_brush.`);
1974
- const base = basedOn !== void 0 ? (await entry(basedOn)).brush : void 0;
1975
- const raw = base ? merge(base, spec) : spec;
1976
- const saved = await store(raw, false);
1977
- return { id: saved.brush.id, brush: saved.brush, engine: resolveEngine(saved.brush), problems: saved.problems, warnings: warnings(saved.check) };
1978
- }
2710
+ function snapshotBrushLibrary(library, onChange) {
2711
+ let entries = [];
2712
+ let user = [];
2713
+ const refresh = () => library.list().then(
2714
+ (list2) => {
2715
+ entries = list2;
2716
+ user = list2.filter((e) => !e.builtin).map((e) => e.brush);
2717
+ onChange?.();
1979
2718
  },
1980
- {
1981
- name: "update_brush",
1982
- title: "Update a brush",
1983
- description: `Changes a saved brush: the facets given are laid over its own (objects merge, lists and values replace; marks of another kind replace the marks), and unset removes whole facets back to their defaults. Built-in presets are refused (duplicate_brush first). Checked against this host like create_brush; a refused change saves nothing. One change. Returns the brush, the leaf paths changed, problems and warnings.`,
1984
- inputSchema: {
1985
- type: "object",
1986
- properties: {
1987
- id: { ...ID, description: "The saved brush to change (not a built-in)." },
1988
- name: FACETS2.name,
1989
- ...FACET_PROPS,
1990
- unset: BRUSH_UNSET_PROPERTY
1991
- },
1992
- required: ["id"],
1993
- additionalProperties: false
1994
- },
1995
- annotations: { title: "Update a brush", ...WRITE, idempotentHint: true },
1996
- handler: async ({ id, unset, ...patch }) => {
1997
- const e = await entry(id);
1998
- if (e.builtin) throw new BrushLibraryError("builtin", `"${id}" is a built-in brush and can't be changed; duplicate_brush makes a copy you can change.`);
1999
- const base = copy5(e.brush);
2000
- for (const k of unset ?? []) delete base[k];
2001
- const saved = await store(merge(base, { ...patch, id }), true);
2002
- return { id, brush: saved.brush, changed: changedPaths(e.brush, saved.brush), problems: saved.problems, warnings: warnings(saved.check) };
2003
- }
2719
+ () => void 0
2720
+ );
2721
+ let ready = refresh();
2722
+ const unsubscribe = library.subscribe(() => {
2723
+ ready = refresh();
2724
+ });
2725
+ return {
2726
+ get entries() {
2727
+ return entries;
2004
2728
  },
2005
- {
2006
- name: "duplicate_brush",
2007
- title: "Duplicate a brush",
2008
- description: "Copies any brush (built-in presets included) to a new saved brush you can change with update_brush. Returns the new id (default <id>-copy, -copy-2\u2026) and the brush.",
2009
- inputSchema: {
2010
- type: "object",
2011
- properties: {
2012
- id: ID,
2013
- newId: { ...FACETS2.id, description: "Id for the copy (default <id>-copy, -copy-2\u2026); must not be taken." },
2014
- name: { ...FACETS2.name, description: 'Name for the copy (default "<name> copy").' }
2015
- },
2016
- required: ["id"],
2017
- additionalProperties: false
2018
- },
2019
- annotations: { title: "Duplicate a brush", ...WRITE },
2020
- handler: async ({ id, newId, name }) => {
2021
- const brush = await library.duplicate(id, { ...newId !== void 0 ? { id: newId } : {}, ...name !== void 0 ? { name } : {} });
2022
- return { id: brush.id, brush };
2023
- }
2729
+ get user() {
2730
+ return user;
2024
2731
  },
2025
- {
2026
- name: "remove_brush",
2027
- title: "Remove a brush",
2028
- description: "Deletes a saved brush from the library for good (export_brushes first to keep a copy). Built-in presets are refused. Returns the id removed.",
2029
- inputSchema: { type: "object", properties: { id: { ...ID, description: "The saved brush to remove (not a built-in)." } }, required: ["id"], additionalProperties: false },
2030
- annotations: { title: "Remove a brush", ...WRITE, destructiveHint: true },
2031
- handler: async ({ id }) => {
2032
- await entry(id);
2033
- await library.remove(id);
2034
- return { removed: id };
2035
- }
2732
+ get ready() {
2733
+ return ready;
2036
2734
  },
2037
- {
2038
- name: "import_brushes",
2039
- title: "Import brushes",
2040
- description: "Adds the brushes of a library file (format pixerate-brushes version 2 from export_brushes, a version 1 file of the paint editor's brushes, which are migrated, or a bare list of brushes). Brushes with a built-in id are skipped, and so are ids already saved unless overwrite. Returns added, replaced and skipped ids and problems (each fix or skip, per brush).",
2041
- inputSchema: {
2042
- type: "object",
2043
- properties: {
2044
- file: { type: ["object", "array"], description: "The library file object, or an array of brushes." },
2045
- overwrite: { type: "boolean", description: "Replace saved brushes with the same id (default false: skip them)." }
2046
- },
2047
- required: ["file"],
2048
- additionalProperties: false
2049
- },
2050
- annotations: { title: "Import brushes", ...WRITE, destructiveHint: true },
2051
- handler: async ({ file, overwrite }) => library.importFile(file, { overwrite: !!overwrite })
2052
- }
2053
- );
2054
- return tools;
2055
- }
2056
- function createBrushLibraryToolProvider(library, options = {}) {
2057
- const profile = options.profile ?? PAINT_PROFILE;
2058
- const tools = createBrushLibraryTools(library, options);
2059
- return {
2060
- id: options.id ?? "brushes",
2061
- namespace: options.namespace,
2062
- title: "Brush library",
2063
- description: `The shared brush library: find, describe, create, change, check and share brushes made of facets, for ${profile.name}.`,
2064
- skill: "pixerate-brushes",
2065
- tools
2066
- };
2067
- }
2068
-
2069
- // src/paint-editor/brush-controls.ts
2070
- var BRUSH_SLIDERS = [
2071
- { path: "tip.size", label: "Size", min: 1, max: 400, step: 1 },
2072
- { path: "tip.opacity", label: "Opacity", min: 0, max: 1, step: 0.01 },
2073
- { path: "tip.flow", label: "Flow", min: 0, max: 1, step: 0.01 },
2074
- { path: "tip.hardness", label: "Hardness", min: 0, max: 1, step: 0.01 },
2075
- { path: "tip.spacing", label: "Spacing", min: 0.01, max: 1, step: 0.01 },
2076
- { path: "tip.roundness", label: "Roundness", min: 0.05, max: 1, step: 0.01 },
2077
- { path: "tip.angle", label: "Angle", min: -180, max: 180, step: 1 },
2078
- { path: "grain", label: "Grain", min: 0, max: 1, step: 0.01 },
2079
- { path: "tip.smoothing", label: "Smoothing", min: 0, max: 0.95, step: 0.01 },
2080
- { path: "pressureSmoothing", label: "Pressure smoothing", min: 0, max: 0.95, step: 0.01 },
2081
- { path: "dynamics.minSize", label: "Min size", min: 0, max: 1, step: 0.01 },
2082
- { path: "dynamics.tiltSize", label: "Tilt size", min: 0, max: 1, step: 0.01 },
2083
- { path: "dynamics.velocitySize", label: "Speed size", min: -1, max: 1, step: 0.01 }
2084
- ];
2085
- var LIGHT_SLIDER = { path: "light.amount", label: "Light", min: 0, max: 1, step: 0.01 };
2086
- var PICKUP_SLIDERS = [
2087
- { path: "behavior.pickup", label: "Pick-up", min: 0, max: 1, step: 0.01 },
2088
- { path: "behavior.mix", label: "Mix", min: 0, max: 1, step: 0.01 }
2089
- ];
2090
- function brushEngineControls(brush) {
2091
- const c = engineControls(brush);
2092
- const wetPaint = brush.engine === "wet" && brush.wet?.tool === "paint";
2093
- if (!c || !(wetPaint || ["dab", "mixer", "particle", "bristle", "impasto"].includes(brush.engine))) return c;
2094
- 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 }] : [];
2095
- return { ...c, sliders: [...c.sliders, LIGHT_SLIDER, ...extra], light: true };
2096
- }
2097
- function engineControls(brush) {
2098
- switch (brush.engine) {
2099
- case "particle":
2100
- return {
2101
- title: "Particles",
2102
- sliders: [
2103
- { path: "particle.count", label: "Particles", min: 1, max: 64, step: 1 },
2104
- { path: "particle.size", label: "Mark size", min: 0, max: 1, step: 0.01 },
2105
- { path: "particle.spread", label: "Spread", min: 0, max: 1, step: 0.01 },
2106
- { path: "particle.stiffness", label: "Stiffness", min: 0, max: 1, step: 0.01 },
2107
- { path: "particle.damping", label: "Damping", min: 0, max: 1, step: 0.01 },
2108
- { path: "particle.speed", label: "Speed", min: 0, max: 1, step: 0.01 },
2109
- { path: "particle.gravity", label: "Gravity", min: -1, max: 1, step: 0.01 },
2110
- { path: "particle.life", label: "Life", min: 0, max: 1, step: 0.01 },
2111
- { path: "particle.interval", label: "Bead spacing", min: 0, max: 1, step: 0.01 },
2112
- { path: "particle.ribbonFill", label: "Ribbon fill", min: 0, max: 1, step: 0.01 },
2113
- { path: "particle.waveAmplitude", label: "Wave", min: 0, max: 1, step: 0.01 },
2114
- { path: "particle.waveLength", label: "Wavelength", min: 4, max: 400, step: 1 },
2115
- { path: "particle.waveSpeed", label: "Wave speed", min: 0, max: 1, step: 0.01 },
2116
- { path: "particle.wavePhaseJitter", label: "Wave scatter", min: 0, max: 1, step: 0.01 }
2117
- ],
2118
- mixingPath: null,
2119
- dirty: false,
2120
- multicolor: false,
2121
- load: false,
2122
- particleMenus: true
2123
- };
2124
- case "bristle":
2125
- return {
2126
- title: "Bristles",
2127
- sliders: [
2128
- { path: "bristle.count", label: "Bristles", min: 3, max: 128, step: 1 },
2129
- { path: "bristle.stiffness", label: "Stiffness", min: 0, max: 1, step: 0.01 },
2130
- { path: "bristle.splay", label: "Splay", min: 0, max: 1, step: 0.01 },
2131
- { path: "bristle.thickness", label: "Bristle width", min: 0, max: 1, step: 0.01 },
2132
- { path: "bristle.load", label: "Load", min: 0, max: 1, step: 0.01 },
2133
- { path: "bristle.jitter", label: "Variation", min: 0, max: 1, step: 0.01 }
2134
- ],
2135
- mixingPath: null,
2136
- dirty: false,
2137
- multicolor: false,
2138
- load: false
2139
- };
2140
- case "sticker":
2141
- return {
2142
- title: "Stickers",
2143
- sliders: [
2144
- { path: "sticker.density", label: "Density", min: 0, max: 1, step: 0.01 },
2145
- { path: "sticker.sizeJitter", label: "Size jitter", min: 0, max: 1, step: 0.01 },
2146
- { path: "sticker.rotationJitter", label: "Turn jitter", min: 0, max: 1, step: 0.01 },
2147
- { path: "sticker.scatter", label: "Scatter", min: 0, max: 2, step: 0.01 },
2148
- { path: "sticker.colorJitter", label: "Colour jitter", min: 0, max: 1, step: 0.01 }
2149
- ],
2150
- mixingPath: null,
2151
- dirty: false,
2152
- multicolor: false,
2153
- load: false,
2154
- stickerStamps: true
2155
- };
2156
- case "impasto":
2157
- return {
2158
- title: "Thick paint",
2159
- sliders: [
2160
- { path: "impasto.thickness", label: "Thickness", min: 0, max: 1, step: 0.01 },
2161
- { path: "impasto.gloss", label: "Gloss", min: 0, max: 1, step: 0.01 },
2162
- { path: "impasto.metallic", label: "Metallic", min: 0, max: 1, step: 0.01 },
2163
- { path: "impasto.load", label: "Load", min: 0, max: 1, step: 0.01 },
2164
- { path: "impasto.thinner", label: "Thinner", min: 0, max: 1, step: 0.01 },
2165
- { path: "impasto.drag", label: "Drag", min: 0, max: 1, step: 0.01 },
2166
- ...PICKUP_SLIDERS
2167
- ],
2168
- mixingPath: "behavior.mixing",
2169
- dirty: true,
2170
- multicolor: false,
2171
- load: true,
2172
- impastoTool: true
2173
- };
2174
- case "wet": {
2175
- const tool = brush.wet?.tool ?? "paint";
2176
- const sliders = tool === "paint" ? [
2177
- { path: "wet.water", label: "Water", min: 0, max: 1, step: 0.01 },
2178
- { path: "wet.pigment", label: "Pigment", min: 0, max: 1, step: 0.01 },
2179
- { path: "wet.body", label: "Body (opacity)", min: 0, max: 1, step: 0.01 },
2180
- { path: "wet.rewet", label: "Re-wet", min: 0, max: 1, step: 0.01 },
2181
- { path: "wet.load", label: "Load", min: 0, max: 1, step: 0.01 },
2182
- { path: "wet.thinner", label: "Thinner", min: 0, max: 1, step: 0.01 },
2183
- ...PICKUP_SLIDERS
2184
- ] : tool === "water" ? [
2185
- { path: "wet.water", label: "Water", min: 0, max: 1, step: 0.01 },
2186
- { path: "wet.rewet", label: "Re-wet", min: 0, max: 1, step: 0.01 }
2187
- ] : [{ path: "wet.strength", label: "Strength", min: 0, max: 1, step: 0.01 }];
2188
- return { title: "Wet brush", sliders, mixingPath: tool === "paint" ? "behavior.mixing" : null, dirty: tool === "paint", multicolor: false, load: tool === "paint", wetTool: true };
2189
- }
2190
- case "blender":
2191
- return {
2192
- title: "Blender",
2193
- sliders: [
2194
- { path: "blender.strength", label: "Strength", min: 0, max: 1, step: 0.01 },
2195
- { path: "blender.distance", label: "Blend distance", min: 0, max: 1, step: 0.01 }
2196
- ],
2197
- mixingPath: "blender.mixing",
2198
- dirty: false,
2199
- multicolor: false,
2200
- load: false
2201
- };
2202
- case "smudge":
2203
- return {
2204
- title: "Smudge",
2205
- sliders: [
2206
- { path: "smudge.strength", label: "Strength", min: 0, max: 1, step: 0.01 },
2207
- { path: "smudge.length", label: "Length", min: 0, max: 1, step: 0.01 }
2208
- ],
2209
- mixingPath: "smudge.mixing",
2210
- dirty: false,
2211
- multicolor: false,
2212
- load: false
2213
- };
2214
- case "mixer":
2215
- 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 };
2216
- case "dab":
2217
- return { title: "Paint behaviour", sliders: PICKUP_SLIDERS, mixingPath: "behavior.mixing", dirty: true, multicolor: true, load: true };
2218
- default:
2219
- return null;
2220
- }
2221
- }
2222
- function multiColorSliders(mode) {
2223
- if (mode === "off") return [];
2224
- const soft = { path: "behavior.multicolor.softness", label: "Colour softness", min: 0, max: 1, step: 0.01 };
2225
- return mode === "dab" ? [soft, { path: "behavior.multicolor.length", label: "Dabs per colour", min: 1, max: 100, step: 1 }] : [soft];
2226
- }
2227
- function patchBrushValue(brush, path, value) {
2228
- const set = (obj, keys) => {
2229
- const [head2, ...rest2] = keys;
2230
- if (!rest2.length) return { ...obj, [head2]: value };
2231
- return { ...obj, [head2]: set(obj[head2] ?? {}, rest2) };
2735
+ refresh: () => ready = refresh(),
2736
+ dispose: unsubscribe
2232
2737
  };
2233
- const [head, ...rest] = path.split(".");
2234
- if (!rest.length) return { [head]: value };
2235
- return { [head]: set({ ...brush[head] ?? {} }, rest) };
2236
2738
  }
2237
2739
 
2238
2740
  export {
2741
+ MAX_STICKER_STAMPS,
2742
+ DEFAULT_STICKER,
2743
+ normalizeStickerSettings,
2744
+ PARTICLE_KINDS,
2745
+ PARTICLE_RENDERS,
2746
+ RIBBON_PAIRS,
2747
+ DOT_TEXTURE_ORDERS,
2748
+ DOT_ROTATIONS,
2749
+ MAX_DOT_TEXTURES,
2750
+ DEFAULT_PARTICLE,
2751
+ normalizeParticle,
2752
+ DEFAULT_BRISTLE,
2753
+ normalizeBristle,
2754
+ PAINT_BRUSH_ENGINES,
2755
+ PAINT_BRUSH_CATEGORIES,
2756
+ WET_TOOLS,
2757
+ WET_MEDIA,
2758
+ DEFAULT_WET_BRUSH,
2759
+ normalizeWetBrush,
2760
+ IMPASTO_TOOLS,
2761
+ DEFAULT_IMPASTO,
2762
+ DEFAULT_LIGHT,
2763
+ normalizeLight,
2764
+ normalizeImpasto,
2765
+ MULTI_COLOR_MODES,
2766
+ DEFAULT_MULTI_COLOR,
2767
+ DEFAULT_BEHAVIOR,
2768
+ BRUSH_ROTATIONS,
2769
+ DEFAULT_DYNAMICS,
2770
+ normalizeDynamics,
2771
+ BRUSH_ID,
2772
+ normalizeMultiColor,
2773
+ normalizeBehavior,
2774
+ isWetCapable,
2775
+ isWetBrush,
2776
+ usesPaintExchange,
2777
+ brushTakesPaint,
2778
+ paintToMultiColor,
2779
+ multiColorToPaint,
2780
+ normalizePaintBrush,
2781
+ BUILTIN_BRUSHES,
2782
+ DEFAULT_BRUSH_ID,
2783
+ brushToJSON,
2784
+ parseBrushLibrary,
2239
2785
  BRUSH_FIELDS,
2240
2786
  getPath,
2241
2787
  setPath,
@@ -2267,18 +2813,8 @@ export {
2267
2813
  listPresets,
2268
2814
  degrade,
2269
2815
  BrushLibraryError,
2270
- parseBrushLibrary,
2816
+ parseBrushLibrary2,
2271
2817
  createBrushLibrary,
2272
2818
  closest,
2273
- BRUSH_PATCH_PROPERTIES,
2274
- UNSETTABLE_BRUSH_FACETS,
2275
- BRUSH_UNSET_PROPERTY,
2276
- mergeBrushPatch,
2277
- changedBrushPaths,
2278
- createBrushLibraryTools,
2279
- createBrushLibraryToolProvider,
2280
- BRUSH_SLIDERS,
2281
- brushEngineControls,
2282
- multiColorSliders,
2283
- patchBrushValue
2819
+ snapshotBrushLibrary
2284
2820
  };