@pixerate/editor 0.266.1 → 0.267.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 (117) hide show
  1. package/dist/agent/index.cjs +8 -4
  2. package/dist/agent/index.js +6 -2
  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 +172 -105
  8. package/dist/animation-editor/index.js +91 -24
  9. package/dist/brushes/index.cjs +59 -59
  10. package/dist/brushes/index.js +5 -5
  11. package/dist/brushes/profiles.d.cts +6 -1
  12. package/dist/brushes/profiles.d.ts +6 -1
  13. package/dist/{brushstrokeRender-FSUWXUZY.cjs → brushstrokeRender-L7JYRR2R.cjs} +16 -16
  14. package/dist/{brushstrokeRender-KD453OBX.js → brushstrokeRender-NLFPEBGI.js} +4 -4
  15. package/dist/canvas/brushTypes.d.cts +12 -0
  16. package/dist/canvas/brushTypes.d.ts +12 -0
  17. package/dist/canvas/brushstroke.d.cts +1 -1
  18. package/dist/canvas/brushstroke.d.ts +1 -1
  19. package/dist/canvas/drawController.d.cts +19 -4
  20. package/dist/canvas/drawController.d.ts +19 -4
  21. package/dist/canvas/index.cjs +144 -105
  22. package/dist/canvas/index.js +46 -7
  23. package/dist/{chunk-XORJ7UZO.cjs → chunk-3VHH6V67.cjs} +13 -12
  24. package/dist/{chunk-T2LDTEXK.js → chunk-4IDUYB45.js} +2217 -56
  25. package/dist/chunk-7UKFXXWL.js +802 -0
  26. package/dist/chunk-ADMRNWR7.js +340 -0
  27. package/dist/{chunk-CIJBSUM5.js → chunk-AGNUD5BI.js} +2 -2
  28. package/dist/{chunk-WLUVZKVD.cjs → chunk-DT4YQUGT.cjs} +32 -32
  29. package/dist/{chunk-DXYHQ2VN.js → chunk-EF7LZAXQ.js} +1 -1
  30. package/dist/chunk-EXB2EGBE.cjs +802 -0
  31. package/dist/{chunk-4ODRXQRW.js → chunk-EYSHTYLG.js} +1 -1
  32. package/dist/{chunk-R44Y3EFS.js → chunk-FGJHJIMN.js} +8 -336
  33. package/dist/{chunk-AQZQWXEU.cjs → chunk-G7NHE2VD.cjs} +35 -35
  34. package/dist/{chunk-XC6DPRYI.js → chunk-GSTSZPSR.js} +154 -949
  35. package/dist/{chunk-VRK322ZC.cjs → chunk-HIO6E7LC.cjs} +2 -2
  36. package/dist/{chunk-TOID45I2.cjs → chunk-J2TNIZ55.cjs} +1 -1
  37. package/dist/{chunk-YHDHFJUC.cjs → chunk-J5VQYK7T.cjs} +217 -1012
  38. package/dist/{chunk-3C33YTNN.js → chunk-JGWVZPT6.js} +2 -2
  39. package/dist/{chunk-ACFSFUQH.js → chunk-JL6YH5WD.js} +4 -0
  40. package/dist/{chunk-3SBLVGOM.cjs → chunk-LA5Y4ETA.cjs} +23 -23
  41. package/dist/{chunk-MVUQSK5J.cjs → chunk-LHPEG5AU.cjs} +107 -435
  42. package/dist/{chunk-DOZ6F2K2.js → chunk-NSETJI74.js} +3 -2
  43. package/dist/{chunk-XRXFVRGY.cjs → chunk-OKHKA43L.cjs} +2364 -203
  44. package/dist/{chunk-LK4SXL3V.js → chunk-OSEXT7JL.js} +1 -1
  45. package/dist/{chunk-PE6YJMQT.cjs → chunk-PFHJS4HE.cjs} +20 -19
  46. package/dist/{chunk-ZQI3WUPP.js → chunk-QCLY5SFS.js} +12 -0
  47. package/dist/{chunk-7KNW3WXZ.cjs → chunk-QEVLRLUN.cjs} +63 -0
  48. package/dist/{chunk-OTN6VB5S.cjs → chunk-RDFHWAHL.cjs} +5 -1
  49. package/dist/{chunk-6JL2D5SG.js → chunk-VE6UKYQ2.js} +3 -2
  50. package/dist/{chunk-RFJZGTHA.cjs → chunk-XVOP3CGQ.cjs} +9 -9
  51. package/dist/{chunk-EZOJBP4D.js → chunk-YIC6MAJ2.js} +1 -1
  52. package/dist/chunk-YZWM6ZIP.cjs +340 -0
  53. package/dist/{chunk-472INMWH.js → chunk-ZA7L5YJB.js} +63 -0
  54. package/dist/{chunk-YBC3RKT2.cjs → chunk-ZGXJ7BTR.cjs} +13 -1
  55. package/dist/commands/builtins.d.cts +12 -1
  56. package/dist/commands/builtins.d.ts +12 -1
  57. package/dist/commands/index.cjs +2 -2
  58. package/dist/commands/index.js +1 -1
  59. package/dist/image-editor/controller.d.cts +1 -1
  60. package/dist/image-editor/controller.d.ts +1 -1
  61. package/dist/image-editor/index.cjs +837 -833
  62. package/dist/image-editor/index.js +105 -101
  63. package/dist/paint/index.cjs +5 -3
  64. package/dist/paint/index.js +46 -44
  65. package/dist/paint-editor/controller.d.cts +5 -3
  66. package/dist/paint-editor/controller.d.ts +5 -3
  67. package/dist/paint-editor/index.cjs +832 -2295
  68. package/dist/paint-editor/index.js +285 -1748
  69. package/dist/paint-editor/path-samples.d.cts +2 -0
  70. package/dist/paint-editor/path-samples.d.ts +2 -0
  71. package/dist/paint-editor/presence.d.cts +20 -1
  72. package/dist/paint-editor/presence.d.ts +20 -1
  73. package/dist/stickers/helpers.d.cts +2 -0
  74. package/dist/stickers/helpers.d.ts +2 -0
  75. package/dist/stickers/index.cjs +41 -35
  76. package/dist/stickers/index.js +22 -16
  77. package/dist/stickers/types.d.cts +4 -2
  78. package/dist/stickers/types.d.ts +4 -2
  79. package/dist/tools/conformance.d.cts +8 -0
  80. package/dist/tools/conformance.d.ts +8 -0
  81. package/dist/tools/index.cjs +6 -2
  82. package/dist/tools/index.js +5 -1
  83. package/dist/vector/backdrop.d.cts +13 -0
  84. package/dist/vector/backdrop.d.ts +13 -0
  85. package/dist/vector/brushstroke.d.cts +54 -7
  86. package/dist/vector/brushstroke.d.ts +54 -7
  87. package/dist/vector/brushstrokeMedia.d.cts +96 -0
  88. package/dist/vector/brushstrokeMedia.d.ts +96 -0
  89. package/dist/vector/controller.d.cts +17 -3
  90. package/dist/vector/controller.d.ts +17 -3
  91. package/dist/vector/index.cjs +1846 -1386
  92. package/dist/vector/index.d.cts +2 -0
  93. package/dist/vector/index.d.ts +2 -0
  94. package/dist/vector/index.js +517 -57
  95. package/dist/vector/render.d.cts +6 -0
  96. package/dist/vector/render.d.ts +6 -0
  97. package/dist/vector/types.d.cts +14 -0
  98. package/dist/vector/types.d.ts +14 -0
  99. package/package.json +1 -1
  100. package/skills/pixerate-animation-editor/SKILL.md +1 -0
  101. package/skills/pixerate-brushes/SKILL.md +2 -2
  102. package/skills/pixerate-brushes-design/SKILL.md +7 -4
  103. package/skills/pixerate-brushes-design/evals/port-to-vector.json +8 -8
  104. package/skills/pixerate-canvas/SKILL.md +11 -1
  105. package/skills/pixerate-image-editor/SKILL.md +19 -64
  106. package/skills/pixerate-image-editor/references/color-management.md +2 -1
  107. package/skills/pixerate-image-editor/references/free-transform.md +12 -0
  108. package/skills/pixerate-image-editor/references/layer-effects.md +10 -0
  109. package/skills/pixerate-image-editor/references/painting.md +17 -0
  110. package/skills/pixerate-image-editor/references/photoshop-files.md +1 -1
  111. package/skills/pixerate-image-editor/references/shapes-and-type.md +14 -0
  112. package/skills/pixerate-image-editor/references/smart-objects.md +10 -0
  113. package/skills/pixerate-paint-editor/SKILL.md +8 -4
  114. package/skills/pixerate-paint-editor/references/wet-media.md +9 -0
  115. package/skills/pixerate-vector/SKILL.md +16 -6
  116. package/skills/pixerate-vector/references/best-practices.md +1 -0
  117. 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-JGWVZPT6.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-EYSHTYLG.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-FGJHJIMN.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-7UKFXXWL.js";
84
64
  import "../chunk-VWEWY4FY.js";
85
65
  import "../chunk-HVQVHUTV.js";
86
66
  import {
@@ -92,7 +72,47 @@ 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
+ WET_GPU_MODES,
89
+ WET_MEDIUM_PARAMS,
90
+ WetStrokeDriver,
91
+ brushStickerRefs,
92
+ clonePaper,
93
+ effectDabField,
94
+ effectPaintKey,
95
+ effectPaintSize,
96
+ hexToRgb8,
97
+ isPaintEffectPaint,
98
+ mixerTip,
99
+ multiColorForDab,
100
+ multiColorGradient,
101
+ multiColorPalette,
102
+ normalizePaper,
103
+ paperPreset,
104
+ penOrientation,
105
+ readEffectStrokeMap,
106
+ readPaintEffectPaint,
107
+ renderPaper,
108
+ sampleEffectAt,
109
+ sampleEffectField,
110
+ stampSampler,
111
+ tiltToOrientation,
112
+ toStrokePoint,
113
+ wetPaperInput,
114
+ wetPigmentOf
115
+ } from "../chunk-4IDUYB45.js";
96
116
  import {
97
117
  BRUSH_ID,
98
118
  BRUSH_PRESETS,
@@ -147,7 +167,7 @@ import {
147
167
  parseBrushLibrary,
148
168
  resolveBrush,
149
169
  usesPaintExchange
150
- } from "../chunk-CIJBSUM5.js";
170
+ } from "../chunk-AGNUD5BI.js";
151
171
  import {
152
172
  STAMP_KINDS,
153
173
  StickersSlot,
@@ -156,7 +176,7 @@ import {
156
176
  stampCoverage,
157
177
  stickerIdFor,
158
178
  stickerVariantFor
159
- } from "../chunk-ACFSFUQH.js";
179
+ } from "../chunk-JL6YH5WD.js";
160
180
  import "../chunk-M6DNZNVY.js";
161
181
  import {
162
182
  bezierPathToSvg,
@@ -180,6 +200,21 @@ import {
180
200
  resolveEffectValues
181
201
  } from "../chunk-MV7CYFMJ.js";
182
202
  import "../chunk-V2ZCJRDM.js";
203
+ import {
204
+ DEFAULT_FLOW,
205
+ DEFAULT_WET_PARAMS,
206
+ MAX_WET_LANES,
207
+ SRGB_TO_LINEAR,
208
+ WET_FLOW_KINDS,
209
+ WET_PARAM_KEYS,
210
+ WetWorld,
211
+ linearToSrgb8,
212
+ normalizeFlow,
213
+ normalizeTilt,
214
+ normalizeWetParams,
215
+ pigmentOf,
216
+ wetSupport
217
+ } from "../chunk-GSTSZPSR.js";
183
218
  import {
184
219
  GRAIN_PATTERNS,
185
220
  GRAIN_TILE,
@@ -213,15 +248,12 @@ import {
213
248
  TileMedium,
214
249
  addPaintGradientStop,
215
250
  applyStrokeToTile,
216
- blenderDab,
217
251
  cleanMixer,
218
- createBlenderScratch,
219
252
  dabRect,
220
253
  exchangeRates,
221
254
  hexToPaintRgb,
222
255
  isGradientPaint,
223
256
  loadMixer,
224
- mixerRates,
225
257
  normalizeHexColor,
226
258
  paintGradientMappingFor,
227
259
  paintSourceColor,
@@ -259,7 +291,6 @@ import {
259
291
  PAINTERLY_BLEND_MODES,
260
292
  isBlendMode,
261
293
  isPainterlyBlendMode,
262
- mixPigment8,
263
294
  mixPigments,
264
295
  standardBlendMode
265
296
  } from "../chunk-IRSTHLQQ.js";
@@ -277,996 +308,72 @@ import {
277
308
  tilesInRect,
278
309
  translateSurface,
279
310
  writeRegion
280
- } from "../chunk-HVVR5PZW.js";
281
- import "../chunk-QGHEDC7B.js";
282
- import "../chunk-SIFAIGB3.js";
283
- import "../chunk-CIQIM2IP.js";
284
- import "../chunk-VDGBHYC7.js";
285
- import {
286
- EDITOR_DOCUMENT_FORMAT,
287
- createEditorAssetRegistry,
288
- defineEditorDocument,
289
- toEditorDocumentFile
290
- } 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);
311
+ } from "../chunk-HVVR5PZW.js";
312
+ import "../chunk-QGHEDC7B.js";
313
+ import "../chunk-SIFAIGB3.js";
314
+ import "../chunk-CIQIM2IP.js";
315
+ import "../chunk-VDGBHYC7.js";
316
+ import {
317
+ EDITOR_DOCUMENT_FORMAT,
318
+ createEditorAssetRegistry,
319
+ defineEditorDocument,
320
+ toEditorDocumentFile
321
+ } from "../chunk-XBQX76CT.js";
322
+ import "../chunk-HF5QZS7Z.js";
323
+ import "../chunk-M2TPMYJE.js";
324
+ import "../chunk-2WPNAGHI.js";
325
+ import {
326
+ HARMONY_KINDS,
327
+ colorHarmony,
328
+ hueToWheel,
329
+ wheelToHue
330
+ } from "../chunk-2EUW2XZX.js";
331
+ import "../chunk-E2V5DA3W.js";
332
+ import "../chunk-VR6OGHU5.js";
333
+ import "../chunk-JC2SHA4A.js";
334
+ import {
335
+ linearToSrgb,
336
+ oklabToOklch,
337
+ oklabToRgb,
338
+ oklchToOklab,
339
+ rgbToOklab,
340
+ srgbToLinear
341
+ } from "../chunk-MHLU6GRJ.js";
342
+ import "../chunk-6ONMQ6WF.js";
343
+
344
+ // src/paint-editor/document.ts
345
+ var MIN_CANVAS_SIDE = 1;
346
+ var MAX_CANVAS_SIDE = 16384;
347
+ var DEFAULT_DPI = 300;
348
+ var nextId = 1;
349
+ function newPaintLayerId() {
350
+ return `pl${Date.now().toString(36)}${(nextId++).toString(36)}`;
351
+ }
352
+ function createPaintLayer(name, id = newPaintLayerId()) {
353
+ return { type: "pixel", id, name, visible: true, opacity: 1, blend: "normal", surface: emptySurface(4), offset: { x: 0, y: 0 } };
354
+ }
355
+ var PaintDocumentError = class extends Error {
356
+ constructor(message) {
357
+ super(message);
358
+ this.name = "PaintDocumentError";
1268
359
  }
1269
360
  };
361
+ function normalizeCanvas(raw) {
362
+ const side = (v, what) => {
363
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new PaintDocumentError(`${what} must be a number of pixels (${MIN_CANVAS_SIDE}..${MAX_CANVAS_SIDE}).`);
364
+ const n = Math.round(v);
365
+ 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.`);
366
+ return n;
367
+ };
368
+ const dpi = raw.dpi === void 0 ? DEFAULT_DPI : raw.dpi;
369
+ if (typeof dpi !== "number" || !Number.isFinite(dpi) || dpi < 1 || dpi > 1e4) throw new PaintDocumentError(`dpi ${dpi} is out of range; use 1..10000.`);
370
+ return { width: side(raw.width, "width"), height: side(raw.height, "height"), dpi: Math.round(dpi * 100) / 100 };
371
+ }
372
+ function createPaintDocument(options = {}) {
373
+ const canvas = normalizeCanvas({ width: options.width ?? 2048, height: options.height ?? 1536, dpi: options.dpi });
374
+ const layer = createPaintLayer("Layer 1");
375
+ return { canvas, paper: normalizePaper(options.paper ?? {}), layers: [layer], activeLayerId: layer.id, strokes: [] };
376
+ }
1270
377
 
1271
378
  // src/paint-editor/thick.ts
1272
379
  var DEFAULT_LIGHTING = { azimuth: 315, elevation: 40, relief: 0.7 };
@@ -3937,696 +3044,6 @@ function downloadBytes(bytes, fileName, mimeType) {
3937
3044
  setTimeout(() => g.URL.revokeObjectURL(url), 1e3);
3938
3045
  }
3939
3046
 
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
3047
  // src/paint-editor/thumbnails.ts
4631
3048
  var thumbWorld = null;
4632
3049
  function paintWetTestStroke(brush, paper, color, width, height) {
@@ -4922,9 +3339,9 @@ function perspectiveGuideLines(g, size, count = 12) {
4922
3339
  }
4923
3340
 
4924
3341
  // src/paint-editor/color.ts
4925
- var HEX2 = /^#?([0-9a-f]{6})$/i;
3342
+ var HEX = /^#?([0-9a-f]{6})$/i;
4926
3343
  function parseHexColor(value, what = "color") {
4927
- const m = typeof value === "string" ? HEX2.exec(value.trim()) : null;
3344
+ const m = typeof value === "string" ? HEX.exec(value.trim()) : null;
4928
3345
  if (!m) throw new Error(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
4929
3346
  const n = parseInt(m[1], 16);
4930
3347
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
@@ -5279,6 +3696,8 @@ function samplePath(d, options = {}) {
5279
3696
  if (!(spacing >= 0.25 && spacing <= 100)) throw new Error(`spacing ${spacing} is out of range; use 0.25..100 document pixels.`);
5280
3697
  const profile = options.pressure;
5281
3698
  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.");
3699
+ const speed = options.speed;
3700
+ 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
3701
  let contours;
5283
3702
  try {
5284
3703
  contours = pathToContours(d);
@@ -5306,7 +3725,10 @@ function samplePath(d, options = {}) {
5306
3725
  const b = line[Math.min(j + 1, line.length - 1)];
5307
3726
  const x = Math.round((a[0] + (b[0] - a[0]) * f) * 1e3) / 1e3;
5308
3727
  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 });
3728
+ const p = { x, y };
3729
+ if (profile) p.pressure = Math.round(profileAt(profile, k / n) * 1e3) / 1e3;
3730
+ if (speed !== void 0) p.t = Math.round(at / speed * 1e3) / 1e3;
3731
+ points.push(p);
5310
3732
  }
5311
3733
  out.push(points);
5312
3734
  }
@@ -5904,10 +4326,10 @@ async function libraryCall(call) {
5904
4326
  throw e instanceof BrushLibraryError ? new PaintEditorError(e.message) : e;
5905
4327
  }
5906
4328
  }
5907
- var HEX3 = /^#[0-9a-f]{6}$/i;
4329
+ var HEX2 = /^#[0-9a-f]{6}$/i;
5908
4330
  var MAX_ADD_STICKERS = 500;
5909
4331
  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".`);
4332
+ if (typeof value !== "string" || !HEX2.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
5911
4333
  return value.trim().toLowerCase();
5912
4334
  }
5913
4335
  function rgbaOf(hex2) {
@@ -6141,36 +4563,82 @@ var PaintEditorController = class _PaintEditorController {
6141
4563
  }
6142
4564
  /**
6143
4565
  * 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.
4566
+ * undone together. When `fn` returns a promise, the group holds until it settles; until then other
4567
+ * changes (the user's strokes, undo) are refused, except those `fn` makes through `within`, so
4568
+ * nothing else lands in (or is lost with) the group's step. Nested groups join the outer one. If
4569
+ * `fn` throws or rejects, its changes are put back and nothing is recorded.
6146
4570
  */
6147
4571
  group(label, fn) {
6148
- if (this.grouping) return fn();
4572
+ if (this.grouping && (!this.grouping.pending || this.grouping.inside)) return fn((step) => step());
6149
4573
  this.requireIdle("grouping changes");
6150
4574
  const before = this.doc;
6151
4575
  const revision = this.docRevision;
6152
- this.grouping = { label, before, changed: false };
6153
- let result;
6154
- try {
6155
- result = fn();
6156
- } catch (e) {
4576
+ const strokesBefore = this.strokeCounter;
4577
+ const origin = this.getOrigin();
4578
+ const g = { label, before, changed: false, pending: false, inside: 0 };
4579
+ this.grouping = g;
4580
+ const within = (step) => {
4581
+ if (this.grouping !== g) return step();
4582
+ g.inside++;
4583
+ try {
4584
+ return step();
4585
+ } finally {
4586
+ g.inside--;
4587
+ }
4588
+ };
4589
+ const rollback = () => {
6157
4590
  this.grouping = null;
4591
+ this.strokeCounter = strokesBefore;
4592
+ if (this.wetOp) {
4593
+ const snap = this.wetOp.before;
4594
+ this.wetOp = null;
4595
+ this.pendingWetEvents = [];
4596
+ this.wet.restore(snap);
4597
+ }
6158
4598
  if (this.doc !== before) {
6159
4599
  this.doc = before;
6160
4600
  this.docRevision = revision + 1;
4601
+ this.wet.syncDisplays();
4602
+ this.emit();
4603
+ }
4604
+ };
4605
+ const finish = () => {
4606
+ this.grouping = null;
4607
+ if (g.changed && this.doc !== before || this.wetOp) {
4608
+ const entry = { label: g.label, origin, before, after: this.doc };
4609
+ if (this.wetOp) {
4610
+ entry.wet = { before: this.wetOp.before, after: this.wet.snapshot(), lost: false };
4611
+ this.wetOp = null;
4612
+ }
4613
+ this.undoStack.push(entry);
4614
+ if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
4615
+ this.redoStack = [];
4616
+ this.trimWetHistory();
4617
+ this.notifyCommit({ kind: "execute", label: g.label, origin: entry.origin });
6161
4618
  this.emit();
6162
4619
  }
4620
+ };
4621
+ let result;
4622
+ try {
4623
+ result = fn(within);
4624
+ } catch (e) {
4625
+ rollback();
6163
4626
  throw e;
6164
4627
  }
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();
4628
+ if (result && typeof result.then === "function") {
4629
+ g.pending = true;
4630
+ return Promise.resolve(result).then(
4631
+ (val) => {
4632
+ finish();
4633
+ return val;
4634
+ },
4635
+ (e) => {
4636
+ rollback();
4637
+ throw e;
4638
+ }
4639
+ );
6173
4640
  }
4641
+ finish();
6174
4642
  return result;
6175
4643
  }
6176
4644
  /**
@@ -6297,11 +4765,13 @@ var PaintEditorController = class _PaintEditorController {
6297
4765
  /** Why the document cannot be replaced or stepped through right now, or `null`. */
6298
4766
  busyReason() {
6299
4767
  if (this.session) return "A stroke is in progress; end or cancel it first.";
4768
+ if (this.grouping?.pending && !this.grouping.inside) return `"${this.grouping.label}" is still being painted; wait for it to finish first.`;
6300
4769
  if (this.wet.busy) return "Wet paint is mid-step; wait a moment (wetIdle()) first.";
6301
4770
  return null;
6302
4771
  }
6303
4772
  requireIdle(what) {
6304
4773
  if (this.session) throw new PaintEditorError(`A stroke is in progress; end or cancel it before ${what}.`);
4774
+ 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
4775
  }
6306
4776
  commit(label, next, before = this.doc) {
6307
4777
  if (this.grouping) {
@@ -7968,7 +6438,7 @@ var PaintEditorController = class _PaintEditorController {
7968
6438
  } catch (e) {
7969
6439
  throw new PaintEditorError(e instanceof Error ? e.message : String(e));
7970
6440
  }
7971
- const { path: _path, spacing: _spacing, pressure: _pressure, ...stroke } = init;
6441
+ const { path: _path, spacing: _spacing, pressure: _pressure, speed: _speed, ...stroke } = init;
7972
6442
  const before = this.doc;
7973
6443
  let doc = this.doc;
7974
6444
  let bounds = null;
@@ -10164,6 +8634,9 @@ var PaintDirector = class {
10164
8634
  case "stroke":
10165
8635
  await this.say(id, command.note, 500);
10166
8636
  return this.stroke(id, command.stroke);
8637
+ case "strokes":
8638
+ await this.say(id, command.note, 500);
8639
+ return this.strokes(id, command.strokes, command.label);
10167
8640
  case "fill": {
10168
8641
  await this.say(id, command.note, 500);
10169
8642
  const { width, height } = c.getState().document.canvas;
@@ -10189,11 +8662,11 @@ var PaintDirector = class {
10189
8662
  }
10190
8663
  }
10191
8664
  /** 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) {
8665
+ async stroke(id, init, within = (step) => step()) {
10193
8666
  const c = this.controller;
10194
8667
  const points = c.checkPoints(init.points);
10195
8668
  const { points: _points, ...rest } = init;
10196
- const session = c.beginStroke({ snapToPerspective: false, start: points[0], ...rest });
8669
+ const session = within(() => c.beginStroke({ snapToPerspective: false, start: points[0], ...rest }));
10197
8670
  try {
10198
8671
  await this.glide(id, points[0]);
10199
8672
  this.presence.set({ id, pressed: true });
@@ -10221,7 +8694,46 @@ var PaintDirector = class {
10221
8694
  throw error;
10222
8695
  }
10223
8696
  }
8697
+ /** Paints several strokes live in turn and commits them as one undo step (e.g. a path's subpaths). */
8698
+ async strokes(id, inits, label) {
8699
+ const c = this.controller;
8700
+ if (!Array.isArray(inits) || !inits.length) throw new PaintEditorError("strokes is empty; give at least one stroke.");
8701
+ const checked = inits.map((s) => ({ ...s, points: c.checkPoints(s.points) }));
8702
+ const first = checked[0];
8703
+ const base = first.brushId ? c.getBrush(first.brushId) : c.getState().brush;
8704
+ const stepLabel = label ?? `${base?.name ?? "Brush"} path stroke`;
8705
+ return c.group(stepLabel, async (within) => {
8706
+ let layerId = first.layerId ?? c.document.activeLayerId ?? "";
8707
+ let bounds = null;
8708
+ let dabs = 0;
8709
+ const strokeIds = [];
8710
+ let last = null;
8711
+ for (const s of checked) {
8712
+ const r = await this.stroke(id, s, within);
8713
+ layerId = r.layerId;
8714
+ bounds = unionRect2(bounds, r.bounds);
8715
+ dabs += r.dabs;
8716
+ if (r.strokeId) strokeIds.push(r.strokeId);
8717
+ last = r;
8718
+ }
8719
+ return {
8720
+ layerId,
8721
+ bounds,
8722
+ dabs,
8723
+ strokeIds,
8724
+ ...last?.mixerLoad ? { mixerLoad: last.mixerLoad } : {},
8725
+ ...last?.wet !== void 0 ? { wet: last.wet } : {}
8726
+ };
8727
+ });
8728
+ }
10224
8729
  };
8730
+ function unionRect2(a, b) {
8731
+ if (!a) return b ? { ...b } : null;
8732
+ if (!b) return { ...a };
8733
+ const x = Math.min(a.x, b.x);
8734
+ const y = Math.min(a.y, b.y);
8735
+ 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 };
8736
+ }
10225
8737
 
10226
8738
  // src/paint-editor/playback.ts
10227
8739
  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 +8765,7 @@ function planPaintToolPlayback(tool, _args) {
10253
8765
  if (control) steps.push({ type: "target", ref: control }, { type: "press" }, { type: "release" });
10254
8766
  return steps.length ? steps : null;
10255
8767
  }
10256
- var DIRECTED = /* @__PURE__ */ new Set(["paint_stroke", "fill_layer", "replay_strokes"]);
8768
+ var DIRECTED = /* @__PURE__ */ new Set(["paint_stroke", "stroke_path", "fill_layer", "replay_strokes"]);
10257
8769
  function withPaintPlayback(provider, options) {
10258
8770
  const plan = (tool) => {
10259
8771
  if (tool.plan || tool.playback !== void 0) return tool;
@@ -11288,7 +9800,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
11288
9800
  const add = (t) => tools.push(t);
11289
9801
  const act = async (command, direct) => {
11290
9802
  if (!options.director) return direct();
11291
- const [r] = await options.director.perform(options.actor ?? { id: "agent", name: "Agent" }, [command]);
9803
+ const cmd = typeof command === "function" ? command() : command;
9804
+ const [r] = await options.director.perform(options.actor ?? { id: "agent", name: "Agent" }, [cmd]);
11292
9805
  if (!r || !r.ok) throw new Error(r && !r.ok ? r.message : "The director did not run the command.");
11293
9806
  return r.result;
11294
9807
  };
@@ -11394,7 +9907,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11394
9907
  annotations: { title: "Get the paper", ...READ6 },
11395
9908
  handler: () => JSON.parse(JSON.stringify(c.document.paper))
11396
9909
  });
11397
- const tuning2 = {
9910
+ const tuning = {
11398
9911
  type: "object",
11399
9912
  properties: {
11400
9913
  contrast: { type: "number", minimum: 0, maximum: 3, description: "Height contrast around the middle (default 1)." },
@@ -11417,8 +9930,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
11417
9930
  pattern: { type: "string", enum: [...GRAIN_PATTERNS], description: "Grain pattern." },
11418
9931
  tiling: { type: "string", enum: ["repeat", "mirror"], description: "How the grain tile repeats (mirror hides seams)." },
11419
9932
  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)." },
9933
+ dry: { ...tuning, description: "Height map tuning for dry media." },
9934
+ wet: { ...tuning, description: "Height map tuning for wet media (the grain water and pigment follow)." },
11422
9935
  absorption: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how fast the paper soaks up water (high: washes stop spreading sooner)." },
11423
9936
  permeability: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how freely water flows over the fibres (low: more ragged, slower spreading)." },
11424
9937
  roughness: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how much the grain pins wet edges and catches granulating pigment." },
@@ -11737,13 +10250,14 @@ function createPaintEditorToolProvider(controller, options = {}) {
11737
10250
  add({
11738
10251
  name: "stroke_path",
11739
10252
  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).",
10253
+ 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
10254
  inputSchema: {
11742
10255
  type: "object",
11743
10256
  properties: {
11744
10257
  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
10258
  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
10259
  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." },
10260
+ 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
10261
  layerId: LAYER_ID4,
11748
10262
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
11749
10263
  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 +10279,30 @@ function createPaintEditorToolProvider(controller, options = {}) {
11765
10279
  if (refs.length) await c.prepareStickers(refs);
11766
10280
  }
11767
10281
  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 });
10282
+ 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 };
10283
+ const r = await act(
10284
+ () => {
10285
+ 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".');
10286
+ let subpaths;
10287
+ try {
10288
+ subpaths = samplePath(path, init);
10289
+ } catch (e) {
10290
+ throw new PaintEditorError(e instanceof Error ? e.message : String(e));
10291
+ }
10292
+ const { path: _path, spacing: _spacing, pressure: _pressure, speed: _speed, ...stroke } = init;
10293
+ return {
10294
+ kind: "strokes",
10295
+ label: `${base?.name ?? "Brush"} path stroke`,
10296
+ strokes: subpaths.map((points, i) => ({
10297
+ ...stroke,
10298
+ ...stroke.seed !== void 0 ? { seed: stroke.seed + i } : {},
10299
+ snapToPerspective: false,
10300
+ points
10301
+ }))
10302
+ };
10303
+ },
10304
+ () => c.strokePath(init)
10305
+ );
11769
10306
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11770
10307
  }
11771
10308
  });
@@ -13058,7 +11595,7 @@ function createPaintChangeFeed(controller, options = {}) {
13058
11595
  }
13059
11596
  };
13060
11597
  }
13061
- var HEX4 = /^#[0-9a-f]{6}$/i;
11598
+ var HEX3 = /^#[0-9a-f]{6}$/i;
13062
11599
  var DRY_ENGINES = /* @__PURE__ */ new Set(["dab", "particle", "bristle", "mixer"]);
13063
11600
  function suggestionBrushRefusal(brush) {
13064
11601
  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 +11635,20 @@ function createPaintProposals(options) {
13098
11635
  if (op.kind === "stroke") {
13099
11636
  if (!op.points?.length) throw new PaintEditorError(`${at}: a stroke needs points in document pixels.`);
13100
11637
  points += op.points.length;
13101
- if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
11638
+ if (op.color !== void 0 && !HEX3.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
13102
11639
  const brush = op.brushId ? controller.getBrush(op.brushId) : controller.getState().brush;
13103
11640
  if (!brush) throw new PaintEditorError(`${at}: unknown brush "${op.brushId}". Call list_brush_presets for the ids.`);
13104
11641
  const refusal = suggestionBrushRefusal(brush);
13105
11642
  if (refusal) throw new PaintEditorError(`${at}: ${refusal}`);
13106
11643
  if (op.size !== void 0 && !(op.size > 0)) throw new PaintEditorError(`${at}: size must be above 0 (document pixels).`);
13107
11644
  } else if (op.kind === "wash") {
13108
- if (!HEX4.test(op.color ?? "")) throw new PaintEditorError(`${at}: a wash needs color as #rrggbb.`);
11645
+ if (!HEX3.test(op.color ?? "")) throw new PaintEditorError(`${at}: a wash needs color as #rrggbb.`);
13109
11646
  if (op.opacity !== void 0 && !(op.opacity > 0 && op.opacity <= 1)) throw new PaintEditorError(`${at}: opacity must be in (0, 1].`);
13110
11647
  } else if (op.kind === "echo") {
13111
11648
  if (!op.strokeIds?.length) throw new PaintEditorError(`${at}: an echo needs strokeIds (list_strokes).`);
13112
11649
  const unknown = op.strokeIds.filter((id) => !known.has(id));
13113
11650
  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.`);
11651
+ if (op.color !== void 0 && !HEX3.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
13115
11652
  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
11653
  for (const id of op.strokeIds) {
13117
11654
  const stroke = controller.getStrokes().find((s) => s.id === id);