@pixerate/editor 0.266.2 → 0.268.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 (149) hide show
  1. package/dist/agent/index.cjs +2 -2
  2. package/dist/agent/index.js +1 -1
  3. package/dist/animation-editor/controller.d.cts +8 -0
  4. package/dist/animation-editor/controller.d.ts +8 -0
  5. package/dist/animation-editor/effect-tools.d.cts +1 -1
  6. package/dist/animation-editor/effect-tools.d.ts +1 -1
  7. package/dist/animation-editor/index.cjs +175 -108
  8. package/dist/animation-editor/index.js +94 -27
  9. package/dist/brushes/index.cjs +201 -75
  10. package/dist/brushes/index.d.cts +4 -3
  11. package/dist/brushes/index.d.ts +4 -3
  12. package/dist/brushes/index.js +138 -12
  13. package/dist/brushes/library.d.cts +27 -1
  14. package/dist/brushes/library.d.ts +27 -1
  15. package/dist/brushes/profiles.d.cts +10 -1
  16. package/dist/brushes/profiles.d.ts +10 -1
  17. package/dist/brushes/schema.d.cts +4 -0
  18. package/dist/brushes/schema.d.ts +4 -0
  19. package/dist/brushes/sources.d.cts +114 -0
  20. package/dist/brushes/sources.d.ts +114 -0
  21. package/dist/brushes/types.d.cts +37 -5
  22. package/dist/brushes/types.d.ts +37 -5
  23. package/dist/brushes/ui.d.cts +40 -4
  24. package/dist/brushes/ui.d.ts +40 -4
  25. package/dist/{brushstrokeRender-KD453OBX.js → brushstrokeRender-A727TTNQ.js} +7 -7
  26. package/dist/{brushstrokeRender-FSUWXUZY.cjs → brushstrokeRender-WR2N5HHY.cjs} +23 -23
  27. package/dist/canvas/brushTypes.d.cts +12 -0
  28. package/dist/canvas/brushTypes.d.ts +12 -0
  29. package/dist/canvas/brushstroke.d.cts +1 -1
  30. package/dist/canvas/brushstroke.d.ts +1 -1
  31. package/dist/canvas/drawController.d.cts +19 -4
  32. package/dist/canvas/drawController.d.ts +19 -4
  33. package/dist/canvas/index.cjs +144 -105
  34. package/dist/canvas/index.js +46 -7
  35. package/dist/{chunk-TOID45I2.cjs → chunk-2WOGXEDX.cjs} +1 -1
  36. package/dist/{chunk-XORJ7UZO.cjs → chunk-3VHH6V67.cjs} +13 -12
  37. package/dist/{chunk-3C33YTNN.js → chunk-3XDSNIGF.js} +73 -6
  38. package/dist/chunk-62MFUHSI.cjs +802 -0
  39. package/dist/{chunk-R44Y3EFS.js → chunk-74ODPZLX.js} +17 -345
  40. package/dist/{chunk-GCVFEPJG.js → chunk-ACTHSQOI.js} +182 -0
  41. package/dist/chunk-ADMRNWR7.js +340 -0
  42. package/dist/{chunk-MVUQSK5J.cjs → chunk-B3G7BKUV.cjs} +111 -439
  43. package/dist/{chunk-DXYHQ2VN.js → chunk-BAZMCFVL.js} +1 -1
  44. package/dist/{chunk-VRK322ZC.cjs → chunk-BPJSI4NT.cjs} +2 -2
  45. package/dist/{chunk-RFJZGTHA.cjs → chunk-EU2ZZA6L.cjs} +400 -69
  46. package/dist/{chunk-XC6DPRYI.js → chunk-GSTSZPSR.js} +154 -949
  47. package/dist/{chunk-EZOJBP4D.js → chunk-HFY5LG5J.js} +1 -1
  48. package/dist/chunk-HVO6XCI4.js +802 -0
  49. package/dist/{chunk-YHDHFJUC.cjs → chunk-J5VQYK7T.cjs} +217 -1012
  50. package/dist/{chunk-XRXFVRGY.cjs → chunk-JBB3GDBB.cjs} +2483 -208
  51. package/dist/{chunk-ACFSFUQH.js → chunk-JL6YH5WD.js} +4 -0
  52. package/dist/{chunk-CIJBSUM5.js → chunk-KZI3YWKW.js} +353 -22
  53. package/dist/{chunk-4ODRXQRW.js → chunk-LETF75CA.js} +1 -1
  54. package/dist/{chunk-FQPUF2W2.cjs → chunk-NIBFKXMD.cjs} +217 -35
  55. package/dist/{chunk-AQZQWXEU.cjs → chunk-NPWWSCWT.cjs} +35 -35
  56. package/dist/{chunk-DOZ6F2K2.js → chunk-NSETJI74.js} +3 -2
  57. package/dist/{chunk-PE6YJMQT.cjs → chunk-PFHJS4HE.cjs} +20 -19
  58. package/dist/{chunk-7KNW3WXZ.cjs → chunk-QEVLRLUN.cjs} +63 -0
  59. package/dist/{chunk-LK4SXL3V.js → chunk-QNY7BDFP.js} +1 -1
  60. package/dist/{chunk-OTN6VB5S.cjs → chunk-RDFHWAHL.cjs} +5 -1
  61. package/dist/{chunk-PXDIAYB6.cjs → chunk-STLTOISW.cjs} +34 -214
  62. package/dist/{chunk-T2LDTEXK.js → chunk-UVTO3D4E.js} +2333 -58
  63. package/dist/{chunk-6JL2D5SG.js → chunk-VE6UKYQ2.js} +3 -2
  64. package/dist/{chunk-TXFWZ5ZJ.js → chunk-W6JDUXP3.js} +4 -184
  65. package/dist/{chunk-3SBLVGOM.cjs → chunk-YOVXQHA3.cjs} +23 -23
  66. package/dist/{chunk-WLUVZKVD.cjs → chunk-YXP6X3SY.cjs} +103 -36
  67. package/dist/chunk-YZWM6ZIP.cjs +340 -0
  68. package/dist/{chunk-472INMWH.js → chunk-ZA7L5YJB.js} +63 -0
  69. package/dist/commands/builtins.d.cts +12 -1
  70. package/dist/commands/builtins.d.ts +12 -1
  71. package/dist/commands/index.cjs +2 -2
  72. package/dist/commands/index.js +1 -1
  73. package/dist/image-editor/controller.d.cts +1 -1
  74. package/dist/image-editor/controller.d.ts +1 -1
  75. package/dist/image-editor/index.cjs +868 -864
  76. package/dist/image-editor/index.js +123 -119
  77. package/dist/paint/index.cjs +8 -6
  78. package/dist/paint/index.js +56 -54
  79. package/dist/paint-editor/brush-library-tools.d.cts +12 -2
  80. package/dist/paint-editor/brush-library-tools.d.ts +12 -2
  81. package/dist/paint-editor/brush-model.d.cts +29 -1
  82. package/dist/paint-editor/brush-model.d.ts +29 -1
  83. package/dist/paint-editor/brush-source-art.d.cts +32 -0
  84. package/dist/paint-editor/brush-source-art.d.ts +32 -0
  85. package/dist/paint-editor/controller.d.cts +11 -4
  86. package/dist/paint-editor/controller.d.ts +11 -4
  87. package/dist/paint-editor/dab-stroke.d.cts +2 -0
  88. package/dist/paint-editor/dab-stroke.d.ts +2 -0
  89. package/dist/paint-editor/engine.d.cts +11 -2
  90. package/dist/paint-editor/engine.d.ts +11 -2
  91. package/dist/paint-editor/index.cjs +967 -2335
  92. package/dist/paint-editor/index.d.cts +3 -1
  93. package/dist/paint-editor/index.d.ts +3 -1
  94. package/dist/paint-editor/index.js +394 -1762
  95. package/dist/paint-editor/path-samples.d.cts +2 -0
  96. package/dist/paint-editor/path-samples.d.ts +2 -0
  97. package/dist/paint-editor/presence.d.cts +20 -1
  98. package/dist/paint-editor/presence.d.ts +20 -1
  99. package/dist/paint-editor/stamp-tip.d.cts +8 -5
  100. package/dist/paint-editor/stamp-tip.d.ts +8 -5
  101. package/dist/paint-editor/thumbnails.d.cts +6 -0
  102. package/dist/paint-editor/thumbnails.d.ts +6 -0
  103. package/dist/paint-editor/tip-grain.d.cts +34 -0
  104. package/dist/paint-editor/tip-grain.d.ts +34 -0
  105. package/dist/raster/index.cjs +2 -2
  106. package/dist/raster/index.js +9 -9
  107. package/dist/stickers/helpers.d.cts +2 -0
  108. package/dist/stickers/helpers.d.ts +2 -0
  109. package/dist/stickers/index.cjs +41 -35
  110. package/dist/stickers/index.js +22 -16
  111. package/dist/stickers/types.d.cts +4 -2
  112. package/dist/stickers/types.d.ts +4 -2
  113. package/dist/vector/backdrop.d.cts +13 -0
  114. package/dist/vector/backdrop.d.ts +13 -0
  115. package/dist/vector/brushstroke.d.cts +54 -7
  116. package/dist/vector/brushstroke.d.ts +54 -7
  117. package/dist/vector/brushstrokeMedia.d.cts +96 -0
  118. package/dist/vector/brushstrokeMedia.d.ts +96 -0
  119. package/dist/vector/controller.d.cts +17 -3
  120. package/dist/vector/controller.d.ts +17 -3
  121. package/dist/vector/index.cjs +1850 -1390
  122. package/dist/vector/index.d.cts +2 -0
  123. package/dist/vector/index.d.ts +2 -0
  124. package/dist/vector/index.js +520 -60
  125. package/dist/vector/render.d.cts +6 -0
  126. package/dist/vector/render.d.ts +6 -0
  127. package/dist/vector/types.d.cts +14 -0
  128. package/dist/vector/types.d.ts +14 -0
  129. package/package.json +1 -1
  130. package/skills/pixerate-animation-editor/SKILL.md +1 -0
  131. package/skills/pixerate-brushes/SKILL.md +37 -10
  132. package/skills/pixerate-brushes-design/SKILL.md +15 -5
  133. package/skills/pixerate-brushes-design/evals/port-to-vector.json +8 -8
  134. package/skills/pixerate-canvas/SKILL.md +11 -1
  135. package/skills/pixerate-image-editor/SKILL.md +19 -64
  136. package/skills/pixerate-image-editor/references/color-management.md +2 -1
  137. package/skills/pixerate-image-editor/references/free-transform.md +12 -0
  138. package/skills/pixerate-image-editor/references/layer-effects.md +10 -0
  139. package/skills/pixerate-image-editor/references/painting.md +17 -0
  140. package/skills/pixerate-image-editor/references/photoshop-files.md +1 -1
  141. package/skills/pixerate-image-editor/references/shapes-and-type.md +14 -0
  142. package/skills/pixerate-image-editor/references/smart-objects.md +10 -0
  143. package/skills/pixerate-paint-editor/SKILL.md +14 -4
  144. package/skills/pixerate-paint-editor/references/brush-facets.md +3 -1
  145. package/skills/pixerate-paint-editor/references/wet-media.md +9 -0
  146. package/skills/pixerate-paint-sketch-to-finish/SKILL.md +1 -0
  147. package/skills/pixerate-vector/SKILL.md +16 -6
  148. package/skills/pixerate-vector/references/best-practices.md +1 -0
  149. package/skills/pixerate-vector/references/commands.md +19 -0
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  StickerBrowser,
3
3
  editorStickers
4
- } from "../chunk-DOZ6F2K2.js";
4
+ } from "../chunk-NSETJI74.js";
5
5
  import {
6
6
  BRUSH_PATCH_PROPERTIES,
7
7
  BRUSH_SLIDERS,
@@ -11,15 +11,15 @@ import {
11
11
  mergeBrushPatch,
12
12
  multiColorSliders,
13
13
  patchBrushValue
14
- } from "../chunk-3C33YTNN.js";
14
+ } from "../chunk-3XDSNIGF.js";
15
15
  import {
16
16
  createStickerTools,
17
17
  describeStickerRef
18
- } from "../chunk-6JL2D5SG.js";
18
+ } from "../chunk-VE6UKYQ2.js";
19
19
  import {
20
20
  BRUSH_PREVIEW_DARK_PAPER,
21
21
  brushPreviewsOnDark
22
- } from "../chunk-4ODRXQRW.js";
22
+ } from "../chunk-LETF75CA.js";
23
23
  import {
24
24
  toVideoFfmpeg
25
25
  } from "../chunk-MWUZCEFT.js";
@@ -34,7 +34,8 @@ import {
34
34
  resolveExternalAssets,
35
35
  serializeLayers,
36
36
  serializeLayersExternal
37
- } from "../chunk-R44Y3EFS.js";
37
+ } from "../chunk-74ODPZLX.js";
38
+ import "../chunk-ADMRNWR7.js";
38
39
  import "../chunk-WJ4WQJCC.js";
39
40
  import {
40
41
  LAYER_EFFECT_KINDS,
@@ -57,30 +58,9 @@ import {
57
58
  import "../chunk-OHKOFKBY.js";
58
59
  import "../chunk-ZIF4UJJ6.js";
59
60
  import {
60
- DEFAULT_FLOW,
61
- DEFAULT_WET_PARAMS,
62
- FLAT,
63
- MAX_WET_LANES,
64
- PIGMENT_UNIT,
65
- SRGB_TO_LINEAR,
66
- WET_FLOW_KINDS,
67
- WET_PARAM_KEYS,
68
- WET_TILE,
69
- WetMedium,
70
- WetWorld,
71
61
  createPigmentTools,
72
- createWetGpuEngine,
73
- findWebGpu,
74
- flowField,
75
- linearToSrgb8,
76
- moveWetSnapshot,
77
- normalizeFlow,
78
- normalizeTilt,
79
- normalizeWetParams,
80
- pigmentOf,
81
- pointerPressure,
82
- wetSupport
83
- } from "../chunk-XC6DPRYI.js";
62
+ pointerPressure
63
+ } from "../chunk-HVO6XCI4.js";
84
64
  import "../chunk-VWEWY4FY.js";
85
65
  import "../chunk-HVQVHUTV.js";
86
66
  import {
@@ -92,13 +72,55 @@ import {
92
72
  realPresenceClock,
93
73
  tweenPresenceCursor
94
74
  } from "../chunk-IVSWRVB4.js";
95
- import "../chunk-T2LDTEXK.js";
75
+ import {
76
+ DEFAULT_PAPER,
77
+ DryStroke,
78
+ EFFECT_MAP_REPEATS,
79
+ EFFECT_STRIP_HEIGHT,
80
+ GPU_LEAD,
81
+ IDENTITY_COPY,
82
+ PAPER_FAMILIES,
83
+ PAPER_LIBRARY,
84
+ PaintDabStroke,
85
+ PaintWetLayers,
86
+ PenDabWalker,
87
+ PenSmoother,
88
+ TipGrain,
89
+ WET_GPU_MODES,
90
+ WET_MEDIUM_PARAMS,
91
+ WetStrokeDriver,
92
+ brushStickerRefs,
93
+ clonePaper,
94
+ effectDabField,
95
+ effectPaintKey,
96
+ effectPaintSize,
97
+ hexToRgb8,
98
+ isPaintEffectPaint,
99
+ mixerTip,
100
+ multiColorForDab,
101
+ multiColorGradient,
102
+ multiColorPalette,
103
+ normalizePaper,
104
+ paperPreset,
105
+ penOrientation,
106
+ readEffectStrokeMap,
107
+ readPaintEffectPaint,
108
+ renderPaper,
109
+ sampleEffectAt,
110
+ sampleEffectField,
111
+ stampSampler,
112
+ tiltToOrientation,
113
+ toStrokePoint,
114
+ wetPaperInput,
115
+ wetPigmentOf
116
+ } from "../chunk-UVTO3D4E.js";
96
117
  import {
97
118
  BRUSH_ID,
98
119
  BRUSH_PRESETS,
99
120
  BRUSH_ROTATIONS,
100
121
  BUILTIN_BRUSHES,
101
122
  BrushLibraryError,
123
+ DAB_FLIPS,
102
124
  DEFAULT_BEHAVIOR,
103
125
  DEFAULT_BRISTLE,
104
126
  DEFAULT_BRUSH_ID,
@@ -109,6 +131,7 @@ import {
109
131
  DEFAULT_PARTICLE,
110
132
  DEFAULT_SPRAY,
111
133
  DEFAULT_STICKER,
134
+ DEFAULT_TIP_GRAIN,
112
135
  DEFAULT_WET_BRUSH,
113
136
  DOT_ROTATIONS,
114
137
  DOT_TEXTURE_ORDERS,
@@ -122,12 +145,17 @@ import {
122
145
  PARTICLE_KINDS,
123
146
  PARTICLE_RENDERS,
124
147
  RIBBON_PAIRS,
148
+ TIP_GRAIN_MODES,
125
149
  WET_MEDIA,
126
150
  WET_TOOLS,
151
+ brushSourceIdOf,
152
+ brushSourceIds,
127
153
  brushTakesPaint,
128
154
  brushToJSON,
129
155
  closest,
130
156
  createBrushLibrary,
157
+ decodeBrushSource,
158
+ isBrushSourceRef,
131
159
  isWetBrush,
132
160
  isWetCapable,
133
161
  migrateBrushV1,
@@ -145,9 +173,10 @@ import {
145
173
  normalizeWetBrush,
146
174
  paintToMultiColor,
147
175
  parseBrushLibrary,
176
+ readBrushSource,
148
177
  resolveBrush,
149
178
  usesPaintExchange
150
- } from "../chunk-CIJBSUM5.js";
179
+ } from "../chunk-KZI3YWKW.js";
151
180
  import {
152
181
  STAMP_KINDS,
153
182
  StickersSlot,
@@ -156,7 +185,7 @@ import {
156
185
  stampCoverage,
157
186
  stickerIdFor,
158
187
  stickerVariantFor
159
- } from "../chunk-ACFSFUQH.js";
188
+ } from "../chunk-JL6YH5WD.js";
160
189
  import "../chunk-M6DNZNVY.js";
161
190
  import {
162
191
  bezierPathToSvg,
@@ -181,17 +210,28 @@ import {
181
210
  } from "../chunk-MV7CYFMJ.js";
182
211
  import "../chunk-V2ZCJRDM.js";
183
212
  import {
184
- GRAIN_PATTERNS,
185
- GRAIN_TILE,
186
- PaperGrain,
187
- grainCatch,
188
- grainHeights
189
- } from "../chunk-TXFWZ5ZJ.js";
213
+ DEFAULT_FLOW,
214
+ DEFAULT_WET_PARAMS,
215
+ MAX_WET_LANES,
216
+ SRGB_TO_LINEAR,
217
+ WET_FLOW_KINDS,
218
+ WET_PARAM_KEYS,
219
+ WetWorld,
220
+ linearToSrgb8,
221
+ normalizeFlow,
222
+ normalizeTilt,
223
+ normalizeWetParams,
224
+ pigmentOf,
225
+ wetSupport
226
+ } from "../chunk-GSTSZPSR.js";
227
+ import "../chunk-W6JDUXP3.js";
190
228
  import {
191
229
  CoverageMap,
192
230
  DEFAULT_PAINT_GRADIENT,
193
231
  DEFAULT_PAINT_GRADIENT_MAPPING,
194
232
  EMPTY_MIXER_LOAD,
233
+ GRAIN_PATTERNS,
234
+ GRAIN_TILE,
195
235
  MAX_BRUSH_SIZE,
196
236
  PAINT_COLOR_JSON_SCHEMA,
197
237
  PAINT_GRADIENT_FULL_SPEED,
@@ -208,20 +248,20 @@ import {
208
248
  PaintExchange,
209
249
  PaintGradientSampler,
210
250
  PaintSourceError,
251
+ PaperGrain,
211
252
  SmudgeTip,
212
253
  TIP_GRADIENT_STEPS,
213
254
  TileMedium,
214
255
  addPaintGradientStop,
215
256
  applyStrokeToTile,
216
- blenderDab,
217
257
  cleanMixer,
218
- createBlenderScratch,
219
258
  dabRect,
220
259
  exchangeRates,
260
+ grainCatch,
261
+ grainHeights,
221
262
  hexToPaintRgb,
222
263
  isGradientPaint,
223
264
  loadMixer,
224
- mixerRates,
225
265
  normalizeHexColor,
226
266
  paintGradientMappingFor,
227
267
  paintSourceColor,
@@ -235,7 +275,7 @@ import {
235
275
  scalePaintGradient,
236
276
  solidPaint,
237
277
  updatePaintGradientStop
238
- } from "../chunk-GCVFEPJG.js";
278
+ } from "../chunk-ACTHSQOI.js";
239
279
  import {
240
280
  TileableNoise,
241
281
  hash2,
@@ -245,6 +285,14 @@ import {
245
285
  seedFromString
246
286
  } from "../chunk-4D6KP5BV.js";
247
287
  import "../chunk-ODBT55Q7.js";
288
+ import {
289
+ BLEND_MODES,
290
+ PAINTERLY_BLEND_MODES,
291
+ isBlendMode,
292
+ isPainterlyBlendMode,
293
+ mixPigments,
294
+ standardBlendMode
295
+ } from "../chunk-IRSTHLQQ.js";
248
296
  import {
249
297
  base64ToBytes,
250
298
  bytesToBase64,
@@ -254,15 +302,6 @@ import {
254
302
  parseDataUrl,
255
303
  pngDataUrl
256
304
  } from "../chunk-OTTT5N3K.js";
257
- import {
258
- BLEND_MODES,
259
- PAINTERLY_BLEND_MODES,
260
- isBlendMode,
261
- isPainterlyBlendMode,
262
- mixPigment8,
263
- mixPigments,
264
- standardBlendMode
265
- } from "../chunk-IRSTHLQQ.js";
266
305
  import {
267
306
  TILE,
268
307
  TiledSurface,
@@ -288,985 +327,61 @@ import {
288
327
  defineEditorDocument,
289
328
  toEditorDocumentFile
290
329
  } from "../chunk-XBQX76CT.js";
291
- import "../chunk-HF5QZS7Z.js";
292
- import "../chunk-M2TPMYJE.js";
293
- import "../chunk-2WPNAGHI.js";
294
- import {
295
- HARMONY_KINDS,
296
- colorHarmony,
297
- hueToWheel,
298
- wheelToHue
299
- } from "../chunk-2EUW2XZX.js";
300
- import "../chunk-E2V5DA3W.js";
301
- import "../chunk-VR6OGHU5.js";
302
- import "../chunk-JC2SHA4A.js";
303
- import {
304
- linearToSrgb,
305
- oklabToOklch,
306
- oklabToRgb,
307
- oklchToOklab,
308
- rgbToOklab,
309
- srgbToLinear
310
- } from "../chunk-MHLU6GRJ.js";
311
- import "../chunk-6ONMQ6WF.js";
312
-
313
- // src/paint-editor/paper.ts
314
- var PAPER_FAMILIES = ["watercolor", "drawing", "pastel", "canvas", "panel", "smooth"];
315
- var unit = (v, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d;
316
- var clampNum = (v, lo, hi, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : d;
317
- var HEX = /^#[0-9a-f]{6}$/i;
318
- var tuning = (contrast = 1, invert = false) => ({ contrast, invert });
319
- function preset(id, name, family, pattern, color, o) {
320
- return {
321
- preset: id,
322
- name,
323
- family,
324
- pattern,
325
- seed: o.seed ?? 1,
326
- color,
327
- scale: o.scale ?? 1,
328
- tiling: "mirror",
329
- depth: o.depth,
330
- shading: o.shading ?? 0.5,
331
- dry: tuning(o.contrast ?? 1),
332
- wet: tuning(1),
333
- absorption: o.absorption,
334
- permeability: o.permeability,
335
- roughness: o.roughness,
336
- ...o.backlight ? { backlight: o.backlight, backlightColor: o.backlightColor ?? "#fff1d6" } : {}
337
- };
338
- }
339
- var PAPER_LIBRARY = [
340
- preset("cold-pressed", "Cold pressed watercolour", "watercolor", "cold-pressed", "#f7f3ea", { depth: 0.5, absorption: 0.7, permeability: 0.5, roughness: 0.6 }),
341
- preset("hot-pressed", "Hot pressed watercolour", "watercolor", "hot-pressed", "#f8f6ef", { depth: 0.25, absorption: 0.55, permeability: 0.4, roughness: 0.25 }),
342
- preset("rough-watercolor", "Rough watercolour", "watercolor", "rough", "#f5f0e4", { depth: 0.75, absorption: 0.8, permeability: 0.6, roughness: 0.85 }),
343
- preset("cartridge", "Cartridge", "drawing", "vellum", "#fbfaf6", { depth: 0.3, absorption: 0.4, permeability: 0.3, roughness: 0.35 }),
344
- preset("laid", "Laid paper", "drawing", "laid", "#f3ecdc", { depth: 0.45, absorption: 0.45, permeability: 0.35, roughness: 0.45 }),
345
- preset("ingres", "Charcoal paper", "drawing", "ingres", "#ede6d6", { depth: 0.6, absorption: 0.4, permeability: 0.3, roughness: 0.65 }),
346
- preset("sanded-pastel", "Sanded pastel paper", "pastel", "sanded", "#d8cfc0", { depth: 0.7, absorption: 0.3, permeability: 0.2, roughness: 0.9, shading: 0.35 }),
347
- preset("pastel-card", "Grey pastel card", "pastel", "sanded", "#9c978e", { depth: 0.55, absorption: 0.3, permeability: 0.2, roughness: 0.75, scale: 1.5, shading: 0.3 }),
348
- preset("cotton-canvas", "Cotton canvas", "canvas", "canvas", "#efe9dc", { depth: 0.55, absorption: 0.35, permeability: 0.25, roughness: 0.6 }),
349
- preset("fine-linen", "Fine linen", "canvas", "linen", "#e9e2d2", { depth: 0.45, absorption: 0.3, permeability: 0.25, roughness: 0.5 }),
350
- preset("gesso", "Gesso panel", "panel", "gesso", "#f6f4ef", { depth: 0.35, absorption: 0.2, permeability: 0.15, roughness: 0.4 }),
351
- preset("smooth", "Smooth bristol", "smooth", "smooth", "#ffffff", { depth: 0, absorption: 0.3, permeability: 0.2, roughness: 0.05, shading: 0 }),
352
- // Backlit: smooth paper on a lightbox, for glazes that glow like stained glass.
353
- preset("stained-glass", "Stained glass (backlit)", "smooth", "smooth", "#ffffff", { depth: 0, absorption: 0.35, permeability: 0.25, roughness: 0.05, shading: 0, backlight: 0.8, backlightColor: "#fff1d6" })
354
- ];
355
- var DEFAULT_PAPER = PAPER_LIBRARY[0];
356
- function paperPreset(id) {
357
- const p = PAPER_LIBRARY.find((x) => x.preset === id);
358
- return p ? clonePaper(p) : void 0;
359
- }
360
- function clonePaper(p) {
361
- return { ...p, dry: { ...p.dry }, wet: { ...p.wet }, ...p.offset ? { offset: { ...p.offset } } : {} };
362
- }
363
- function readOffset(raw, d) {
364
- const o = raw && typeof raw === "object" ? raw : null;
365
- const x = o && typeof o.x === "number" && Number.isFinite(o.x) ? Math.round(o.x) : d?.x ?? 0;
366
- const y = o && typeof o.y === "number" && Number.isFinite(o.y) ? Math.round(o.y) : d?.y ?? 0;
367
- return x || y ? { offset: { x, y } } : {};
368
- }
369
- function readTuning(raw, d) {
370
- const r = raw && typeof raw === "object" ? raw : {};
371
- return { contrast: clampNum(r.contrast, 0, 3, d.contrast), invert: typeof r.invert === "boolean" ? r.invert : d.invert };
372
- }
373
- function normalizePaper(raw = {}, base) {
374
- const r = raw;
375
- const b = base ?? (typeof r.preset === "string" ? paperPreset(r.preset) : void 0) ?? clonePaper(DEFAULT_PAPER);
376
- return {
377
- preset: typeof r.preset === "string" && r.preset ? r.preset : b.preset,
378
- name: typeof r.name === "string" && r.name.trim() ? r.name.trim() : b.name,
379
- family: PAPER_FAMILIES.includes(r.family) ? r.family : b.family,
380
- pattern: GRAIN_PATTERNS.includes(r.pattern) ? r.pattern : b.pattern,
381
- seed: typeof r.seed === "number" && Number.isFinite(r.seed) ? Math.round(Math.abs(r.seed)) % 2147483647 : b.seed,
382
- color: typeof r.color === "string" && HEX.test(r.color) ? r.color.toLowerCase() : b.color,
383
- scale: clampNum(r.scale, 0.25, 8, b.scale),
384
- tiling: r.tiling === "repeat" || r.tiling === "mirror" ? r.tiling : b.tiling,
385
- depth: unit(r.depth, b.depth),
386
- shading: unit(r.shading, b.shading),
387
- dry: readTuning(r.dry, b.dry),
388
- wet: readTuning(r.wet, b.wet),
389
- absorption: unit(r.absorption, b.absorption),
390
- permeability: unit(r.permeability, b.permeability),
391
- roughness: unit(r.roughness, b.roughness),
392
- ...unit(r.metallic, b.metallic ?? 0) > 0 ? { metallic: unit(r.metallic, b.metallic ?? 0) } : {},
393
- ...unit(r.backlight, b.backlight ?? 0) > 0 ? { backlight: unit(r.backlight, b.backlight ?? 0) } : {},
394
- ...typeof r.backlightColor === "string" && HEX.test(r.backlightColor) ? { backlightColor: r.backlightColor.toLowerCase() } : b.backlightColor ? { backlightColor: b.backlightColor } : {},
395
- ...readOffset(r.offset, b.offset)
396
- };
397
- }
398
- function hexToRgb8(hex2) {
399
- const n = parseInt(hex2.slice(1, 7), 16);
400
- return [n >> 16 & 255, n >> 8 & 255, n & 255];
401
- }
402
- function renderPaper(paper, rect, grain = new PaperGrain(paper), light) {
403
- const [r, g, b] = hexToRgb8(paper.color);
404
- const out = new Uint8ClampedArray(rect.width * rect.height * 4);
405
- const shade = grain.flat ? 0 : paper.shading * 0.16;
406
- const metal = light ? paper.metallic ?? 0 : 0;
407
- let hx = 0;
408
- let hy = 0;
409
- let hz = 1;
410
- if (metal > 0 && light) {
411
- const az = light.azimuth * Math.PI / 180;
412
- const el = light.elevation * Math.PI / 180;
413
- const lx = Math.cos(el) * Math.sin(az);
414
- const ly = -Math.cos(el) * Math.cos(az);
415
- const lz = Math.sin(el) + 1;
416
- const n = Math.hypot(lx, ly, lz);
417
- hx = lx / n;
418
- hy = ly / n;
419
- hz = lz / n;
420
- }
421
- for (let y = 0; y < rect.height; y++) {
422
- for (let x = 0; x < rect.width; x++) {
423
- const i = (y * rect.width + x) * 4;
424
- const px = rect.x + x + 0.5;
425
- const py = rect.y + y + 0.5;
426
- const k = shade ? 1 - shade * (1 - grain.at(px, py)) : 1;
427
- let spec = 0;
428
- if (metal > 0) {
429
- const gx = (grain.at(px + 1, py) - grain.at(px - 1, py)) * 4;
430
- const gy = (grain.at(px, py + 1) - grain.at(px, py - 1)) * 4;
431
- const nl = Math.hypot(gx, gy, 1);
432
- const nh = Math.max(0, (-gx * hx - gy * hy + hz) / nl);
433
- spec = metal * Math.pow(nh, 24) * 0.9;
434
- }
435
- out[i] = Math.min(255, r * k * (1 - 0.25 * metal) + spec * (r * 0.6 + 102));
436
- out[i + 1] = Math.min(255, g * k * (1 - 0.25 * metal) + spec * (g * 0.6 + 102));
437
- out[i + 2] = Math.min(255, b * k * (1 - 0.25 * metal) + spec * (b * 0.6 + 102));
438
- out[i + 3] = 255;
439
- }
440
- }
441
- return out;
442
- }
443
-
444
- // src/paint-editor/document.ts
445
- var MIN_CANVAS_SIDE = 1;
446
- var MAX_CANVAS_SIDE = 16384;
447
- var DEFAULT_DPI = 300;
448
- var nextId = 1;
449
- function newPaintLayerId() {
450
- return `pl${Date.now().toString(36)}${(nextId++).toString(36)}`;
451
- }
452
- function createPaintLayer(name, id = newPaintLayerId()) {
453
- return { type: "pixel", id, name, visible: true, opacity: 1, blend: "normal", surface: emptySurface(4), offset: { x: 0, y: 0 } };
454
- }
455
- var PaintDocumentError = class extends Error {
456
- constructor(message) {
457
- super(message);
458
- this.name = "PaintDocumentError";
459
- }
460
- };
461
- function normalizeCanvas(raw) {
462
- const side = (v, what) => {
463
- if (typeof v !== "number" || !Number.isFinite(v)) throw new PaintDocumentError(`${what} must be a number of pixels (${MIN_CANVAS_SIDE}..${MAX_CANVAS_SIDE}).`);
464
- const n = Math.round(v);
465
- if (n < MIN_CANVAS_SIDE || n > MAX_CANVAS_SIDE) throw new PaintDocumentError(`${what} ${n} is out of range; use ${MIN_CANVAS_SIDE}..${MAX_CANVAS_SIDE} pixels.`);
466
- return n;
467
- };
468
- const dpi = raw.dpi === void 0 ? DEFAULT_DPI : raw.dpi;
469
- if (typeof dpi !== "number" || !Number.isFinite(dpi) || dpi < 1 || dpi > 1e4) throw new PaintDocumentError(`dpi ${dpi} is out of range; use 1..10000.`);
470
- return { width: side(raw.width, "width"), height: side(raw.height, "height"), dpi: Math.round(dpi * 100) / 100 };
471
- }
472
- function createPaintDocument(options = {}) {
473
- const canvas = normalizeCanvas({ width: options.width ?? 2048, height: options.height ?? 1536, dpi: options.dpi });
474
- const layer = createPaintLayer("Layer 1");
475
- return { canvas, paper: normalizePaper(options.paper ?? {}), layers: [layer], activeLayerId: layer.id, strokes: [] };
476
- }
477
-
478
- // src/paint-editor/effect-paint.ts
479
- var EFFECT_STRIP_HEIGHT = 256;
480
- var EFFECT_MAP_REPEATS = ["repeat", "mirror", "clamp"];
481
- function effectPaintSize(p, canvas) {
482
- return p.map ? { width: p.map.length, height: EFFECT_STRIP_HEIGHT } : { width: canvas.width, height: canvas.height };
483
- }
484
- function readEffectStrokeMap(raw) {
485
- if (raw === void 0 || raw === null || raw.by === "canvas") return void 0;
486
- const r = raw;
487
- if (r.by !== "stroke") throw new Error(`map.by ${JSON.stringify(r.by)} is not a mapping; use "canvas" (fixed to the canvas) or "stroke" (along each stroke).`);
488
- const length = r.length === void 0 ? 300 : Number(r.length);
489
- if (!(Number.isFinite(length) && length >= 16 && length <= 4096)) throw new Error(`map.length ${JSON.stringify(r.length)} is out of range; use 16..4096 px of stroke per repeat.`);
490
- const repeat = r.repeat === void 0 ? "repeat" : r.repeat;
491
- if (!EFFECT_MAP_REPEATS.includes(repeat)) throw new Error(`map.repeat ${JSON.stringify(r.repeat)} is not a repeat mode; use ${EFFECT_MAP_REPEATS.join(", ")}.`);
492
- return { by: "stroke", length: Math.round(length), repeat };
493
- }
494
- function stripIndex(img, map, dab, x, y) {
495
- const ca = Math.cos(dab.direction);
496
- const sa = Math.sin(dab.direction);
497
- const dx = x - dab.x;
498
- const dy = y - dab.y;
499
- let u = (dab.travel + dx * ca - dy * sa) / map.length;
500
- if (map.repeat === "clamp") u = Math.min(1, Math.max(0, u));
501
- else if (map.repeat === "mirror") {
502
- const m = (u % 2 + 2) % 2;
503
- u = m > 1 ? 2 - m : m;
504
- } else u = (u % 1 + 1) % 1;
505
- const v = Math.min(1, Math.max(0, ((dx * sa + dy * ca) / Math.max(0.5, dab.radius) + 1) / 2));
506
- const px = Math.min(img.width - 1, Math.floor(u * img.width));
507
- const py = Math.min(img.height - 1, Math.floor(v * img.height));
508
- return (py * img.width + px) * 4;
509
- }
510
- function effectPaintKey(p) {
511
- return JSON.stringify({ type: "effect", effect: p.effect, provider: p.provider, values: p.values, time: p.time, ...p.map ? { map: p.map } : {} });
512
- }
513
- function isPaintEffectPaint(raw) {
514
- return Boolean(raw) && typeof raw === "object" && raw.type === "effect";
515
- }
516
- function readMapLeniently(raw) {
517
- try {
518
- return readEffectStrokeMap(raw);
519
- } catch {
520
- return void 0;
521
- }
522
- }
523
- function readPaintEffectPaint(raw) {
524
- if (!isPaintEffectPaint(raw)) return null;
525
- const r = raw;
526
- if (typeof r.effect !== "string" || !r.effect || !r.values || typeof r.values !== "object" || Array.isArray(r.values)) return null;
527
- return {
528
- type: "effect",
529
- effect: r.effect,
530
- provider: typeof r.provider === "string" ? r.provider : "",
531
- values: JSON.parse(JSON.stringify(r.values)),
532
- time: typeof r.time === "number" && Number.isFinite(r.time) && r.time > 0 ? r.time : 0,
533
- ...readMapLeniently(r.map) ? { map: readMapLeniently(r.map) } : {},
534
- ...r.live === true ? { live: true } : {}
535
- };
536
- }
537
- function sampleEffectField(img, rect, vals, field, dab) {
538
- const { width, height, data } = img;
539
- if (img.map && dab) {
540
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
541
- for (let xx = 0; xx < rect.width; xx++, k++) {
542
- const i = stripIndex(img, img.map, dab, rect.x + xx + 0.5, rect.y + yy + 0.5);
543
- const f = k * 3;
544
- const a = data[i + 3];
545
- if (a < 255) vals[k] *= a / 255;
546
- field[f] = data[i] / 255;
547
- field[f + 1] = data[i + 1] / 255;
548
- field[f + 2] = data[i + 2] / 255;
549
- }
550
- }
551
- return;
552
- }
553
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
554
- const y = rect.y + yy;
555
- const inY = y >= 0 && y < height;
556
- for (let xx = 0; xx < rect.width; xx++, k++) {
557
- const x = rect.x + xx;
558
- const f = k * 3;
559
- if (!inY || x < 0 || x >= width) {
560
- vals[k] = 0;
561
- field[f] = field[f + 1] = field[f + 2] = 0;
562
- continue;
563
- }
564
- const i = (y * width + x) * 4;
565
- const a = data[i + 3];
566
- if (a < 255) vals[k] *= a / 255;
567
- field[f] = data[i] / 255;
568
- field[f + 1] = data[i + 1] / 255;
569
- field[f + 2] = data[i + 2] / 255;
570
- }
571
- }
572
- }
573
- function sampleEffectAt(img, x, y, out, dab) {
574
- if (img.map && dab) {
575
- const i2 = stripIndex(img, img.map, dab, x, y);
576
- out[0] = img.data[i2] / 255;
577
- out[1] = img.data[i2 + 1] / 255;
578
- out[2] = img.data[i2 + 2] / 255;
579
- return img.data[i2 + 3] / 255;
580
- }
581
- const px = Math.floor(x);
582
- const py = Math.floor(y);
583
- if (px < 0 || py < 0 || px >= img.width || py >= img.height) return 0;
584
- const i = (py * img.width + px) * 4;
585
- out[0] = img.data[i] / 255;
586
- out[1] = img.data[i + 1] / 255;
587
- out[2] = img.data[i + 2] / 255;
588
- return img.data[i + 3] / 255;
589
- }
590
- function effectDabField(img, rect, cov, centre2, mixed, field, steps = 0, dab = null) {
591
- sampleEffectField(img, rect, cov, field, dab);
592
- const d0 = mixed[0] - centre2[0];
593
- const d1 = mixed[1] - centre2[1];
594
- const d2 = mixed[2] - centre2[2];
595
- const n = rect.width * rect.height;
596
- for (let k = 0; k < n; k++) {
597
- if (steps) cov[k] = Math.round(cov[k] * steps) / steps;
598
- const f = k * 3;
599
- field[f] = Math.min(1, Math.max(0, field[f] + d0));
600
- field[f + 1] = Math.min(1, Math.max(0, field[f + 1] + d1));
601
- field[f + 2] = Math.min(1, Math.max(0, field[f + 2] + d2));
602
- }
603
- }
604
-
605
- // src/paint-editor/stamp-tip.ts
606
- var samplers = /* @__PURE__ */ new WeakMap();
607
- function stampSampler(ref2, art) {
608
- let kind = null;
609
- try {
610
- kind = editorStampOf(ref2.includes(":") ? ref2 : `editor:${ref2}`);
611
- } catch {
612
- return null;
613
- }
614
- if (kind) {
615
- const k = kind;
616
- return (u, v, seed) => stampCoverage(k, u, v, seed);
617
- }
618
- const a = art?.(ref2);
619
- if (!a || !(a.width > 0 && a.height > 0)) return null;
620
- const hit = samplers.get(a);
621
- if (hit) return hit;
622
- const { width: w, height: h, data } = a;
623
- const scale = Math.max(w, h) / 2;
624
- const alpha = (x, y) => x < 0 || y < 0 || x >= w || y >= h ? 0 : data[(y * w + x) * 4 + 3] / 255;
625
- const sampler = (u, v) => {
626
- const x = u * scale + w / 2 - 0.5;
627
- const y = v * scale + h / 2 - 0.5;
628
- const x0 = Math.floor(x);
629
- const y0 = Math.floor(y);
630
- const fx = x - x0;
631
- const fy = y - y0;
632
- return (alpha(x0, y0) * (1 - fx) + alpha(x0 + 1, y0) * fx) * (1 - fy) + (alpha(x0, y0 + 1) * (1 - fx) + alpha(x0 + 1, y0 + 1) * fx) * fy;
633
- };
634
- samplers.set(a, sampler);
635
- return sampler;
636
- }
637
- function stampDabRect(d) {
638
- const r = Math.max(0.5, d.radius) * Math.SQRT2 + 1.5;
639
- const x0 = Math.floor(d.x - r);
640
- const y0 = Math.floor(d.y - r);
641
- return { x: x0, y: y0, width: Math.ceil(d.x + r) - x0, height: Math.ceil(d.y + r) - y0 };
642
- }
643
- function rasterizeStampDab(d, sample, seed, rect, out) {
644
- const w = rect.width;
645
- const h = rect.height;
646
- out.fill(0, 0, w * h);
647
- const r = Math.max(0.5, d.radius);
648
- const ro = Math.min(1, Math.max(0.05, d.roundness));
649
- const sn = Math.sin(d.angle);
650
- const cs = Math.cos(d.angle);
651
- const ss = r < 6 ? 3 : 1;
652
- for (let yy = 0; yy < h; yy++) {
653
- for (let xx = 0; xx < w; xx++) {
654
- let sum = 0;
655
- for (let sy = 0; sy < ss; sy++) {
656
- for (let sx = 0; sx < ss; sx++) {
657
- const dx = rect.x + xx + (sx + 0.5) / ss - d.x;
658
- const dy = rect.y + yy + (sy + 0.5) / ss - d.y;
659
- const u = (dx * cs - dy * sn) / r;
660
- const v = (dx * sn + dy * cs) / (r * ro);
661
- if (u > -1.05 && u < 1.05 && v > -1.05 && v < 1.05) sum += sample(u, v, seed);
662
- }
663
- }
664
- const val = sum / (ss * ss);
665
- if (val > 0) out[yy * w + xx] = Math.min(1, val) * d.alpha;
666
- }
667
- }
668
- }
669
- function brushStickerRefs(brush) {
670
- const refs = brush.engine === "sticker" ? [...brush.sticker.stamps] : brush.engine === "particle" ? [...brush.particle.dotTextures] : brush.engine === "dab" && brush.stamp ? [brush.stamp] : [];
671
- return [...new Set(refs.filter((r) => r.includes(":") && !r.startsWith("editor:")))];
672
- }
673
- function sprayDroplets(d, spray, seed) {
674
- const count = 1 + Math.round(spray.density * 31);
675
- const key = d.index * 131 + Math.round(d.travel * 8);
676
- const out = [];
677
- for (let i = 0; i < count; i++) {
678
- const n = key * 64 + i;
679
- const a = rand01(seed, n, 61) * Math.PI * 2;
680
- const rr = Math.sqrt(rand01(seed, n, 67)) * d.radius;
681
- out.push({ ...d, x: d.x + Math.cos(a) * rr, y: d.y + Math.sin(a) * rr, radius: Math.max(0.5, d.radius * spray.size * (0.6 + 0.8 * rand01(seed, n, 71))), roundness: 1, angle: rand01(seed, n, 73) * Math.PI * 2 });
682
- }
683
- return out;
684
- }
685
-
686
- // src/paint-editor/engine.ts
687
- function penOrientation(p) {
688
- if (typeof p.altitude === "number" && Number.isFinite(p.altitude)) {
689
- const altitude = Math.min(Math.PI / 2, Math.max(0, p.altitude));
690
- const azimuth = typeof p.azimuth === "number" && Number.isFinite(p.azimuth) ? p.azimuth : 0;
691
- return { altitude, azimuth };
692
- }
693
- const hasX = typeof p.tiltX === "number" && Number.isFinite(p.tiltX);
694
- const hasY = typeof p.tiltY === "number" && Number.isFinite(p.tiltY);
695
- if (!hasX && !hasY) return null;
696
- return tiltToOrientation(hasX ? p.tiltX : 0, hasY ? p.tiltY : 0);
697
- }
698
- function tiltToOrientation(tiltX, tiltY) {
699
- const clampT = (v) => Math.min(89.999, Math.max(-89.999, v));
700
- const tx = Math.tan(clampT(tiltX) * Math.PI / 180);
701
- const ty = Math.tan(clampT(tiltY) * Math.PI / 180);
702
- const r = Math.hypot(tx, ty);
703
- if (r < 1e-9) return { altitude: Math.PI / 2, azimuth: 0 };
704
- let azimuth = Math.atan2(ty, tx);
705
- if (azimuth < 0) azimuth += 2 * Math.PI;
706
- return { altitude: Math.atan(1 / r), azimuth };
707
- }
708
- var pressureOf = (p) => typeof p.pressure === "number" && Number.isFinite(p.pressure) ? Math.min(1, Math.max(0, p.pressure)) : 1;
709
- var PenSmoother = class {
710
- pos = null;
711
- pressure = null;
712
- a;
713
- ap;
714
- constructor(position, pressure) {
715
- this.a = 1 - Math.min(0.95, Math.max(0, position));
716
- this.ap = 1 - Math.min(0.95, Math.max(0, pressure));
717
- }
718
- push(p) {
719
- const pr = pressureOf(p);
720
- if (!this.pos) {
721
- this.pos = { x: p.x, y: p.y };
722
- this.pressure = pr;
723
- return p;
724
- }
725
- this.pos = { x: this.pos.x + (p.x - this.pos.x) * this.a, y: this.pos.y + (p.y - this.pos.y) * this.a };
726
- this.pressure = this.pressure + (pr - this.pressure) * this.ap;
727
- return { ...p, x: this.pos.x, y: this.pos.y, pressure: this.pressure };
728
- }
729
- /** Where the smoothed position is now (or `null` before the first sample). */
730
- get position() {
731
- return this.pos;
732
- }
733
- };
734
- var IDENTITY_COPY = { map: (p) => p, linear: [1, 0, 0, 1] };
735
- var FULL_SPEED = 2.5;
736
- var PenDabWalker = class {
737
- constructor(brush, seed = 0, linear = [1, 0, 0, 1]) {
738
- this.brush = brush;
739
- this.seed = seed;
740
- this.linear = linear;
741
- }
742
- brush;
743
- seed;
744
- linear;
745
- last = null;
746
- nextAt = 0;
747
- pendingFirst = null;
748
- index = 0;
749
- dir = 0;
750
- lastT = null;
751
- firstT = null;
752
- speed = null;
753
- travelled = 0;
754
- sample(p) {
755
- const o = penOrientation(p);
756
- let speed = null;
757
- let time = null;
758
- if (this.last) this.travelled += Math.hypot(p.x - this.last.x, p.y - this.last.y);
759
- if (typeof p.t === "number" && Number.isFinite(p.t)) {
760
- if (this.firstT === null) this.firstT = p.t;
761
- time = p.t - this.firstT;
762
- if (this.last && this.lastT !== null && p.t > this.lastT) {
763
- const v = Math.hypot(p.x - this.last.x, p.y - this.last.y) / (p.t - this.lastT);
764
- this.speed = this.speed === null ? v : this.speed + (v - this.speed) * 0.35;
765
- }
766
- this.lastT = p.t;
767
- speed = this.speed ?? 0;
768
- }
769
- return {
770
- x: p.x,
771
- y: p.y,
772
- pressure: pressureOf(p),
773
- lean: o ? 1 - o.altitude / (Math.PI / 2) : 0,
774
- azimuth: o ? o.azimuth : 0,
775
- twist: typeof p.twist === "number" && Number.isFinite(p.twist) ? p.twist : null,
776
- speed,
777
- travel: this.travelled,
778
- time
779
- };
780
- }
781
- diameter(s) {
782
- const tip = this.brush.tip;
783
- const dyn = this.brush.dynamics;
784
- let d = tip.size;
785
- if (tip.pressureSize) d *= dyn.minSize + (1 - dyn.minSize) * s.pressure;
786
- d *= 1 + dyn.tiltSize * s.lean * 1.5;
787
- if (s.speed !== null && dyn.velocitySize) d *= Math.max(0.05, 1 + dyn.velocitySize * Math.min(1, s.speed / FULL_SPEED) * (dyn.velocitySize < 0 ? 0.85 : 1));
788
- return d;
789
- }
790
- stepLen(s) {
791
- return Math.max(0.5, Math.max(1, this.diameter(s)) * Math.max(0.01, this.brush.tip.spacing));
792
- }
793
- /** A rasterizer angle (CCW, y up) mapped through this copy's linear part. */
794
- mapAngle(theta) {
795
- const [a, b, c, d] = this.linear;
796
- if (a === 1 && b === 0 && c === 0 && d === 1) return theta;
797
- const vx = Math.cos(theta);
798
- const vy = -Math.sin(theta);
799
- return Math.atan2(-(c * vx + d * vy), a * vx + b * vy);
800
- }
801
- dab(s) {
802
- const { tip, dynamics: dyn, rotation } = this.brush;
803
- const i = this.index++;
804
- const seed = this.seed;
805
- let diameter = this.diameter(s);
806
- if (dyn.sizeJitter) diameter *= Math.max(0.05, 1 + dyn.sizeJitter * randSigned(seed, i, 1 /* Size */));
807
- const base = tip.angle * Math.PI / 180;
808
- let angle;
809
- if (rotation === "stroke") angle = this.dir + base;
810
- else if (rotation === "barrel" && s.twist !== null) angle = this.mapAngle(base - s.twist * Math.PI / 180);
811
- else if (dyn.tiltRoundness > 0 && s.lean > 0.02) angle = this.mapAngle(base - s.azimuth);
812
- else angle = this.mapAngle(base);
813
- if (dyn.angleJitter) angle += dyn.angleJitter * Math.PI * randSigned(seed, i, 2 /* Angle */);
814
- let opacity = tip.pressureOpacity ? s.pressure : 1;
815
- opacity *= 1 - dyn.tiltOpacity * s.lean * 0.8;
816
- if (s.speed !== null && dyn.velocityOpacity) opacity *= Math.max(0, 1 + dyn.velocityOpacity * (Math.min(1, s.speed / FULL_SPEED) - 0.5));
817
- if (dyn.opacityJitter) opacity *= 1 - dyn.opacityJitter * (randSigned(seed, i, 3 /* Opacity */) * 0.5 + 0.5);
818
- const flow = tip.flow * (1 - dyn.pressureFlow + dyn.pressureFlow * s.pressure);
819
- let { x, y } = s;
820
- if (dyn.scatter) {
821
- const off = dyn.scatter * diameter * randSigned(seed, i, 4 /* Scatter */);
822
- x += Math.sin(this.dir) * off;
823
- y += Math.cos(this.dir) * off;
824
- }
825
- return {
826
- x,
827
- y,
828
- radius: Math.max(0.5, diameter / 2),
829
- alpha: Math.min(1, Math.max(0, flow)),
830
- opacity: Math.min(1, Math.max(0, opacity)),
831
- angle,
832
- roundness: Math.min(1, Math.max(0.01, tip.roundness * (1 - dyn.tiltRoundness * s.lean * 0.8))),
833
- pressure: s.pressure,
834
- direction: this.dir,
835
- index: i,
836
- travel: s.travel,
837
- time: s.time,
838
- lean: s.lean,
839
- speed: s.speed
840
- };
841
- }
842
- push(p, out) {
843
- const a = this.last;
844
- const s = this.sample(p);
845
- if (!a) {
846
- this.last = s;
847
- this.pendingFirst = s;
848
- this.nextAt = this.stepLen(s);
849
- return;
850
- }
851
- const dx = s.x - a.x;
852
- const dy = s.y - a.y;
853
- const len = Math.hypot(dx, dy);
854
- if (len > 1e-9) this.dir = Math.atan2(-dy, dx);
855
- if (this.pendingFirst) {
856
- out.push(this.dab(this.pendingFirst));
857
- this.pendingFirst = null;
858
- }
859
- if (len > 1e-9) {
860
- while (this.nextAt <= len + 1e-9) {
861
- const f = Math.min(1, this.nextAt / len);
862
- const lerp = (u, v) => u + (v - u) * f;
863
- const q = {
864
- x: a.x + dx * f,
865
- y: a.y + dy * f,
866
- pressure: lerp(a.pressure, s.pressure),
867
- lean: lerp(a.lean, s.lean),
868
- azimuth: lerpAngle(a.azimuth, s.azimuth, f),
869
- twist: a.twist !== null && s.twist !== null ? lerpDegrees(a.twist, s.twist, f) : s.twist,
870
- speed: a.speed !== null && s.speed !== null ? lerp(a.speed, s.speed) : s.speed,
871
- travel: a.travel + this.nextAt,
872
- time: a.time !== null && s.time !== null ? lerp(a.time, s.time) : s.time
873
- };
874
- out.push(this.dab(q));
875
- this.nextAt += this.stepLen(q);
876
- }
877
- this.nextAt -= len;
878
- }
879
- this.last = s;
880
- }
881
- finish(out) {
882
- if (this.pendingFirst) {
883
- out.push(this.dab(this.pendingFirst));
884
- this.pendingFirst = null;
885
- }
886
- }
887
- };
888
- function lerpAngle(a, b, t) {
889
- let d = b - a;
890
- while (d > Math.PI) d -= 2 * Math.PI;
891
- while (d < -Math.PI) d += 2 * Math.PI;
892
- return a + d * t;
893
- }
894
- function lerpDegrees(a, b, t) {
895
- let d = b - a;
896
- while (d > 180) d -= 360;
897
- while (d < -180) d += 360;
898
- return a + d * t;
899
- }
900
- var DryStroke = class {
901
- constructor(brush, options) {
902
- this.brush = brush;
903
- this.options = options;
904
- this.stamp = brush.stamp ? stampSampler(brush.stamp, options.stampArt) : null;
905
- this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
906
- this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
907
- this.walkers = this.copies.map((c, i) => new PenDabWalker(brush, (options.seed ?? 0) + i * 7919 | 0, c.linear));
908
- this.depth = options.grain && !options.grain.flat ? Math.min(1, Math.max(0, options.paperDepth * brush.grain)) : 0;
909
- }
910
- brush;
911
- options;
912
- coverage = new CoverageMap();
913
- dabCount = 0;
914
- smoother;
915
- walkers;
916
- copies;
917
- depth;
918
- buf = new Float32Array(0);
919
- field = new Float32Array(0);
920
- lastRaw = null;
921
- lastFed = null;
922
- /** The stamp tip's shape, or `null` for the round computed tip. */
923
- stamp;
924
- /** Feeds raw pen samples; returns the document rectangles the new dabs touched. */
925
- push(points) {
926
- const dabs = [];
927
- for (const raw of points) {
928
- this.lastRaw = raw;
929
- const p = this.smoother.push(raw);
930
- this.lastFed = p;
931
- this.copies.forEach((c, i) => this.walkers[i].push(c.map(p), dabs));
932
- }
933
- return this.paint(dabs);
934
- }
935
- /** Ends the stroke: the smoothed path catches up with the last raw sample; a single tap paints one dab. */
936
- finish() {
937
- const dabs = [];
938
- const raw = this.lastRaw;
939
- const fed = this.lastFed;
940
- if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.copies.forEach((c, i) => this.walkers[i].push(c.map({ ...raw, pressure: fed.pressure }), dabs));
941
- for (const w of this.walkers) w.finish(dabs);
942
- return this.paint(dabs);
943
- }
944
- paint(dabs) {
945
- const rects = [];
946
- const spray = this.brush.spray;
947
- const seed = this.options.seed ?? 0;
948
- for (const d of dabs) {
949
- if (!spray) {
950
- this.mark(d, d, rects);
951
- continue;
952
- }
953
- for (const drop of sprayDroplets(d, spray, seed)) this.mark(drop, d, rects);
954
- }
955
- return rects;
956
- }
957
- /** Lays one mark (a dab, or a spray droplet of dab `d`): the round tip or the stamp, the paper grain, effect paint. */
958
- mark(m, d, rects) {
959
- const hardness = this.brush.tip.hardness;
960
- const grain = this.options.grain;
961
- const rect = this.stamp ? stampDabRect(m) : dabRect(m, hardness);
962
- const n = rect.width * rect.height;
963
- if (this.buf.length < n) this.buf = new Float32Array(n);
964
- const vals = this.buf.subarray(0, n);
965
- if (this.stamp) rasterizeStampDab(m, this.stamp, d.index, rect, vals);
966
- else rasterizeDab(m, hardness, rect, vals);
967
- if (this.depth > 0 && grain) {
968
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
969
- const y = rect.y + yy + 0.5;
970
- for (let xx = 0; xx < rect.width; xx++, k++) {
971
- if (vals[k] > 0) vals[k] *= grainCatch(grain.at(rect.x + xx + 0.5, y), this.depth, d.pressure);
972
- }
973
- }
974
- }
975
- let field = null;
976
- if (this.options.effect) {
977
- if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
978
- field = this.field.subarray(0, n * 3);
979
- sampleEffectField(this.options.effect, rect, vals, field, d);
980
- }
981
- this.coverage.accumulate(rect, vals, m.opacity, null, field);
982
- rects.push(rect);
983
- this.dabCount++;
984
- }
985
- };
986
- function mixerTip(brush) {
987
- return { ...brush.tip, smoothing: 0, pressureSize: brush.tip.pressureSize };
988
- }
989
- function toStrokePoint(p) {
990
- return { x: p.x, y: p.y, pressure: pressureOf(p), ...typeof p.t === "number" ? { t: p.t } : {} };
991
- }
992
-
993
- // src/paint-editor/dab-stroke.ts
994
- var GRADIENT_STEPS = 32;
995
- function multiColorPalette(mc, color, secondary) {
996
- if (mc.colors.length >= 2) {
997
- return mc.colors.map((h) => {
998
- const n = parseInt(h.slice(1), 16);
999
- return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
1000
- });
1001
- }
1002
- return [[...color], [...secondary]];
1003
- }
1004
- function soften(f, softness) {
1005
- if (softness >= 1) return f;
1006
- const w = Math.max(1e-3, softness);
1007
- return Math.min(1, Math.max(0, (f - 0.5) / w + 0.5));
1008
- }
1009
- function mixRgb(a, b, t, mixing) {
1010
- if (t <= 0) return [...a];
1011
- if (t >= 1) return [...b];
1012
- if (mixing === "pigment") {
1013
- const o = [0, 0, 0];
1014
- const q = (v) => Math.round(Math.min(1, Math.max(0, v)) * 255);
1015
- mixPigment8(q(a[0]), q(a[1]), q(a[2]), q(b[0]), q(b[1]), q(b[2]), t, o);
1016
- return [o[0], o[1], o[2]];
1017
- }
1018
- return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
1019
- }
1020
- function multiColorGradient(palette, softness, mixing) {
1021
- const n = palette.length;
1022
- const out = [];
1023
- for (let j = 0; j < GRADIENT_STEPS; j++) {
1024
- const f = j / (GRADIENT_STEPS - 1) * (n - 1);
1025
- const i = Math.min(n - 2, Math.floor(f));
1026
- out.push(mixRgb(palette[i], palette[i + 1], soften(f - i, softness), mixing));
1027
- }
1028
- return out;
1029
- }
1030
- function multiColorForDab(mc, palette, index, seed, mixing) {
1031
- const n = palette.length;
1032
- if (mc.order === "random") {
1033
- const run = Math.floor(index / mc.length);
1034
- return [...palette[Math.min(n - 1, Math.floor(rand01(seed, run, 11) * n))]];
1035
- }
1036
- const f = index / mc.length % n;
1037
- const i = Math.floor(f);
1038
- return mixRgb(palette[i], palette[(i + 1) % n], soften(f - i, mc.softness), mixing);
1039
- }
1040
- var PaintDabStroke = class {
1041
- constructor(brush, options) {
1042
- this.brush = brush;
1043
- this.options = options;
1044
- this.stamp = brush.engine === "dab" && brush.stamp ? stampSampler(brush.stamp, options.stampArt) : null;
1045
- this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
1046
- this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
1047
- const exchanging = brush.engine === "mixer" || brush.engine === "dab";
1048
- const rates = brush.engine === "mixer" ? mixerRates(brush.mixer) : exchangeRates(brush.behavior.pickup, brush.behavior.mix, 1, brush.behavior.mixing);
1049
- this.mixing = brush.engine === "mixer" ? brush.mixer.mixing : brush.behavior.mixing;
1050
- this.states = this.copies.map((c, i) => ({
1051
- walker: new PenDabWalker(brush, (options.seed ?? 0) + i * 7919 | 0, c.linear),
1052
- exchange: exchanging ? new PaintExchange(rates, options.load) : null,
1053
- smudge: brush.engine === "smudge" ? new SmudgeTip(brush.smudge, brush.tip.size * (1 + brush.dynamics.tiltSize * 1.5)) : null
1054
- }));
1055
- this.medium = new TileMedium(options.target, brush.engine === "dab" ? { blend: brush.tip.blend } : {});
1056
- this.depth = options.grain && !options.grain.flat ? Math.min(1, Math.max(0, options.paperDepth * brush.grain)) : 0;
1057
- this.coverage = brush.engine === "dab" ? new CoverageMap() : null;
1058
- this.sampler = exchanging && options.paint ? new PaintGradientSampler(options.paint, options.seed ?? 0) : null;
1059
- const table = this.sampler?.acrossTip ? this.sampler.tipTable(GRADIENT_STEPS) : null;
1060
- this.gradient = table ? table.map((e) => e.rgb) : null;
1061
- this.gradientAlpha = table && table.some((e) => e.alpha < 1) ? table.map((e) => e.alpha) : null;
1062
- this.scratch = brush.engine === "blender" ? createBlenderScratch() : null;
1063
- }
1064
- brush;
1065
- options;
1066
- dabCount = 0;
1067
- smoother;
1068
- copies;
1069
- states;
1070
- medium;
1071
- depth;
1072
- coverage;
1073
- sampler;
1074
- sample = { rgb: [0, 0, 0], alpha: 1 };
1075
- gradient;
1076
- /** Opacity across the tip (`across-tip` gradients with opacity stops), or `null`. */
1077
- gradientAlpha;
1078
- alphaCov = new Float32Array(0);
1079
- fieldLut = [];
1080
- scratch;
1081
- mixing;
1082
- tip = new Float32Array(0);
1083
- cov = new Float32Array(0);
1084
- field = new Float32Array(0);
1085
- paint = [0, 0, 0];
1086
- lastRaw = null;
1087
- lastFed = null;
1088
- /** The stamp tip's shape (dab brushes), or `null` for the round computed tip. */
1089
- stamp;
1090
- /** What copy 0's brush carries after the dabs so far (mixer and wet-capable dab brushes). */
1091
- get load() {
1092
- return this.states[0].exchange?.load ?? null;
1093
- }
1094
- push(points) {
1095
- const rects = [];
1096
- for (const raw of points) {
1097
- this.lastRaw = raw;
1098
- const p = this.smoother.push(raw);
1099
- this.lastFed = p;
1100
- this.copies.forEach((c, i) => {
1101
- const dabs = [];
1102
- this.states[i].walker.push(c.map(p), dabs);
1103
- this.paintDabs(i, dabs, rects);
1104
- });
1105
- }
1106
- return rects;
1107
- }
1108
- /** Ends the stroke: the smoothed path catches up with the last raw sample; a tap paints one dab. */
1109
- finish() {
1110
- const rects = [];
1111
- const raw = this.lastRaw;
1112
- const fed = this.lastFed;
1113
- this.copies.forEach((c, i) => {
1114
- const dabs = [];
1115
- if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.states[i].walker.push(c.map({ ...raw, pressure: fed.pressure }), dabs);
1116
- this.states[i].walker.finish(dabs);
1117
- this.paintDabs(i, dabs, rects);
1118
- });
1119
- return rects;
1120
- }
1121
- paintDabs(copy, dabs, rects) {
1122
- const spray = this.brush.engine === "dab" ? this.brush.spray : null;
1123
- for (const d of spray ? dabs.flatMap((dab) => sprayDroplets(dab, spray, (this.options.seed ?? 0) + copy * 7919)) : dabs) {
1124
- const rect = this.stamp ? stampDabRect(d) : dabRect(d, this.brush.tip.hardness);
1125
- if (rect.width <= 0 || rect.height <= 0) continue;
1126
- this.dab(copy, d, rect);
1127
- rects.push(rect);
1128
- this.dabCount++;
1129
- }
1130
- }
1131
- /** The tip's pure coverage (`tip`) and, with grain, what the paper catches of it (`cov`). */
1132
- rasterize(d, rect) {
1133
- const n = rect.width * rect.height;
1134
- if (this.tip.length < n) {
1135
- this.tip = new Float32Array(n);
1136
- this.cov = new Float32Array(n);
1137
- }
1138
- const tip = this.tip.subarray(0, n);
1139
- tip.fill(0);
1140
- if (this.stamp) rasterizeStampDab({ ...d, alpha: 1 }, this.stamp, d.index, rect, tip);
1141
- else rasterizeDab({ ...d, alpha: 1 }, this.brush.tip.hardness, rect, tip);
1142
- if (!(this.depth > 0 && this.options.grain)) return { tip, cov: tip };
1143
- const cov = this.cov.subarray(0, n);
1144
- const grain = this.options.grain;
1145
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
1146
- const y = rect.y + yy + 0.5;
1147
- for (let xx = 0; xx < rect.width; xx++, k++) cov[k] = tip[k] > 0 ? tip[k] * grainCatch(grain.at(rect.x + xx + 0.5, y), this.depth, d.pressure) : 0;
1148
- }
1149
- return { tip, cov };
1150
- }
1151
- dab(copy, d, rect) {
1152
- const brush = this.brush;
1153
- const { tip, cov } = this.rasterize(d, rect);
1154
- const st = this.states[copy];
1155
- if (brush.engine === "blender") {
1156
- blenderDab(this.medium, rect, cov, d.opacity * d.alpha * brush.tip.opacity, d.radius, brush.blender, this.scratch);
1157
- return;
1158
- }
1159
- if (brush.engine === "smudge") {
1160
- st.smudge.dab(this.medium, { x: d.x, y: d.y, radius: d.radius, opacity: d.opacity * d.alpha * brush.tip.opacity }, rect, cov);
1161
- return;
1162
- }
1163
- const ex = st.exchange;
1164
- ex.pickUp(this.medium.sample(rect, tip));
1165
- let flow = 1;
1166
- if (this.options.effect) {
1167
- const rgb = [0, 0, 0];
1168
- flow = sampleEffectAt(this.options.effect, d.x, d.y, rgb, d);
1169
- ex.setReservoirColor(rgb[0], rgb[1], rgb[2]);
1170
- } else if (this.sampler && !this.gradient) {
1171
- const g = this.sampler.dab(d, this.sample);
1172
- ex.setReservoirColor(g.rgb[0], g.rgb[1], g.rgb[2]);
1173
- flow = g.alpha;
1174
- }
1175
- let field = null;
1176
- let total;
1177
- let laid = cov;
1178
- if (this.gradient) {
1179
- total = this.tipField(ex, d, rect);
1180
- field = this.field;
1181
- if (this.gradientAlpha) laid = this.tipAlpha(d, rect, cov);
1182
- } else total = ex.paint(this.paint);
1183
- if (total > 0 && flow > 0) {
1184
- if (this.coverage) this.depositWithCeiling(d, rect, laid, field, ex, flow);
1185
- else {
1186
- const dep = Math.min(1, total) * d.alpha * brush.tip.opacity * d.opacity * flow;
1187
- if (dep > 0) this.medium.deposit(rect, laid, dep, this.paint, ex.paintPigment, field);
1188
- }
1189
- }
1190
- ex.drain();
1191
- }
1192
- /** Fills `this.field` with the tip gradient (mixed with the carried paint) per pixel; returns the paint weight. */
1193
- tipField(ex, d, rect) {
1194
- const g = this.gradient;
1195
- const lut = this.fieldLut;
1196
- let total = 0;
1197
- for (let j = 0; j < g.length; j++) {
1198
- ex.setReservoirColor(g[j][0], g[j][1], g[j][2]);
1199
- const out = lut[j] ?? [0, 0, 0];
1200
- total = ex.paint(out);
1201
- lut[j] = out;
1202
- }
1203
- const n = rect.width * rect.height;
1204
- if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
1205
- const f = this.field;
1206
- const nx = Math.sin(d.direction);
1207
- const ny = Math.cos(d.direction);
1208
- const r = Math.max(0.5, d.radius);
1209
- const last = g.length - 1;
1210
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
1211
- const py = rect.y + yy + 0.5 - d.y;
1212
- for (let xx = 0; xx < rect.width; xx++, k++) {
1213
- const u = ((rect.x + xx + 0.5 - d.x) * nx + py * ny) / r;
1214
- const j = Math.min(last, Math.max(0, Math.round((u + 1) / 2 * last)));
1215
- f[k * 3] = lut[j][0];
1216
- f[k * 3 + 1] = lut[j][1];
1217
- f[k * 3 + 2] = lut[j][2];
1218
- }
1219
- }
1220
- return total;
1221
- }
1222
- /** `cov` scaled by the gradient's opacity across the tip. */
1223
- tipAlpha(d, rect, cov) {
1224
- const a = this.gradientAlpha;
1225
- const n = rect.width * rect.height;
1226
- if (this.alphaCov.length < n) this.alphaCov = new Float32Array(n);
1227
- const out = this.alphaCov.subarray(0, n);
1228
- const nx = Math.sin(d.direction);
1229
- const ny = Math.cos(d.direction);
1230
- const r = Math.max(0.5, d.radius);
1231
- const last = a.length - 1;
1232
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
1233
- const py = rect.y + yy + 0.5 - d.y;
1234
- for (let xx = 0; xx < rect.width; xx++, k++) {
1235
- const u = ((rect.x + xx + 0.5 - d.x) * nx + py * ny) / r;
1236
- out[k] = cov[k] * a[Math.min(last, Math.max(0, Math.round((u + 1) / 2 * last)))];
1237
- }
1238
- }
1239
- return out;
1240
- }
1241
- /** A dab brush's deposit: only what this dab adds to the stroke's coverage, under its opacity ceiling. */
1242
- depositWithCeiling(d, rect, cov, field, ex, flow = 1) {
1243
- const map = this.coverage;
1244
- const n = rect.width * rect.height;
1245
- const before = new Float32Array(n);
1246
- const vals = new Float32Array(n);
1247
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
1248
- for (let xx = 0; xx < rect.width; xx++, k++) {
1249
- if (cov[k] <= 0) continue;
1250
- before[k] = map.get(rect.x + xx, rect.y + yy);
1251
- vals[k] = cov[k] * d.alpha * flow;
1252
- }
1253
- }
1254
- map.accumulate(rect, vals, d.opacity);
1255
- const o = this.brush.tip.opacity;
1256
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
1257
- for (let xx = 0; xx < rect.width; xx++, k++) {
1258
- if (cov[k] <= 0) {
1259
- vals[k] = 0;
1260
- continue;
1261
- }
1262
- const c0 = before[k] * o;
1263
- const c1 = map.get(rect.x + xx, rect.y + yy) * o;
1264
- vals[k] = c1 > c0 && c0 < 1 ? (c1 - c0) / (1 - c0) : 0;
1265
- }
1266
- }
1267
- this.medium.deposit(rect, vals, 1, this.paint, ex.paintPigment, field);
330
+ import "../chunk-HF5QZS7Z.js";
331
+ import "../chunk-M2TPMYJE.js";
332
+ import "../chunk-2WPNAGHI.js";
333
+ import {
334
+ HARMONY_KINDS,
335
+ colorHarmony,
336
+ hueToWheel,
337
+ wheelToHue
338
+ } from "../chunk-2EUW2XZX.js";
339
+ import "../chunk-E2V5DA3W.js";
340
+ import "../chunk-VR6OGHU5.js";
341
+ import "../chunk-JC2SHA4A.js";
342
+ import {
343
+ linearToSrgb,
344
+ oklabToOklch,
345
+ oklabToRgb,
346
+ oklchToOklab,
347
+ rgbToOklab,
348
+ srgbToLinear
349
+ } from "../chunk-MHLU6GRJ.js";
350
+ import "../chunk-6ONMQ6WF.js";
351
+
352
+ // src/paint-editor/document.ts
353
+ var MIN_CANVAS_SIDE = 1;
354
+ var MAX_CANVAS_SIDE = 16384;
355
+ var DEFAULT_DPI = 300;
356
+ var nextId = 1;
357
+ function newPaintLayerId() {
358
+ return `pl${Date.now().toString(36)}${(nextId++).toString(36)}`;
359
+ }
360
+ function createPaintLayer(name, id = newPaintLayerId()) {
361
+ return { type: "pixel", id, name, visible: true, opacity: 1, blend: "normal", surface: emptySurface(4), offset: { x: 0, y: 0 } };
362
+ }
363
+ var PaintDocumentError = class extends Error {
364
+ constructor(message) {
365
+ super(message);
366
+ this.name = "PaintDocumentError";
1268
367
  }
1269
368
  };
369
+ function normalizeCanvas(raw) {
370
+ const side = (v, what) => {
371
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new PaintDocumentError(`${what} must be a number of pixels (${MIN_CANVAS_SIDE}..${MAX_CANVAS_SIDE}).`);
372
+ const n = Math.round(v);
373
+ if (n < MIN_CANVAS_SIDE || n > MAX_CANVAS_SIDE) throw new PaintDocumentError(`${what} ${n} is out of range; use ${MIN_CANVAS_SIDE}..${MAX_CANVAS_SIDE} pixels.`);
374
+ return n;
375
+ };
376
+ const dpi = raw.dpi === void 0 ? DEFAULT_DPI : raw.dpi;
377
+ if (typeof dpi !== "number" || !Number.isFinite(dpi) || dpi < 1 || dpi > 1e4) throw new PaintDocumentError(`dpi ${dpi} is out of range; use 1..10000.`);
378
+ return { width: side(raw.width, "width"), height: side(raw.height, "height"), dpi: Math.round(dpi * 100) / 100 };
379
+ }
380
+ function createPaintDocument(options = {}) {
381
+ const canvas = normalizeCanvas({ width: options.width ?? 2048, height: options.height ?? 1536, dpi: options.dpi });
382
+ const layer = createPaintLayer("Layer 1");
383
+ return { canvas, paper: normalizePaper(options.paper ?? {}), layers: [layer], activeLayerId: layer.id, strokes: [] };
384
+ }
1270
385
 
1271
386
  // src/paint-editor/thick.ts
1272
387
  var DEFAULT_LIGHTING = { azimuth: 315, elevation: 40, relief: 0.7 };
@@ -3937,696 +3052,6 @@ function downloadBytes(bytes, fileName, mimeType) {
3937
3052
  setTimeout(() => g.URL.revokeObjectURL(url), 1e3);
3938
3053
  }
3939
3054
 
3940
- // src/paint-editor/wet.ts
3941
- var STROKE_WATER = 0.9;
3942
- var STROKE_MASS = PIGMENT_UNIT * 2.5;
3943
- var STROKE_LIGHT = 1.5;
3944
- var COVERAGE_STEPS = 4096;
3945
- function union2(a, b) {
3946
- if (!a) return { ...b };
3947
- const x = Math.min(a.x, b.x);
3948
- const y = Math.min(a.y, b.y);
3949
- return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
3950
- }
3951
- function wetPaperInput(paper) {
3952
- const grain = new PaperGrain(paper, "wet");
3953
- const flat = grain.flat;
3954
- return {
3955
- absorption: paper.absorption,
3956
- permeability: paper.permeability,
3957
- roughness: flat ? 0 : paper.roughness,
3958
- color: hexToRgb8(paper.color),
3959
- heightAt: flat ? () => 0.5 : (x, y) => grain.at(x, y)
3960
- };
3961
- }
3962
- var WetStrokeDriver = class {
3963
- constructor(init) {
3964
- this.init = init;
3965
- const brush = init.brush;
3966
- this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
3967
- const copies = init.copies.length ? init.copies : [IDENTITY_COPY];
3968
- const wet = brush.wet;
3969
- const spacing = Math.max(0.02, brush.tip.spacing);
3970
- const perDab = Math.min(1, spacing * 1.2);
3971
- const thin = wet.thinner;
3972
- this.medium = new WetMedium(init.world, init.laneId, {
3973
- water: STROKE_WATER * Math.min(1, wet.water * (1 + thin)) * perDab,
3974
- mass: STROKE_MASS * wet.pigment * (1 - 0.6 * thin) * perDab,
3975
- body: wet.body * (1 - thin),
3976
- light: 0
3977
- });
3978
- this.lightBase = wet.tool === "paint" ? STROKE_LIGHT * brush.light.amount * (1 - 0.6 * thin) * perDab : 0;
3979
- const rates = exchangeRates(brush.behavior.pickup, brush.behavior.mix, wet.load, brush.behavior.mixing);
3980
- this.states = copies.map((c, i) => ({
3981
- walker: new PenDabWalker(brush, init.seed + i * 7919 | 0, c.linear),
3982
- exchange: wet.tool === "paint" ? new PaintExchange(rates, init.load) : null,
3983
- last: null
3984
- }));
3985
- this.copies = copies;
3986
- this.depth = init.grain && !init.grain.flat ? Math.min(1, Math.max(0, init.paperDepth * brush.grain)) : 0;
3987
- const g = init.paint ?? null;
3988
- this.sampler = g ? new PaintGradientSampler(g.map.by === "across-tip" ? { ...g, map: { by: "distance", length: 300, repeat: "mirror" } } : g, init.seed) : null;
3989
- }
3990
- init;
3991
- dabCount = 0;
3992
- bounds = null;
3993
- /** The world's clock at which each raw point was applied (recorded with the stroke). */
3994
- steps = [];
3995
- smoother;
3996
- states;
3997
- medium;
3998
- queue = [];
3999
- finishing = false;
4000
- finished = false;
4001
- lastRaw = null;
4002
- lastFed = null;
4003
- tip = new Float32Array(0);
4004
- cov = new Float32Array(0);
4005
- paint = [0, 0, 0];
4006
- centre = [0, 0, 0];
4007
- depth;
4008
- lightBase;
4009
- sampler;
4010
- sample = { rgb: [0, 0, 0], alpha: 1 };
4011
- copies;
4012
- /** What copy 0's brush carries after the dabs so far. */
4013
- get load() {
4014
- return this.states[0].exchange?.load ?? null;
4015
- }
4016
- /** Whether every point (and the end) has been applied. */
4017
- get done() {
4018
- return this.finished;
4019
- }
4020
- /** Adds raw points; applies them now unless a batch is running. Returns where pixels changed. */
4021
- push(points) {
4022
- for (const p of points) this.queue.push(p);
4023
- return this.flush();
4024
- }
4025
- /** Asks for the end; it happens now unless a batch is running (then at the next `flush`). */
4026
- finish() {
4027
- this.finishing = true;
4028
- return this.flush();
4029
- }
4030
- /** Applies waiting points (and the end, when asked) if the world is between batches. */
4031
- flush() {
4032
- const rects = [];
4033
- if (this.init.world.busy || this.finished) return rects;
4034
- while (this.queue.length) {
4035
- const raw = this.queue.shift();
4036
- this.steps.push(this.init.world.clock);
4037
- this.lastRaw = raw;
4038
- const p = this.smoother.push(raw);
4039
- this.lastFed = p;
4040
- this.copies.forEach((c, i) => {
4041
- const dabs = [];
4042
- this.states[i].walker.push(c.map(p), dabs);
4043
- this.applyDabs(i, dabs, rects);
4044
- });
4045
- }
4046
- if (this.finishing) {
4047
- const raw = this.lastRaw;
4048
- const fed = this.lastFed;
4049
- this.copies.forEach((c, i) => {
4050
- const dabs = [];
4051
- if (raw && fed && (raw.x !== fed.x || raw.y !== fed.y)) this.states[i].walker.push(c.map({ ...raw, pressure: fed.pressure }), dabs);
4052
- this.states[i].walker.finish(dabs);
4053
- this.applyDabs(i, dabs, rects);
4054
- });
4055
- this.finished = true;
4056
- }
4057
- for (const r of rects) this.bounds = union2(this.bounds, r);
4058
- return rects;
4059
- }
4060
- applyDabs(copy, dabs, rects) {
4061
- for (const d of dabs) {
4062
- const rect = dabRect(d, this.init.brush.tip.hardness);
4063
- if (rect.width <= 0 || rect.height <= 0) continue;
4064
- this.dab(copy, d, rect);
4065
- rects.push(rect);
4066
- this.dabCount++;
4067
- }
4068
- }
4069
- /** The tip's coverage over `rect`, with the grain, quantised. */
4070
- coverage(d, rect) {
4071
- const n = rect.width * rect.height;
4072
- if (this.tip.length < n) {
4073
- this.tip = new Float32Array(n);
4074
- this.cov = new Float32Array(n);
4075
- }
4076
- const tip = this.tip.subarray(0, n);
4077
- const cov = this.cov.subarray(0, n);
4078
- tip.fill(0);
4079
- rasterizeDab({ ...d, alpha: 1 }, this.init.brush.tip.hardness, rect, tip);
4080
- const grain = this.depth > 0 ? this.init.grain : null;
4081
- const mask = this.init.mask;
4082
- for (let yy = 0, k = 0; yy < rect.height; yy++) {
4083
- for (let xx = 0; xx < rect.width; xx++, k++) {
4084
- if (mask && tip[k] > 0) {
4085
- const mx = rect.x + xx;
4086
- const my = rect.y + yy;
4087
- tip[k] *= mx >= 0 && my >= 0 && mx < mask.width && my < mask.height ? mask.data[my * mask.width + mx] / 255 : 0;
4088
- }
4089
- let v = tip[k];
4090
- if (v > 0 && grain) v *= grainCatch(grain.at(rect.x + xx + 0.5, rect.y + yy + 0.5), this.depth, d.pressure);
4091
- tip[k] = Math.round(tip[k] * COVERAGE_STEPS) / COVERAGE_STEPS;
4092
- cov[k] = Math.round(v * COVERAGE_STEPS) / COVERAGE_STEPS;
4093
- }
4094
- }
4095
- return { tip, cov };
4096
- }
4097
- dab(copy, d, rect) {
4098
- const { world, laneId, brush } = this.init;
4099
- const wet = brush.wet;
4100
- const st = this.states[copy];
4101
- const { tip, cov } = this.coverage(d, rect);
4102
- const k = Math.round(Math.min(1, d.alpha * d.opacity * brush.tip.opacity) * COVERAGE_STEPS) / COVERAGE_STEPS;
4103
- if (!(k > 0)) return;
4104
- switch (wet.tool) {
4105
- case "paint": {
4106
- const ex = st.exchange;
4107
- this.medium.options.light = this.lightBase * (brush.light.pressure ? d.pressure : 1);
4108
- let flow = 1;
4109
- const effect = this.init.effect;
4110
- if (effect) {
4111
- sampleEffectAt(effect, d.x, d.y, this.centre, d);
4112
- ex.setReservoirColor(this.centre[0], this.centre[1], this.centre[2]);
4113
- } else if (this.sampler) {
4114
- const g = this.sampler.dab(d, this.sample);
4115
- ex.setReservoirColor(g.rgb[0], g.rgb[1], g.rgb[2]);
4116
- flow = g.alpha;
4117
- }
4118
- ex.pickUp(this.medium.sample(rect, tip));
4119
- const total = ex.paint(this.paint);
4120
- if (total > 0 && flow > 0 && effect) {
4121
- const ec = cov.slice();
4122
- const field = new Float32Array(ec.length * 3);
4123
- effectDabField(effect, rect, ec, this.centre, this.paint, field, COVERAGE_STEPS, d);
4124
- this.medium.deposit(rect, ec, Math.min(1, total) * k, this.paint, null, field);
4125
- } else if (total > 0 && flow > 0) this.medium.deposit(rect, cov, Math.min(1, total) * k * flow, this.paint, null);
4126
- else world.paint(laneId, rect, cov, this.medium.options.water * k, null);
4127
- ex.drain();
4128
- if (wet.rewet > 0) world.rewet(laneId, rect, cov, wet.rewet * k * 0.15, this.init.paperColor);
4129
- break;
4130
- }
4131
- case "water": {
4132
- world.paint(laneId, rect, cov, this.medium.options.water * k * (wet.water > 0 ? 1 : 0), null);
4133
- if (wet.rewet > 0) world.rewet(laneId, rect, cov, wet.rewet * k * 0.15, this.init.paperColor);
4134
- break;
4135
- }
4136
- case "dry":
4137
- world.remove(laneId, rect, cov, wet.strength * 0.35 * k, 0, 0, wet.strength * 0.35 * k);
4138
- break;
4139
- case "blow": {
4140
- const s = wet.strength * 0.08 * k;
4141
- world.push(laneId, rect, cov, Math.cos(d.direction) * s, -Math.sin(d.direction) * s);
4142
- break;
4143
- }
4144
- case "blend":
4145
- world.blend(laneId, rect, cov, wet.strength * k);
4146
- break;
4147
- case "smudge": {
4148
- if (st.last) world.drag(laneId, rect, cov, d.x - st.last.x, d.y - st.last.y, wet.strength * k);
4149
- break;
4150
- }
4151
- case "blot":
4152
- world.remove(laneId, rect, cov, wet.strength * 0.8 * k, wet.strength * 0.6 * k, wet.strength * 0.15 * k, wet.strength * 0.5 * k);
4153
- break;
4154
- }
4155
- st.last = d;
4156
- }
4157
- };
4158
- var WET_MEDIUM_PARAMS = {
4159
- watercolor: {},
4160
- gouache: { viscosity: 0.45, evaporation: 0.1, edgeDarkening: 0.15, granulation: 0.1, bloom: 0.1, bleed: 0.15, pickup: 0.5, pigmentWeight: 0.5 },
4161
- oil: { viscosity: 0.95, evaporation: 0.03, edgeDarkening: 0, granulation: 0, bloom: 0, bleed: 0.08, pickup: 0.6, fixture: 0.02, pigmentWeight: 0.8 }
4162
- };
4163
- var WET_GPU_MODES = ["auto", "off"];
4164
- var GPU_LEAD = 16;
4165
- var DISPLAY_TILES_PER_TICK = 24;
4166
- var PaintWetLayers = class {
4167
- constructor(host, options = {}) {
4168
- this.host = host;
4169
- this.options = options;
4170
- this.speed = Math.max(1, Math.min(2e3, options.speed ?? 120));
4171
- this.undoMemory = Math.max(0, options.undoMemory ?? 512 * 1024 * 1024);
4172
- this.gpuMode = options.gpu === "off" ? "off" : "auto";
4173
- this.gpuMinTiles = Math.max(1, Math.floor(options.gpuMinTiles ?? 16));
4174
- }
4175
- host;
4176
- options;
4177
- world = null;
4178
- tilt = { ...FLAT };
4179
- settings = /* @__PURE__ */ new Map();
4180
- paused = false;
4181
- /** Wet strokes dry as soon as they end (one undo step with the stroke). */
4182
- instantDry = false;
4183
- speed;
4184
- overlay = false;
4185
- undoMemory;
4186
- /** The live wet stroke, if any. */
4187
- driver = null;
4188
- lastTick = null;
4189
- ticking = null;
4190
- stepsOwed = 0;
4191
- displaying = null;
4192
- leadAt = 0;
4193
- gpuMode;
4194
- gpuMinTiles;
4195
- gpu = null;
4196
- gpuSupport = null;
4197
- gpuStarting = null;
4198
- // --- Engine B ------------------------------------------------------------------
4199
- /** Starts the GPU engine for the world (once; it may turn out unavailable). */
4200
- startGpu() {
4201
- if (this.gpuMode === "off" || this.gpu || this.gpuStarting || this.gpuSupport) return;
4202
- const provider = this.options.gpuProvider === void 0 ? findWebGpu() : this.options.gpuProvider;
4203
- this.gpuStarting = createWetGpuEngine(provider).then(({ engine, support }) => {
4204
- this.gpuSupport = support;
4205
- if (!engine) return;
4206
- if (this.gpuMode === "off" || !this.world) {
4207
- engine.destroy();
4208
- this.gpuSupport = null;
4209
- return;
4210
- }
4211
- this.gpu = engine;
4212
- this.world.gpuMinTiles = this.gpuMinTiles;
4213
- this.world.attachGpu(engine);
4214
- }).finally(() => {
4215
- this.gpuStarting = null;
4216
- this.host.changed();
4217
- });
4218
- }
4219
- /** Resolves once the GPU engine has started or turned out unavailable. */
4220
- async gpuReady() {
4221
- if (this.gpuStarting) await this.gpuStarting;
4222
- return this.engineInfo();
4223
- }
4224
- /** `auto`: use the GPU for large wet areas when there is one; `off`: the CPU only. */
4225
- setGpu(mode, minTiles) {
4226
- if (minTiles !== void 0) {
4227
- this.gpuMinTiles = Math.max(1, Math.floor(minTiles));
4228
- if (this.world) this.world.gpuMinTiles = this.gpuMinTiles;
4229
- }
4230
- if (mode === this.gpuMode) return this.engineInfo();
4231
- this.gpuMode = mode;
4232
- if (mode === "off") {
4233
- this.world?.attachGpu(null);
4234
- this.gpu?.destroy();
4235
- this.gpu = null;
4236
- this.gpuSupport = null;
4237
- } else if (this.world) this.startGpu();
4238
- return this.engineInfo();
4239
- }
4240
- engineInfo() {
4241
- const w = this.world;
4242
- const base = { mode: this.gpuMode, adapter: this.gpuSupport?.adapter ?? null, maxTiles: this.gpuSupport?.maxSlots ?? 0, minTiles: this.gpuMinTiles, activeTiles: w?.activeTiles() ?? 0, cpu: this.backend() };
4243
- if (this.gpuMode === "off") return { ...base, state: "off", reason: "Forced to the CPU." };
4244
- if (this.gpuStarting) return { ...base, state: "starting", reason: null };
4245
- if (w?.gpuError) return { ...base, state: "failed", reason: w.gpuError };
4246
- if (this.gpuSupport && !this.gpuSupport.supported) return { ...base, state: "unavailable", reason: this.gpuSupport.reason };
4247
- if (!this.gpu) return { ...base, state: "not-started", reason: "Starts with the first wet stroke." };
4248
- if (w?.gpuActive) return { ...base, state: "active", reason: null };
4249
- return { ...base, state: "standby", reason: `The GPU takes over at ${this.gpuMinTiles} wet tiles (256 px).` };
4250
- }
4251
- /** The world (made on first use, at the canvas size). */
4252
- ensureWorld() {
4253
- const { width, height } = this.host.canvas();
4254
- if (this.world && (this.world.width !== width || this.world.height !== height)) {
4255
- if (this.world.laneIds().length) throw new Error("The wet world is the wrong size; dry the paint before resizing the canvas.");
4256
- this.dropWorld();
4257
- }
4258
- if (!this.world) this.world = this.makeWorld(width, height, this.host.paper());
4259
- return this.world;
4260
- }
4261
- makeWorld(width, height, paper) {
4262
- const world = new WetWorld(width, height, wetPaperInput(paper), { kernel: this.options.kernel, threads: this.options.threads });
4263
- world.setTilt(this.tilt);
4264
- world.gpuMinTiles = this.gpuMinTiles;
4265
- if (this.gpu) world.attachGpu(this.gpu);
4266
- else this.startGpu();
4267
- return world;
4268
- }
4269
- /**
4270
- * Whether `extend` can keep the wet paint when the canvas grows by `left` / `top` on those
4271
- * sides: the shift has to be whole wet tiles (256 px), and no wet stroke may be live.
4272
- */
4273
- canExtend(left, top) {
4274
- return left % WET_TILE === 0 && top % WET_TILE === 0 && !this.driver;
4275
- }
4276
- /**
4277
- * Keeps the wet paint while the canvas grows to `width`×`height` by `left` / `top` on those
4278
- * sides (`canExtend` must hold): the world is made again at the new size with every wet layer
4279
- * moved by (left, top), still wet, its base and flow map with it; `paper` is the new
4280
- * document's paper (its grain moved with the paint). Wet settings' flow centres move too. The
4281
- * caller shows the layers again (`syncDisplays`) once the document is the new size.
4282
- */
4283
- extend(width, height, left, top, paper) {
4284
- if (!this.canExtend(left, top)) throw new Error("Wet paint only moves by whole 256 px tiles, between strokes.");
4285
- for (const [id, s] of this.settings) if (s.flow.center) this.settings.set(id, { ...s, flow: { ...s.flow, center: { x: s.flow.center.x + left, y: s.flow.center.y + top } } });
4286
- const old = this.world;
4287
- const snap = old?.laneIds().length ? old.snapshot() : null;
4288
- this.dropWorld();
4289
- this.lastTick = null;
4290
- this.stepsOwed = 0;
4291
- if (!old || !snap) return;
4292
- const moved = moveWetSnapshot(snap, old, { width, height }, left, top, (l) => {
4293
- const flow = this.settings.get(l.id)?.flow;
4294
- return {
4295
- base: translateSurface(l.base, left, top),
4296
- ...l.light ? { light: translateSurface(l.light, left, top) } : {},
4297
- flow: l.flow && flow && flow.kind !== "none" ? flowField(flow, width, height) : l.flow
4298
- };
4299
- });
4300
- this.world = this.makeWorld(width, height, paper);
4301
- this.world.restore(moved);
4302
- }
4303
- /** Whether any layer (or `layerId`) has wet paint. */
4304
- isWet(layerId) {
4305
- const w = this.world;
4306
- if (!w) return false;
4307
- if (layerId !== void 0) return w.hasLane(layerId) && w.isActive(layerId);
4308
- return w.laneIds().some((id) => w.isActive(id));
4309
- }
4310
- /** Layers holding a wet layer (wet or drying). */
4311
- wetLayerIds() {
4312
- return this.world?.laneIds() ?? [];
4313
- }
4314
- /** Whether a batch is running (edits wait). */
4315
- get busy() {
4316
- return Boolean(this.world?.busy);
4317
- }
4318
- /** Resolves when no batch is running. */
4319
- async idle() {
4320
- while (this.ticking) await this.ticking;
4321
- }
4322
- settingsOf(layerId, medium = "watercolor") {
4323
- let s = this.settings.get(layerId);
4324
- if (!s) {
4325
- s = { params: normalizeWetParams(WET_MEDIUM_PARAMS[medium], DEFAULT_WET_PARAMS), flow: { ...DEFAULT_FLOW } };
4326
- this.settings.set(layerId, s);
4327
- }
4328
- return s;
4329
- }
4330
- /** Puts a layer in the world (its current pixels become the base under the wet paint). */
4331
- ensureLane(layerId, medium) {
4332
- const w = this.ensureWorld();
4333
- if (!w.hasLane(layerId)) {
4334
- const base = this.host.layerSurface(layerId) ?? new TiledSurface(4, /* @__PURE__ */ new Map(), 0);
4335
- const s = this.settingsOf(layerId, medium);
4336
- w.ensureLane(layerId, { params: s.params, base, light: this.host.layerLight(layerId) });
4337
- const { width, height } = this.host.canvas();
4338
- if (s.flow.kind !== "none") w.setFlow(layerId, flowField(s.flow, width, height), s.flow.strength);
4339
- }
4340
- return w;
4341
- }
4342
- setParams(layerId, update) {
4343
- const s = this.settingsOf(layerId);
4344
- s.params = normalizeWetParams(update, s.params);
4345
- if (this.world?.hasLane(layerId)) this.world.setParams(layerId, s.params);
4346
- return { ...s.params };
4347
- }
4348
- setFlow(layerId, update) {
4349
- const s = this.settingsOf(layerId);
4350
- s.flow = normalizeFlow(update, s.flow);
4351
- if (this.world?.hasLane(layerId)) {
4352
- const { width, height } = this.host.canvas();
4353
- this.world.setFlow(layerId, flowField(s.flow, width, height), s.flow.strength);
4354
- }
4355
- return { ...s.flow };
4356
- }
4357
- setTilt(update) {
4358
- this.tilt = normalizeTilt(update, this.tilt);
4359
- this.world?.setTilt(this.tilt);
4360
- return { ...this.tilt };
4361
- }
4362
- /** The paper changed. */
4363
- paperChanged() {
4364
- this.world?.setPaper(wetPaperInput(this.host.paper()));
4365
- }
4366
- /** A layer as tools describe it. */
4367
- info(layerId) {
4368
- const s = this.settingsOf(layerId);
4369
- const st = this.world?.hasLane(layerId) ? this.world.stats(layerId) : null;
4370
- return {
4371
- layerId,
4372
- wet: Boolean(st && st.wetCells > 0),
4373
- wetCells: st?.wetCells ?? 0,
4374
- wetBounds: st?.wetBounds ?? null,
4375
- activeTiles: st?.activeTiles ?? 0,
4376
- params: { ...s.params },
4377
- flow: { ...s.flow }
4378
- };
4379
- }
4380
- backend() {
4381
- return this.world?.backend() ?? null;
4382
- }
4383
- /** Shows the current wet pixels (at most `maxTiles` reshaded per layer). */
4384
- syncDisplays(maxTiles = Infinity) {
4385
- const w = this.world;
4386
- if (!w) return [];
4387
- const surfaces = /* @__PURE__ */ new Map();
4388
- const rects = [];
4389
- for (const id of w.laneIds()) {
4390
- const d = w.display(id, maxTiles);
4391
- if (this.host.layerSurface(id) !== d.surface) surfaces.set(id, d.surface);
4392
- rects.push(...d.changed);
4393
- }
4394
- this.syncLights();
4395
- if (surfaces.size) this.host.updateSurfaces(surfaces, rects);
4396
- return rects;
4397
- }
4398
- /** Like `syncDisplays`, shading on the GPU when Engine B is active (all stale tiles at once). */
4399
- async syncDisplaysGpu(maxCpuTiles, lead = false) {
4400
- const w = this.world;
4401
- if (!w) return [];
4402
- const surfaces = /* @__PURE__ */ new Map();
4403
- const rects = [];
4404
- for (const id of w.laneIds()) {
4405
- const d = await w.displayGpu(id, maxCpuTiles, lead);
4406
- if (!w.hasLane(id)) continue;
4407
- if (this.host.layerSurface(id) !== d.surface) surfaces.set(id, d.surface);
4408
- rects.push(...d.changed);
4409
- }
4410
- this.syncLights();
4411
- if (surfaces.size) this.host.updateSurfaces(surfaces, rects);
4412
- return rects;
4413
- }
4414
- /** Shows each wet layer's live light paint (the light under it with the wet light shaded in). */
4415
- syncLights() {
4416
- const w = this.world;
4417
- if (!w) return;
4418
- const lights = /* @__PURE__ */ new Map();
4419
- for (const id of w.laneIds()) {
4420
- const l = w.lightDisplay(id);
4421
- if (l && this.host.layerLight(id) !== l) lights.set(id, l);
4422
- }
4423
- if (lights.size) this.host.updateLights(lights);
4424
- }
4425
- /** Drops the wet layers whose paint has all dried (they become ordinary pixel layers). */
4426
- retireDry() {
4427
- const w = this.world;
4428
- if (!w) return;
4429
- for (const id of w.laneIds()) {
4430
- if (w.isActive(id) || this.driver?.init.laneId === id) continue;
4431
- const base = w.getBase(id);
4432
- const light = w.isLit(id) ? w.getLight(id) : null;
4433
- w.removeLane(id);
4434
- if (light && this.host.layerLight(id) !== light) this.host.updateLights(/* @__PURE__ */ new Map([[id, light]]));
4435
- this.host.updateSurfaces(/* @__PURE__ */ new Map([[id, base]]), []);
4436
- }
4437
- }
4438
- /**
4439
- * Advances time by the wall clock (`now` in ms, e.g. `performance.now()`): `speed` steps per
4440
- * second, at most `maxSteps` per call. Call it every frame; it does nothing while paused or dry.
4441
- * Resolves when the steps are done and the display is up to date.
4442
- */
4443
- tick(now, maxSteps = 8) {
4444
- if (this.ticking) {
4445
- this.leadFrame(now);
4446
- return this.ticking;
4447
- }
4448
- if (this.displaying) return this.displaying;
4449
- const last = this.lastTick;
4450
- this.lastTick = now;
4451
- const w = this.world;
4452
- if (!w || this.paused || last === null) {
4453
- this.flushDriver();
4454
- return Promise.resolve();
4455
- }
4456
- if (!w.isActive()) {
4457
- this.flushDriver();
4458
- this.retireDry();
4459
- return Promise.resolve();
4460
- }
4461
- const lead = w.gpuActive && !this.driver;
4462
- w.gpuLeadMode = lead;
4463
- const most = lead ? Math.max(maxSteps, GPU_LEAD) : maxSteps;
4464
- this.stepsOwed = Math.min(most * 4, this.stepsOwed + (now - last) / 1e3 * this.speed);
4465
- const n = Math.min(most, Math.floor(this.stepsOwed));
4466
- this.leadAt = now;
4467
- if (n < 1) {
4468
- this.flushDriver();
4469
- return Promise.resolve();
4470
- }
4471
- this.stepsOwed -= n;
4472
- const run = (async () => {
4473
- try {
4474
- await w.step(n);
4475
- } finally {
4476
- this.ticking = null;
4477
- }
4478
- this.flushDriver();
4479
- if (w.gpuActive) {
4480
- const shading = this.syncDisplaysGpu(DISPLAY_TILES_PER_TICK).then(() => void 0);
4481
- this.displaying = shading;
4482
- try {
4483
- await shading;
4484
- } finally {
4485
- this.displaying = null;
4486
- }
4487
- } else this.syncDisplays(DISPLAY_TILES_PER_TICK);
4488
- this.retireDry();
4489
- this.host.changed();
4490
- })();
4491
- this.ticking = run;
4492
- return run;
4493
- }
4494
- /** Lead mode: steps the GPU on by the time since the last frame (within the running batch) and shows it. */
4495
- leadFrame(now) {
4496
- const w = this.world;
4497
- if (!w || this.displaying || this.driver || !w.gpuLeading) return;
4498
- const k = Math.max(1, Math.round((now - this.leadAt) / 1e3 * this.speed));
4499
- if (!w.gpuLead(k)) return;
4500
- this.leadAt = now;
4501
- const shading = this.syncDisplaysGpu(0, true).then(() => this.host.changed());
4502
- this.displaying = shading;
4503
- void shading.finally(() => {
4504
- if (this.displaying === shading) this.displaying = null;
4505
- });
4506
- }
4507
- flushDriver() {
4508
- const d = this.driver;
4509
- if (!d || !this.world) return;
4510
- const rects = d.flush();
4511
- if (rects.length) this.syncDisplays(DISPLAY_TILES_PER_TICK);
4512
- if (d.done) this.onDriverDone?.(d);
4513
- }
4514
- /** Called when a live stroke that ended mid-batch has finished applying. */
4515
- onDriverDone = null;
4516
- /** Steps `steps` times now (agents, tests). Returns the steps run. */
4517
- async advance(steps) {
4518
- await this.idle();
4519
- const w = this.world;
4520
- if (!w) return 0;
4521
- await w.ready;
4522
- await this.idle();
4523
- const start = w.clock;
4524
- const n = Math.max(0, Math.floor(steps));
4525
- const job = (async () => {
4526
- try {
4527
- await w.step(n);
4528
- } finally {
4529
- this.ticking = null;
4530
- }
4531
- })();
4532
- this.ticking = job;
4533
- await job;
4534
- if (w.gpuActive) await this.syncDisplaysGpu(Infinity);
4535
- else this.syncDisplays();
4536
- this.retireDry();
4537
- return w.clock - start;
4538
- }
4539
- /**
4540
- * Dries at once (everything settles where it is), on one layer or all, optionally only in a
4541
- * rectangle. Returns the layers and rectangles that dried.
4542
- */
4543
- dry(layerId, rect) {
4544
- const w = this.world;
4545
- if (!w) return [];
4546
- const ids = layerId !== void 0 ? w.hasLane(layerId) ? [layerId] : [] : w.laneIds();
4547
- const out = [];
4548
- for (const id of ids) out.push({ layerId: id, bounds: w.dry(id, rect) });
4549
- this.syncDisplays();
4550
- this.retireDry();
4551
- return out;
4552
- }
4553
- /** The layer's light paint with all its wet paint dried (`null` without a wet layer or light), without changing anything. */
4554
- bakedLight(layerId) {
4555
- const w = this.world;
4556
- if (!w?.hasLane(layerId) || !w.isLit(layerId)) return null;
4557
- return w.bakedLight(layerId);
4558
- }
4559
- /** The layer's pixels with all its wet paint dried (deterministic), without changing anything. */
4560
- bakedSurface(layerId) {
4561
- const w = this.world;
4562
- if (!w?.hasLane(layerId)) return null;
4563
- return w.bakedSurface(layerId);
4564
- }
4565
- /** A snapshot of the world for undo (`null` when there is none). */
4566
- snapshot() {
4567
- const w = this.world;
4568
- if (!w || !w.laneIds().length) return null;
4569
- return w.snapshot();
4570
- }
4571
- /** Puts a snapshot back (`null`: no wet layers). Returns the layer surfaces it implies. */
4572
- restore(snap) {
4573
- if (!snap) {
4574
- if (this.world) for (const id of this.world.laneIds()) this.world.removeLane(id);
4575
- return;
4576
- }
4577
- const { width, height } = this.host.canvas();
4578
- if (this.world && (this.world.width !== width || this.world.height !== height)) this.dropWorld();
4579
- this.ensureWorld().restore(snap);
4580
- this.syncDisplays();
4581
- }
4582
- dropWorld() {
4583
- this.world?.destroy();
4584
- this.world = null;
4585
- this.driver = null;
4586
- }
4587
- /** Forgets everything wet (a new canvas, a resize, opening a document). */
4588
- reset() {
4589
- this.world?.destroy();
4590
- this.world = null;
4591
- this.driver = null;
4592
- this.settings.clear();
4593
- this.lastTick = null;
4594
- this.stepsOwed = 0;
4595
- }
4596
- /** Water overlay tiles (RGBA) over a document rectangle, for the view. */
4597
- waterOverlay(rect) {
4598
- const w = this.world;
4599
- if (!w) return [];
4600
- const out = [];
4601
- const tx0 = Math.max(0, Math.floor(rect.x / TILE));
4602
- const ty0 = Math.max(0, Math.floor(rect.y / TILE));
4603
- const tx1 = Math.floor((rect.x + rect.width - 1) / TILE);
4604
- const ty1 = Math.floor((rect.y + rect.height - 1) / TILE);
4605
- for (let ty = ty0; ty <= ty1; ty++)
4606
- for (let tx = tx0; tx <= tx1; tx++) {
4607
- let merged = null;
4608
- for (const id of w.laneIds()) {
4609
- const t = w.waterOverlay(id, tx, ty);
4610
- if (!t) continue;
4611
- if (!merged) merged = t;
4612
- else for (let i = 3; i < t.length; i += 4) merged[i] = Math.max(merged[i], t[i]);
4613
- }
4614
- if (merged) out.push({ tx, ty, data: merged });
4615
- }
4616
- return out;
4617
- }
4618
- destroy() {
4619
- this.world?.destroy();
4620
- this.world = null;
4621
- this.gpu?.destroy();
4622
- this.gpu = null;
4623
- }
4624
- };
4625
- function wetPigmentOf(hex2, body) {
4626
- const [r, g, b] = hexToRgb8(hex2);
4627
- return Array.from(pigmentOf([r / 255, g / 255, b / 255], body));
4628
- }
4629
-
4630
3055
  // src/paint-editor/thumbnails.ts
4631
3056
  var thumbWorld = null;
4632
3057
  function paintWetTestStroke(brush, paper, color, width, height) {
@@ -4672,7 +3097,7 @@ function thumbnailStroke(width, height) {
4672
3097
  }
4673
3098
  return pts;
4674
3099
  }
4675
- function paintTestStroke(surface, brush, paper, color, width, height) {
3100
+ function paintTestStroke(surface, brush, paper, color, width, height, stampArt) {
4676
3101
  const [r, g, b] = hexToRgb8(color);
4677
3102
  let result = surface;
4678
3103
  let thick = null;
@@ -4704,7 +3129,8 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
4704
3129
  mixerLoad: brushLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255]),
4705
3130
  thick: brush.engine === "impasto" ? { channels: emptyThickChannels() } : void 0,
4706
3131
  sticker: brush.engine === "sticker" ? { nextId: 1 } : void 0,
4707
- light: brush.light.amount > 0 && brush.engine !== "wet" && brush.engine !== "sticker" ? { surface: emptyLight() } : null
3132
+ light: brush.light.amount > 0 && brush.engine !== "wet" && brush.engine !== "sticker" ? { surface: emptyLight() } : null,
3133
+ stampArt
4708
3134
  }
4709
3135
  );
4710
3136
  session.push(thumbnailStroke(width, height));
@@ -4729,7 +3155,7 @@ function renderBrushThumbnail(brush, options) {
4729
3155
  }
4730
3156
  let light = null;
4731
3157
  if (b.engine === "wet") surface = paintWetTestStroke(b, options.paper, options.color, width, height);
4732
- else ({ surface, light } = paintTestStroke(surface, b, options.paper, options.color, width, height));
3158
+ else ({ surface, light } = paintTestStroke(surface, b, options.paper, options.color, width, height, options.stampArt));
4733
3159
  const layer = { type: "pixel", id: "thumbnail", name: "thumbnail", visible: true, opacity: 1, blend: "normal", surface, offset: { x: 0, y: 0 } };
4734
3160
  const paper = light && brushPreviewsOnDark(b) ? { ...options.paper, color: BRUSH_PREVIEW_DARK_PAPER } : options.paper;
4735
3161
  const spill = light ? computeSpill(surface, light, { radius: Math.max(4, height / 6), strength: 1 }) : null;
@@ -4922,9 +3348,9 @@ function perspectiveGuideLines(g, size, count = 12) {
4922
3348
  }
4923
3349
 
4924
3350
  // src/paint-editor/color.ts
4925
- var HEX2 = /^#?([0-9a-f]{6})$/i;
3351
+ var HEX = /^#?([0-9a-f]{6})$/i;
4926
3352
  function parseHexColor(value, what = "color") {
4927
- const m = typeof value === "string" ? HEX2.exec(value.trim()) : null;
3353
+ const m = typeof value === "string" ? HEX.exec(value.trim()) : null;
4928
3354
  if (!m) throw new Error(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
4929
3355
  const n = parseInt(m[1], 16);
4930
3356
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
@@ -5279,6 +3705,8 @@ function samplePath(d, options = {}) {
5279
3705
  if (!(spacing >= 0.25 && spacing <= 100)) throw new Error(`spacing ${spacing} is out of range; use 0.25..100 document pixels.`);
5280
3706
  const profile = options.pressure;
5281
3707
  if (profile && (!profile.length || profile.some((p) => !(p >= 0 && p <= 1)))) throw new Error("pressure needs one or more values from 0 to 1, e.g. [0.2, 1, 0.2] for a taper.");
3708
+ const speed = options.speed;
3709
+ if (speed !== void 0 && !(speed >= 0.01 && speed <= 100)) throw new Error(`speed ${speed} is out of range; use 0.01..100 document pixels per millisecond.`);
5282
3710
  let contours;
5283
3711
  try {
5284
3712
  contours = pathToContours(d);
@@ -5306,7 +3734,10 @@ function samplePath(d, options = {}) {
5306
3734
  const b = line[Math.min(j + 1, line.length - 1)];
5307
3735
  const x = Math.round((a[0] + (b[0] - a[0]) * f) * 1e3) / 1e3;
5308
3736
  const y = Math.round((a[1] + (b[1] - a[1]) * f) * 1e3) / 1e3;
5309
- points.push(profile ? { x, y, pressure: Math.round(profileAt(profile, k / n) * 1e3) / 1e3 } : { x, y });
3737
+ const p = { x, y };
3738
+ if (profile) p.pressure = Math.round(profileAt(profile, k / n) * 1e3) / 1e3;
3739
+ if (speed !== void 0) p.t = Math.round(at / speed * 1e3) / 1e3;
3740
+ points.push(p);
5310
3741
  }
5311
3742
  out.push(points);
5312
3743
  }
@@ -5314,6 +3745,59 @@ function samplePath(d, options = {}) {
5314
3745
  return out;
5315
3746
  }
5316
3747
 
3748
+ // src/paint-editor/brush-source-art.ts
3749
+ var BrushSourceArtCache = class {
3750
+ sources = /* @__PURE__ */ new Map();
3751
+ art = /* @__PURE__ */ new Map();
3752
+ /** Adds or replaces sources (a replaced image is decoded again). */
3753
+ set(sources) {
3754
+ for (const s of sources) {
3755
+ const was = this.sources.get(s.id);
3756
+ if (was && was.image === s.image) continue;
3757
+ this.sources.set(s.id, s);
3758
+ this.art.delete(s.id);
3759
+ }
3760
+ }
3761
+ /** The source of an id, if known. */
3762
+ source(id) {
3763
+ return this.sources.get(id);
3764
+ }
3765
+ /** Every known source. */
3766
+ all() {
3767
+ return [...this.sources.values()];
3768
+ }
3769
+ /** The art of a `source:<id>` ref (alpha = the mask), or `undefined` when the source is unknown or its image can't be read. */
3770
+ get = (ref2) => {
3771
+ const id = brushSourceIdOf(ref2);
3772
+ if (id === null) return void 0;
3773
+ if (this.art.has(id)) return this.art.get(id) ?? void 0;
3774
+ const source = this.sources.get(id);
3775
+ if (!source) return void 0;
3776
+ let art = null;
3777
+ try {
3778
+ const { width, height, mask } = decodeBrushSource(source);
3779
+ const data = new Uint8ClampedArray(width * height * 4);
3780
+ for (let i = 0; i < mask.length; i++) {
3781
+ data[i * 4] = data[i * 4 + 1] = data[i * 4 + 2] = 255;
3782
+ data[i * 4 + 3] = mask[i];
3783
+ }
3784
+ art = { width, height, data, tintable: true };
3785
+ } catch {
3786
+ art = null;
3787
+ }
3788
+ this.art.set(id, art);
3789
+ return art ?? void 0;
3790
+ };
3791
+ /** A lookup that reads sources from here and every other ref from `stickers`. */
3792
+ with(stickers) {
3793
+ return (ref2, variant) => isBrushSourceRef(ref2) ? this.get(ref2) : stickers(ref2, variant);
3794
+ }
3795
+ };
3796
+ function engineBrushSourceIds(brush) {
3797
+ const refs = [brush.stamp, brush.tipGrain?.source, ...brush.particle?.dotTextures ?? []];
3798
+ return [...new Set(refs.filter((r) => !!r && isBrushSourceRef(r)).map((r) => brushSourceIdOf(r)))];
3799
+ }
3800
+
5317
3801
  // src/paint-editor/live-strokes.ts
5318
3802
  var LIVE_STROKE_ENGINES = ["dab", "particle", "bristle", "mixer"];
5319
3803
  function liveStrokeRefusal(brush) {
@@ -5904,10 +4388,10 @@ async function libraryCall(call) {
5904
4388
  throw e instanceof BrushLibraryError ? new PaintEditorError(e.message) : e;
5905
4389
  }
5906
4390
  }
5907
- var HEX3 = /^#[0-9a-f]{6}$/i;
4391
+ var HEX2 = /^#[0-9a-f]{6}$/i;
5908
4392
  var MAX_ADD_STICKERS = 500;
5909
4393
  function parseColor(value, what = "color") {
5910
- if (typeof value !== "string" || !HEX3.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
4394
+ if (typeof value !== "string" || !HEX2.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
5911
4395
  return value.trim().toLowerCase();
5912
4396
  }
5913
4397
  function rgbaOf(hex2) {
@@ -6023,6 +4507,10 @@ var PaintEditorController = class _PaintEditorController {
6023
4507
  /** The sticker library slot (see `setStickersProvider`). */
6024
4508
  stickers = new StickersSlot(editorStickers());
6025
4509
  stickerArt = new StickerArtCache();
4510
+ /** Image sources strokes draw with: the brush library's and the open painting's (saved with it). */
4511
+ brushSources = new BrushSourceArtCache();
4512
+ /** Art for stamp tips and tip grain: image sources from `brushSources`, stickers from the sticker library. */
4513
+ stampArt = (ref2, variant) => isBrushSourceRef(ref2) ? this.brushSources.get(ref2) : this.stickerArt.get(ref2, variant);
6026
4514
  rasterizeSticker;
6027
4515
  effectsRenderer = new PaintEffectsRenderer();
6028
4516
  paperEffectNodeCache = null;
@@ -6141,36 +4629,82 @@ var PaintEditorController = class _PaintEditorController {
6141
4629
  }
6142
4630
  /**
6143
4631
  * Runs `fn` as one undo step named `label`: every change it makes (strokes, fills, new layers) is
6144
- * undone together. Wet paint can't be grouped. Nested groups join the outer one. If `fn` throws,
6145
- * its changes are put back and nothing is recorded.
4632
+ * undone together. When `fn` returns a promise, the group holds until it settles; until then other
4633
+ * changes (the user's strokes, undo) are refused, except those `fn` makes through `within`, so
4634
+ * nothing else lands in (or is lost with) the group's step. Nested groups join the outer one. If
4635
+ * `fn` throws or rejects, its changes are put back and nothing is recorded.
6146
4636
  */
6147
4637
  group(label, fn) {
6148
- if (this.grouping) return fn();
4638
+ if (this.grouping && (!this.grouping.pending || this.grouping.inside)) return fn((step) => step());
6149
4639
  this.requireIdle("grouping changes");
6150
4640
  const before = this.doc;
6151
4641
  const revision = this.docRevision;
6152
- this.grouping = { label, before, changed: false };
6153
- let result;
6154
- try {
6155
- result = fn();
6156
- } catch (e) {
4642
+ const strokesBefore = this.strokeCounter;
4643
+ const origin = this.getOrigin();
4644
+ const g = { label, before, changed: false, pending: false, inside: 0 };
4645
+ this.grouping = g;
4646
+ const within = (step) => {
4647
+ if (this.grouping !== g) return step();
4648
+ g.inside++;
4649
+ try {
4650
+ return step();
4651
+ } finally {
4652
+ g.inside--;
4653
+ }
4654
+ };
4655
+ const rollback = () => {
6157
4656
  this.grouping = null;
4657
+ this.strokeCounter = strokesBefore;
4658
+ if (this.wetOp) {
4659
+ const snap = this.wetOp.before;
4660
+ this.wetOp = null;
4661
+ this.pendingWetEvents = [];
4662
+ this.wet.restore(snap);
4663
+ }
6158
4664
  if (this.doc !== before) {
6159
4665
  this.doc = before;
6160
4666
  this.docRevision = revision + 1;
4667
+ this.wet.syncDisplays();
4668
+ this.emit();
4669
+ }
4670
+ };
4671
+ const finish = () => {
4672
+ this.grouping = null;
4673
+ if (g.changed && this.doc !== before || this.wetOp) {
4674
+ const entry = { label: g.label, origin, before, after: this.doc };
4675
+ if (this.wetOp) {
4676
+ entry.wet = { before: this.wetOp.before, after: this.wet.snapshot(), lost: false };
4677
+ this.wetOp = null;
4678
+ }
4679
+ this.undoStack.push(entry);
4680
+ if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
4681
+ this.redoStack = [];
4682
+ this.trimWetHistory();
4683
+ this.notifyCommit({ kind: "execute", label: g.label, origin: entry.origin });
6161
4684
  this.emit();
6162
4685
  }
4686
+ };
4687
+ let result;
4688
+ try {
4689
+ result = fn(within);
4690
+ } catch (e) {
4691
+ rollback();
6163
4692
  throw e;
6164
4693
  }
6165
- const g = this.grouping;
6166
- this.grouping = null;
6167
- if (g.changed && this.doc !== before) {
6168
- this.undoStack.push({ label, origin: this.getOrigin(), before, after: this.doc });
6169
- if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
6170
- this.redoStack = [];
6171
- this.notifyCommit({ kind: "execute", label, origin: this.getOrigin() });
6172
- this.emit();
4694
+ if (result && typeof result.then === "function") {
4695
+ g.pending = true;
4696
+ return Promise.resolve(result).then(
4697
+ (val) => {
4698
+ finish();
4699
+ return val;
4700
+ },
4701
+ (e) => {
4702
+ rollback();
4703
+ throw e;
4704
+ }
4705
+ );
6173
4706
  }
4707
+ finish();
6174
4708
  return result;
6175
4709
  }
6176
4710
  /**
@@ -6297,11 +4831,13 @@ var PaintEditorController = class _PaintEditorController {
6297
4831
  /** Why the document cannot be replaced or stepped through right now, or `null`. */
6298
4832
  busyReason() {
6299
4833
  if (this.session) return "A stroke is in progress; end or cancel it first.";
4834
+ if (this.grouping?.pending && !this.grouping.inside) return `"${this.grouping.label}" is still being painted; wait for it to finish first.`;
6300
4835
  if (this.wet.busy) return "Wet paint is mid-step; wait a moment (wetIdle()) first.";
6301
4836
  return null;
6302
4837
  }
6303
4838
  requireIdle(what) {
6304
4839
  if (this.session) throw new PaintEditorError(`A stroke is in progress; end or cancel it before ${what}.`);
4840
+ if (this.grouping?.pending && !this.grouping.inside) throw new PaintEditorError(`"${this.grouping.label}" is still being painted (one undo step); wait for it to finish before ${what}.`);
6305
4841
  }
6306
4842
  commit(label, next, before = this.doc) {
6307
4843
  if (this.grouping) {
@@ -7338,7 +5874,10 @@ var PaintEditorController = class _PaintEditorController {
7338
5874
  }
7339
5875
  /** Reads the library again (after a change anywhere it is shared). */
7340
5876
  refreshBrushes() {
7341
- const run = this.brushLibrary.list().then((entries) => {
5877
+ const library = this.brushLibrary;
5878
+ const sources = typeof library.listSources === "function" ? library.listSources().catch(() => []) : Promise.resolve([]);
5879
+ const run = Promise.all([library.list(), sources]).then(([entries, list]) => {
5880
+ this.brushSources.set(list);
7342
5881
  this.brushEntries = entries.map((e) => libraryBrush(e.brush, e.builtin));
7343
5882
  this.library = null;
7344
5883
  const pending = this.pendingBrushId;
@@ -7485,7 +6024,7 @@ var PaintEditorController = class _PaintEditorController {
7485
6024
  /** A thumbnail of a brush: a test stroke on the current paper. */
7486
6025
  brushThumbnail(id, options = {}) {
7487
6026
  const b = id === this.brush.id ? this.brush : this.requireBrush(id);
7488
- return renderBrushThumbnail(b, { paper: this.doc.paper, width: options.width, height: options.height, color: options.color ? parseColor(options.color) : this.color });
6027
+ return renderBrushThumbnail(b, { paper: this.doc.paper, width: options.width, height: options.height, color: options.color ? parseColor(options.color) : this.color, stampArt: this.stampArt });
7489
6028
  }
7490
6029
  // --- Colour --------------------------------------------------------------------
7491
6030
  colorScheme;
@@ -7788,7 +6327,7 @@ var PaintEditorController = class _PaintEditorController {
7788
6327
  // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
7789
6328
  light: !wet && brush.engine !== "sticker" && (brush.light.amount > 0 || doc.light?.[layer.id]) ? { surface: doc.light?.[layer.id] ?? emptyLight() } : null,
7790
6329
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc), art: this.stickerArt.get, variant: (ref2) => this.stickerArt.variantFor(ref2, this.colorScheme === "dark") } : void 0,
7791
- stampArt: this.stickerArt.get,
6330
+ stampArt: this.stampArt,
7792
6331
  paint: effect ? null : this.strokeGradient(init, brush, color),
7793
6332
  effect
7794
6333
  });
@@ -7968,7 +6507,7 @@ var PaintEditorController = class _PaintEditorController {
7968
6507
  } catch (e) {
7969
6508
  throw new PaintEditorError(e instanceof Error ? e.message : String(e));
7970
6509
  }
7971
- const { path: _path, spacing: _spacing, pressure: _pressure, ...stroke } = init;
6510
+ const { path: _path, spacing: _spacing, pressure: _pressure, speed: _speed, ...stroke } = init;
7972
6511
  const before = this.doc;
7973
6512
  let doc = this.doc;
7974
6513
  let bounds = null;
@@ -8166,6 +6705,7 @@ var PaintEditorController = class _PaintEditorController {
8166
6705
  if (w > 16384 || h > 16384) throw new PaintEditorError(`At scale ${scale} the canvas would be ${w}\xD7${h}; the limit is 16384 px per side. Use a smaller scale.`);
8167
6706
  const target = new _PaintEditorController({ width: w, height: h, dpi: dpi * scale, brushLibrary: this.brushLibrary, wet: this.wetOptions });
8168
6707
  target.brushEntries = this.brushEntries;
6708
+ target.brushSources.set(this.brushSources.all());
8169
6709
  const paper = { ...this.doc.paper, scale: Math.min(8, this.doc.paper.scale * scale) };
8170
6710
  target.adopt({ state: { canvas: target.doc.canvas, paper, layers: emptiedLayers(this.doc.layers), activeLayerId: this.doc.activeLayerId, strokes: [] }, extensions: {}, problems: [] });
8171
6711
  target.lighting = { ...this.lighting };
@@ -8604,6 +7144,11 @@ var PaintEditorController = class _PaintEditorController {
8604
7144
  for (const [id, list] of Object.entries(this.doc.liveStrokes ?? {})) if (findLayer(this.doc.layers, id)) liveStrokes[id] = list.map(strokeToJSON);
8605
7145
  if (Object.keys(liveStrokes).length) ext.liveStrokes = liveStrokes;
8606
7146
  else delete ext.liveStrokes;
7147
+ const strokes = [...this.doc.strokes, ...Object.values(this.doc.liveStrokes ?? {}).flat()];
7148
+ const sourceIds = [...new Set(strokes.flatMap((st) => engineBrushSourceIds(st.brush)))];
7149
+ const sources = sourceIds.map((id) => this.brushSources.source(id)).filter((x) => x !== void 0);
7150
+ if (sources.length) ext.brushSources = sources.map((x) => ({ ...x }));
7151
+ else delete ext.brushSources;
8607
7152
  const light = {};
8608
7153
  for (const [id, t] of Object.entries(this.doc.light ?? {})) {
8609
7154
  if (!findLayer(this.doc.layers, id)) continue;
@@ -8626,6 +7171,7 @@ var PaintEditorController = class _PaintEditorController {
8626
7171
  }
8627
7172
  /** Reads `layerChannels` and `lighting` from loaded extensions into the document. */
8628
7173
  readThick(state, ext, problems) {
7174
+ if (Array.isArray(ext.brushSources)) this.brushSources.set(ext.brushSources.map((x, i) => readBrushSource(x, `brushSources[${i}]`, problems)).filter((x) => x !== null));
8629
7175
  this.lighting = normalizeLighting(ext.lighting ?? {}, DEFAULT_LIGHTING);
8630
7176
  this.litCaches.clear();
8631
7177
  if (ext.stickers && typeof ext.stickers === "object") {
@@ -8744,6 +7290,7 @@ var PaintEditorController = class _PaintEditorController {
8744
7290
  delete this.extensions.lighting;
8745
7291
  delete this.extensions.stickers;
8746
7292
  delete this.extensions.liveStrokes;
7293
+ delete this.extensions.brushSources;
8747
7294
  delete this.extensions.effectLayers;
8748
7295
  delete this.extensions.paperEffect;
8749
7296
  this.viewCompositor.clear();
@@ -10164,6 +8711,9 @@ var PaintDirector = class {
10164
8711
  case "stroke":
10165
8712
  await this.say(id, command.note, 500);
10166
8713
  return this.stroke(id, command.stroke);
8714
+ case "strokes":
8715
+ await this.say(id, command.note, 500);
8716
+ return this.strokes(id, command.strokes, command.label);
10167
8717
  case "fill": {
10168
8718
  await this.say(id, command.note, 500);
10169
8719
  const { width, height } = c.getState().document.canvas;
@@ -10189,11 +8739,11 @@ var PaintDirector = class {
10189
8739
  }
10190
8740
  }
10191
8741
  /** Paints a stroke live: begins it, pushes its points frame by frame as the cursor reaches them, then ends it (one undo step). */
10192
- async stroke(id, init) {
8742
+ async stroke(id, init, within = (step) => step()) {
10193
8743
  const c = this.controller;
10194
8744
  const points = c.checkPoints(init.points);
10195
8745
  const { points: _points, ...rest } = init;
10196
- const session = c.beginStroke({ snapToPerspective: false, start: points[0], ...rest });
8746
+ const session = within(() => c.beginStroke({ snapToPerspective: false, start: points[0], ...rest }));
10197
8747
  try {
10198
8748
  await this.glide(id, points[0]);
10199
8749
  this.presence.set({ id, pressed: true });
@@ -10221,7 +8771,46 @@ var PaintDirector = class {
10221
8771
  throw error;
10222
8772
  }
10223
8773
  }
8774
+ /** Paints several strokes live in turn and commits them as one undo step (e.g. a path's subpaths). */
8775
+ async strokes(id, inits, label) {
8776
+ const c = this.controller;
8777
+ if (!Array.isArray(inits) || !inits.length) throw new PaintEditorError("strokes is empty; give at least one stroke.");
8778
+ const checked = inits.map((s) => ({ ...s, points: c.checkPoints(s.points) }));
8779
+ const first = checked[0];
8780
+ const base = first.brushId ? c.getBrush(first.brushId) : c.getState().brush;
8781
+ const stepLabel = label ?? `${base?.name ?? "Brush"} path stroke`;
8782
+ return c.group(stepLabel, async (within) => {
8783
+ let layerId = first.layerId ?? c.document.activeLayerId ?? "";
8784
+ let bounds = null;
8785
+ let dabs = 0;
8786
+ const strokeIds = [];
8787
+ let last = null;
8788
+ for (const s of checked) {
8789
+ const r = await this.stroke(id, s, within);
8790
+ layerId = r.layerId;
8791
+ bounds = unionRect2(bounds, r.bounds);
8792
+ dabs += r.dabs;
8793
+ if (r.strokeId) strokeIds.push(r.strokeId);
8794
+ last = r;
8795
+ }
8796
+ return {
8797
+ layerId,
8798
+ bounds,
8799
+ dabs,
8800
+ strokeIds,
8801
+ ...last?.mixerLoad ? { mixerLoad: last.mixerLoad } : {},
8802
+ ...last?.wet !== void 0 ? { wet: last.wet } : {}
8803
+ };
8804
+ });
8805
+ }
10224
8806
  };
8807
+ function unionRect2(a, b) {
8808
+ if (!a) return b ? { ...b } : null;
8809
+ if (!b) return { ...a };
8810
+ const x = Math.min(a.x, b.x);
8811
+ const y = Math.min(a.y, b.y);
8812
+ return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
8813
+ }
10225
8814
 
10226
8815
  // src/paint-editor/playback.ts
10227
8816
  var WET = /^(advance_wet_paint|dry_paint|wet_paper|set_wet_settings|set_flow_map|set_canvas_tilt|pause_wet_paint|set_wet_engine|set_water_overlay)$/;
@@ -10253,7 +8842,7 @@ function planPaintToolPlayback(tool, _args) {
10253
8842
  if (control) steps.push({ type: "target", ref: control }, { type: "press" }, { type: "release" });
10254
8843
  return steps.length ? steps : null;
10255
8844
  }
10256
- var DIRECTED = /* @__PURE__ */ new Set(["paint_stroke", "fill_layer", "replay_strokes"]);
8845
+ var DIRECTED = /* @__PURE__ */ new Set(["paint_stroke", "stroke_path", "fill_layer", "replay_strokes"]);
10257
8846
  function withPaintPlayback(provider, options) {
10258
8847
  const plan = (tool) => {
10259
8848
  if (tool.plan || tool.playback !== void 0) return tool;
@@ -11288,7 +9877,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
11288
9877
  const add = (t) => tools.push(t);
11289
9878
  const act = async (command, direct) => {
11290
9879
  if (!options.director) return direct();
11291
- const [r] = await options.director.perform(options.actor ?? { id: "agent", name: "Agent" }, [command]);
9880
+ const cmd = typeof command === "function" ? command() : command;
9881
+ const [r] = await options.director.perform(options.actor ?? { id: "agent", name: "Agent" }, [cmd]);
11292
9882
  if (!r || !r.ok) throw new Error(r && !r.ok ? r.message : "The director did not run the command.");
11293
9883
  return r.result;
11294
9884
  };
@@ -11394,7 +9984,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11394
9984
  annotations: { title: "Get the paper", ...READ6 },
11395
9985
  handler: () => JSON.parse(JSON.stringify(c.document.paper))
11396
9986
  });
11397
- const tuning2 = {
9987
+ const tuning = {
11398
9988
  type: "object",
11399
9989
  properties: {
11400
9990
  contrast: { type: "number", minimum: 0, maximum: 3, description: "Height contrast around the middle (default 1)." },
@@ -11417,8 +10007,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
11417
10007
  pattern: { type: "string", enum: [...GRAIN_PATTERNS], description: "Grain pattern." },
11418
10008
  tiling: { type: "string", enum: ["repeat", "mirror"], description: "How the grain tile repeats (mirror hides seams)." },
11419
10009
  shading: { type: "number", minimum: 0, maximum: 1, description: "How much the grain shades the paper colour." },
11420
- dry: { ...tuning2, description: "Height map tuning for dry media." },
11421
- wet: { ...tuning2, description: "Height map tuning for wet media (the grain water and pigment follow)." },
10010
+ dry: { ...tuning, description: "Height map tuning for dry media." },
10011
+ wet: { ...tuning, description: "Height map tuning for wet media (the grain water and pigment follow)." },
11422
10012
  absorption: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how fast the paper soaks up water (high: washes stop spreading sooner)." },
11423
10013
  permeability: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how freely water flows over the fibres (low: more ragged, slower spreading)." },
11424
10014
  roughness: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how much the grain pins wet edges and catches granulating pigment." },
@@ -11737,13 +10327,14 @@ function createPaintEditorToolProvider(controller, options = {}) {
11737
10327
  add({
11738
10328
  name: "stroke_path",
11739
10329
  title: "Brush along a path",
11740
- description: "Brushes vector path data (SVG path d in document pixels: M, L, H, V, C, S, Q, T, A and Z) on a pixel layer with a library brush or the active one, as if drawn by hand along it: the outline of a shape, a lettering guide, a curve from a vector tool. Each subpath becomes one recorded stroke, sampled every spacing px, with an optional pressure profile spread along each subpath (a taper such as [0.2, 1, 0.2]); closed subpaths end where they start. Brushes behave as in paint_stroke (grain, pick-up, wet and thick paint, guides off). One undo step for the whole path. Returns the layer id, changed bounds, dab count and the recorded strokeIds (for replay_strokes, update_stroke).",
10330
+ description: "Brushes vector path data (SVG path d in document pixels: M, L, H, V, C, S, Q, T, A and Z) on a pixel layer with a library brush or the active one, as if drawn by hand along it: the outline of a shape, a lettering guide, a curve from a vector tool. Each subpath becomes one recorded stroke, sampled every spacing px, with an optional pressure profile spread along each subpath (a taper such as [0.2, 1, 0.2]); closed subpaths end where they start. Brushes behave as in paint_stroke (grain, pick-up, wet and thick paint, guides off). With an agent cursor (a director), each subpath is painted live in turn. One undo step for the whole path. Returns the layer id, changed bounds, dab count and the recorded strokeIds (for replay_strokes, update_stroke).",
11741
10331
  inputSchema: {
11742
10332
  type: "object",
11743
10333
  properties: {
11744
10334
  path: { type: "string", minLength: 1, description: 'SVG path data in document pixels, e.g. "M 40 200 C 120 40 280 40 360 200". Several subpaths paint several strokes.' },
11745
10335
  spacing: { type: "number", minimum: 0.25, maximum: 100, description: "Distance between samples along the path in document pixels (default 2); the brush spaces its own dabs, so this only sets how closely curves are followed." },
11746
10336
  pressure: { type: "array", minItems: 1, maxItems: 64, items: { type: "number", minimum: 0, maximum: 1 }, description: "Pen pressure along each subpath, spread evenly from start to end (default full pressure); [0.2, 1, 0.2] tapers both ends." },
10337
+ speed: { type: "number", minimum: 0.01, maximum: 100, description: "Drawing speed in document pixels per millisecond (optional; gives each sample a time t so playback has a pace and speed-sensitive brushes respond)." },
11747
10338
  layerId: LAYER_ID4,
11748
10339
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
11749
10340
  brush: { type: "object", description: "Facets to change for this stroke only, laid over brushId or the active brush (as set_brush takes them).", properties: BRUSH_PATCH_PROPERTIES, additionalProperties: false },
@@ -11765,7 +10356,30 @@ function createPaintEditorToolProvider(controller, options = {}) {
11765
10356
  if (refs.length) await c.prepareStickers(refs);
11766
10357
  }
11767
10358
  const path = options.origin && typeof a.path === "string" && a.path.trim() ? bezierPathToSvg(a.path, { offset: { x: -options.origin.x, y: -options.origin.y } }) : a.path;
11768
- const r = c.strokePath({ path, spacing: a.spacing, pressure: a.pressure, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed });
10359
+ const init = { path, spacing: a.spacing, pressure: a.pressure, speed: a.speed, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed };
10360
+ const r = await act(
10361
+ () => {
10362
+ if (typeof path !== "string" || !path.trim()) throw new PaintEditorError('path is empty; give SVG path data in document pixels, e.g. "M 10 10 C 40 0 80 60 120 30".');
10363
+ let subpaths;
10364
+ try {
10365
+ subpaths = samplePath(path, init);
10366
+ } catch (e) {
10367
+ throw new PaintEditorError(e instanceof Error ? e.message : String(e));
10368
+ }
10369
+ const { path: _path, spacing: _spacing, pressure: _pressure, speed: _speed, ...stroke } = init;
10370
+ return {
10371
+ kind: "strokes",
10372
+ label: `${base?.name ?? "Brush"} path stroke`,
10373
+ strokes: subpaths.map((points, i) => ({
10374
+ ...stroke,
10375
+ ...stroke.seed !== void 0 ? { seed: stroke.seed + i } : {},
10376
+ snapToPerspective: false,
10377
+ points
10378
+ }))
10379
+ };
10380
+ },
10381
+ () => c.strokePath(init)
10382
+ );
11769
10383
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11770
10384
  }
11771
10385
  });
@@ -12154,11 +10768,23 @@ function strokeSummary(s, withPoints, live) {
12154
10768
  }
12155
10769
 
12156
10770
  // src/paint-editor/brush-library-tools.ts
12157
- function paintBrushPreviewRenderer(paper = () => DEFAULT_PAPER) {
12158
- const render = (brush, { width, height, color }) => {
12159
- const t = renderBrushThumbnail(resolveBrush(brush), { paper: paper(), width, height, color: color.toLowerCase() });
10771
+ function paintBrushPreviewRenderer(paper = () => DEFAULT_PAPER, options = {}) {
10772
+ const sources = new BrushSourceArtCache();
10773
+ const draw = (brush, { width, height, color }) => {
10774
+ const stickers = options.stampArt?.();
10775
+ const stampArt = sources.with(stickers ?? (() => void 0));
10776
+ const t = renderBrushThumbnail(resolveBrush(brush), { paper: paper(), width, height, color: color.toLowerCase(), stampArt });
12160
10777
  return { data: bytesToBase64(encodePng(t.width, t.height, t.data)), mimeType: "image/png" };
12161
10778
  };
10779
+ const render = (brush, size) => {
10780
+ const ids = brushSourceIds(brush);
10781
+ if (!ids.length || !options.library) return draw(brush, size);
10782
+ const library = options.library;
10783
+ return Promise.all(ids.map((id) => library.getSource(id))).then((found) => {
10784
+ sources.set(found.filter((s) => s !== null));
10785
+ return draw(brush, size);
10786
+ });
10787
+ };
12162
10788
  return Object.defineProperty(render, "paper", { get: () => paper().color, enumerable: true });
12163
10789
  }
12164
10790
  function createPaintBrushLibraryToolProvider(controller, options = {}) {
@@ -12166,7 +10792,7 @@ function createPaintBrushLibraryToolProvider(controller, options = {}) {
12166
10792
  profile: PAINT_PROFILE,
12167
10793
  stickers: () => controller.stickers.provider,
12168
10794
  ...options,
12169
- renderPreview: paintBrushPreviewRenderer(() => controller.getState().document.paper)
10795
+ renderPreview: paintBrushPreviewRenderer(() => controller.getState().document.paper, { library: controller.brushLibrary, stampArt: () => controller.stampArt })
12170
10796
  });
12171
10797
  }
12172
10798
 
@@ -13058,7 +11684,7 @@ function createPaintChangeFeed(controller, options = {}) {
13058
11684
  }
13059
11685
  };
13060
11686
  }
13061
- var HEX4 = /^#[0-9a-f]{6}$/i;
11687
+ var HEX3 = /^#[0-9a-f]{6}$/i;
13062
11688
  var DRY_ENGINES = /* @__PURE__ */ new Set(["dab", "particle", "bristle", "mixer"]);
13063
11689
  function suggestionBrushRefusal(brush) {
13064
11690
  if (!DRY_ENGINES.has(brush.engine)) return `The ${brush.name} brush (${brush.engine}) can't paint a suggestion: suggestions are dry paint on a layer of their own. Use a dab, particle, bristle or mixer brush.`;
@@ -13098,20 +11724,20 @@ function createPaintProposals(options) {
13098
11724
  if (op.kind === "stroke") {
13099
11725
  if (!op.points?.length) throw new PaintEditorError(`${at}: a stroke needs points in document pixels.`);
13100
11726
  points += op.points.length;
13101
- if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
11727
+ if (op.color !== void 0 && !HEX3.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
13102
11728
  const brush = op.brushId ? controller.getBrush(op.brushId) : controller.getState().brush;
13103
11729
  if (!brush) throw new PaintEditorError(`${at}: unknown brush "${op.brushId}". Call list_brush_presets for the ids.`);
13104
11730
  const refusal = suggestionBrushRefusal(brush);
13105
11731
  if (refusal) throw new PaintEditorError(`${at}: ${refusal}`);
13106
11732
  if (op.size !== void 0 && !(op.size > 0)) throw new PaintEditorError(`${at}: size must be above 0 (document pixels).`);
13107
11733
  } else if (op.kind === "wash") {
13108
- if (!HEX4.test(op.color ?? "")) throw new PaintEditorError(`${at}: a wash needs color as #rrggbb.`);
11734
+ if (!HEX3.test(op.color ?? "")) throw new PaintEditorError(`${at}: a wash needs color as #rrggbb.`);
13109
11735
  if (op.opacity !== void 0 && !(op.opacity > 0 && op.opacity <= 1)) throw new PaintEditorError(`${at}: opacity must be in (0, 1].`);
13110
11736
  } else if (op.kind === "echo") {
13111
11737
  if (!op.strokeIds?.length) throw new PaintEditorError(`${at}: an echo needs strokeIds (list_strokes).`);
13112
11738
  const unknown = op.strokeIds.filter((id) => !known.has(id));
13113
11739
  if (unknown.length) throw new PaintEditorError(`${at}: unknown stroke ${unknown.map((u) => `"${u}"`).join(", ")}. Call list_strokes for the ids.`);
13114
- if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
11740
+ if (op.color !== void 0 && !HEX3.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
13115
11741
  if (op.repeat !== void 0 && !(Number.isInteger(op.repeat) && op.repeat >= 1 && op.repeat <= 50)) throw new PaintEditorError(`${at}: repeat must be a whole number from 1 to 50.`);
13116
11742
  for (const id of op.strokeIds) {
13117
11743
  const stroke = controller.getStrokes().find((s) => s.id === id);
@@ -13537,6 +12163,8 @@ export {
13537
12163
  BRUSH_SLIDERS,
13538
12164
  BUILTIN_BRUSHES,
13539
12165
  BristleStroke,
12166
+ BrushSourceArtCache,
12167
+ DAB_FLIPS,
13540
12168
  DEFAULT_BACKLIGHT_COLOR,
13541
12169
  DEFAULT_BEHAVIOR,
13542
12170
  DEFAULT_BRISTLE,
@@ -13555,6 +12183,7 @@ export {
13555
12183
  DEFAULT_SPRAY,
13556
12184
  DEFAULT_STICKER,
13557
12185
  DEFAULT_SWATCHES,
12186
+ DEFAULT_TIP_GRAIN,
13558
12187
  DEFAULT_VIEW,
13559
12188
  DEFAULT_WET_BRUSH,
13560
12189
  DEFAULT_WET_PARAMS,
@@ -13660,9 +12289,11 @@ export {
13660
12289
  StickerArtCache,
13661
12290
  StickerBrowser,
13662
12291
  StickerSpray,
12292
+ TIP_GRAIN_MODES,
13663
12293
  TRACING_NODE_ID,
13664
12294
  ThickStrokeDriver,
13665
12295
  TileableNoise,
12296
+ TipGrain,
13666
12297
  WET_FLOW_KINDS,
13667
12298
  WET_GPU_MODES,
13668
12299
  WET_GPU_OPTIONS,
@@ -13727,6 +12358,7 @@ export {
13727
12358
  effectPaintSize,
13728
12359
  emptiedLayers,
13729
12360
  emptyThickChannels,
12361
+ engineBrushSourceIds,
13730
12362
  fitZoom,
13731
12363
  grainCatch,
13732
12364
  grainHeights,