@pixerate/editor 0.259.0 → 0.261.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 (80) hide show
  1. package/dist/agent/index.cjs +2 -2
  2. package/dist/agent/index.js +1 -1
  3. package/dist/animation-editor/index.cjs +61 -59
  4. package/dist/animation-editor/index.js +5 -3
  5. package/dist/brushes/index.cjs +52 -52
  6. package/dist/brushes/index.js +6 -6
  7. package/dist/{brushstrokeRender-JI4W6F2G.js → brushstrokeRender-25Q5BEY6.js} +17 -7
  8. package/dist/{brushstrokeRender-MQ6PSDM5.cjs → brushstrokeRender-N6UPZSQS.cjs} +31 -21
  9. package/dist/canvas/brushTypes.d.cts +5 -0
  10. package/dist/canvas/brushTypes.d.ts +5 -0
  11. package/dist/canvas/drawController.d.cts +15 -1
  12. package/dist/canvas/drawController.d.ts +15 -1
  13. package/dist/canvas/index.cjs +136 -102
  14. package/dist/canvas/index.js +35 -1
  15. package/dist/{chunk-3PPJRMIG.js → chunk-3O47TPGX.js} +197 -18
  16. package/dist/{chunk-5Q5MLXAD.cjs → chunk-3TQW5MPW.cjs} +23 -23
  17. package/dist/{chunk-JBBJE76O.cjs → chunk-3ZRNK3RO.cjs} +1138 -85
  18. package/dist/{chunk-YSGW7VV4.cjs → chunk-43NT242D.cjs} +1401 -228
  19. package/dist/{chunk-SMTUQWGY.js → chunk-5IW65VQ6.js} +1 -1
  20. package/dist/{chunk-YQ4PQTBB.js → chunk-6NZL24W4.js} +9 -1
  21. package/dist/{chunk-O3ZVZSNJ.cjs → chunk-7AUQ2UJ3.cjs} +137 -129
  22. package/dist/{chunk-4M4E3N6X.js → chunk-CQVTGBQI.js} +1 -1
  23. package/dist/{chunk-SSFMWJDK.js → chunk-D4TE5JNW.js} +1192 -19
  24. package/dist/{chunk-AORZ5M5C.js → chunk-EHALWBBD.js} +1 -1
  25. package/dist/{chunk-6WCQ35GI.cjs → chunk-NHA26O5X.cjs} +2 -2
  26. package/dist/{chunk-DPYAO5U6.cjs → chunk-PVJMTLPJ.cjs} +197 -18
  27. package/dist/{chunk-MQGQTKVW.cjs → chunk-PYTJBVW5.cjs} +29 -29
  28. package/dist/{chunk-IXQZHCOF.js → chunk-QAR2N7HZ.js} +1 -1
  29. package/dist/{chunk-5P4JEOIV.js → chunk-TMDDMJCR.js} +2 -2
  30. package/dist/{chunk-RSIKDWOP.cjs → chunk-TZOX7KWV.cjs} +15 -15
  31. package/dist/{chunk-IPUTIUGQ.cjs → chunk-UVZ7RQOE.cjs} +1 -1
  32. package/dist/{chunk-A7GJ6BXI.js → chunk-WJ4WQJCC.js} +1062 -9
  33. package/dist/{chunk-ICTPLVSR.js → chunk-X2EXYY4J.js} +1 -1
  34. package/dist/{chunk-WQCNC4DK.js → chunk-XC6DPRYI.js} +695 -4
  35. package/dist/{chunk-KBYB7OWB.cjs → chunk-YHDHFJUC.cjs} +776 -85
  36. package/dist/{chunk-P3PZ63M3.cjs → chunk-YLUIFXSL.cjs} +35 -35
  37. package/dist/commands/index.cjs +2 -2
  38. package/dist/commands/index.js +1 -1
  39. package/dist/image-editor/index.cjs +1385 -2484
  40. package/dist/image-editor/index.js +139 -1238
  41. package/dist/image-editor/selection-worker.cjs +3 -4
  42. package/dist/image-editor/selection-worker.js +1 -2
  43. package/dist/paint/index.cjs +3 -5
  44. package/dist/paint/index.js +40 -42
  45. package/dist/paint-editor/contract.d.cts +29 -0
  46. package/dist/paint-editor/contract.d.ts +29 -0
  47. package/dist/paint-editor/controller.d.cts +17 -0
  48. package/dist/paint-editor/controller.d.ts +17 -0
  49. package/dist/paint-editor/dab-stroke.d.cts +5 -0
  50. package/dist/paint-editor/dab-stroke.d.ts +5 -0
  51. package/dist/paint-editor/index.cjs +610 -327
  52. package/dist/paint-editor/index.d.cts +2 -0
  53. package/dist/paint-editor/index.d.ts +2 -0
  54. package/dist/paint-editor/index.js +337 -54
  55. package/dist/paint-editor/path-samples.d.cts +19 -0
  56. package/dist/paint-editor/path-samples.d.ts +19 -0
  57. package/dist/paint-editor/stamp-tip.d.cts +17 -0
  58. package/dist/paint-editor/stamp-tip.d.ts +17 -0
  59. package/dist/paint-editor/ui.d.cts +27 -1
  60. package/dist/paint-editor/ui.d.ts +27 -1
  61. package/dist/raster/index.cjs +2 -4
  62. package/dist/raster/index.js +6 -8
  63. package/dist/vector/brushstroke.d.cts +6 -0
  64. package/dist/vector/brushstroke.d.ts +6 -0
  65. package/dist/vector/controller.d.cts +11 -2
  66. package/dist/vector/controller.d.ts +11 -2
  67. package/dist/vector/index.cjs +990 -973
  68. package/dist/vector/index.js +28 -11
  69. package/package.json +1 -1
  70. package/skills/pixerate-brushes/SKILL.md +1 -1
  71. package/skills/pixerate-canvas/SKILL.md +3 -1
  72. package/skills/pixerate-paint-editor/SKILL.md +2 -0
  73. package/skills/pixerate-paint-sketch-to-finish/SKILL.md +5 -1
  74. package/skills/pixerate-vector/SKILL.md +2 -2
  75. package/dist/chunk-2VJAVXLI.js +0 -192
  76. package/dist/chunk-62Z5WLRA.js +0 -700
  77. package/dist/chunk-BOHDJJ2O.cjs +0 -192
  78. package/dist/chunk-NKGB64CW.cjs +0 -700
  79. package/dist/chunk-SS53NNZO.cjs +0 -1066
  80. package/dist/chunk-ZNA3IQZF.js +0 -1066
@@ -57,3 +57,5 @@ export type { StickerProvider, StickerRef, StickerSummary } from '../stickers/ty
57
57
  export * from './sticker-art.cjs';
58
58
  export * from './collaboration.cjs';
59
59
  export * from './collaboration-tools.cjs';
60
+ export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.cjs';
61
+ export { paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.cjs';
@@ -57,3 +57,5 @@ export type { StickerProvider, StickerRef, StickerSummary } from '../stickers/ty
57
57
  export * from './sticker-art.js';
58
58
  export * from './collaboration.js';
59
59
  export * from './collaboration-tools.js';
60
+ export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.js';
61
+ export { paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.js';
@@ -11,7 +11,7 @@ import {
11
11
  mergeBrushPatch,
12
12
  multiColorSliders,
13
13
  patchBrushValue
14
- } from "../chunk-ICTPLVSR.js";
14
+ } from "../chunk-X2EXYY4J.js";
15
15
  import {
16
16
  createStickerTools,
17
17
  describeStickerRef
@@ -20,20 +20,18 @@ import {
20
20
  toVideoFfmpeg
21
21
  } from "../chunk-MWUZCEFT.js";
22
22
  import {
23
+ BRUSH_OPTION_CONTROLS,
23
24
  createLayerEffectTools,
24
25
  deserializeLayers,
25
26
  exportPsd,
26
27
  resolveExternalAssets,
27
28
  serializeLayers,
28
29
  serializeLayersExternal
29
- } from "../chunk-SSFMWJDK.js";
30
- import "../chunk-ZNA3IQZF.js";
31
- import {
32
- brushColourHex
33
- } from "../chunk-FJBQTZS4.js";
34
- import "../chunk-3CI7FRMK.js";
30
+ } from "../chunk-D4TE5JNW.js";
31
+ import "../chunk-WJ4WQJCC.js";
35
32
  import {
36
33
  LAYER_EFFECT_KINDS,
34
+ LAYER_EFFECT_MENU,
37
35
  LayerEffectError,
38
36
  RasterCompositor,
39
37
  defaultLayerEffect,
@@ -42,12 +40,13 @@ import {
42
40
  insertLayer,
43
41
  isLayerEffectKind,
44
42
  isLayerLocked,
43
+ layerEffectControls,
45
44
  layerEffectLabel,
46
45
  mapLayer,
47
46
  mergeLayerEffect,
48
47
  newEffectId,
49
48
  validateLayerEffects
50
- } from "../chunk-3PPJRMIG.js";
49
+ } from "../chunk-3O47TPGX.js";
51
50
  import "../chunk-OHKOFKBY.js";
52
51
  import "../chunk-ZIF4UJJ6.js";
53
52
  import {
@@ -74,7 +73,8 @@ import {
74
73
  pigmentOf,
75
74
  pointerPressure,
76
75
  wetSupport
77
- } from "../chunk-WQCNC4DK.js";
76
+ } from "../chunk-XC6DPRYI.js";
77
+ import "../chunk-VWEWY4FY.js";
78
78
  import "../chunk-HVQVHUTV.js";
79
79
  import {
80
80
  writeZip
@@ -85,7 +85,7 @@ import {
85
85
  realPresenceClock,
86
86
  tweenPresenceCursor
87
87
  } from "../chunk-IVSWRVB4.js";
88
- import "../chunk-YQ4PQTBB.js";
88
+ import "../chunk-6NZL24W4.js";
89
89
  import {
90
90
  BRUSH_ID,
91
91
  BRUSH_PRESETS,
@@ -140,7 +140,7 @@ import {
140
140
  parseBrushLibrary,
141
141
  resolveBrush,
142
142
  usesPaintExchange
143
- } from "../chunk-SMTUQWGY.js";
143
+ } from "../chunk-5IW65VQ6.js";
144
144
  import {
145
145
  STAMP_KINDS,
146
146
  StickersSlot,
@@ -150,8 +150,16 @@ import {
150
150
  stickerIdFor
151
151
  } from "../chunk-APX63ILU.js";
152
152
  import "../chunk-M6DNZNVY.js";
153
- import "../chunk-WEIK3UYB.js";
153
+ import {
154
+ bezierPathToSvg,
155
+ flattenChain,
156
+ pathToContours
157
+ } from "../chunk-WEIK3UYB.js";
154
158
  import "../chunk-PWBZH3FK.js";
159
+ import {
160
+ brushColourHex
161
+ } from "../chunk-FJBQTZS4.js";
162
+ import "../chunk-3CI7FRMK.js";
155
163
  import {
156
164
  StickerError
157
165
  } from "../chunk-SZTI6EER.js";
@@ -268,6 +276,7 @@ import "../chunk-SIFAIGB3.js";
268
276
  import "../chunk-CIQIM2IP.js";
269
277
  import "../chunk-VDGBHYC7.js";
270
278
  import {
279
+ EDITOR_DOCUMENT_FORMAT,
271
280
  createEditorAssetRegistry,
272
281
  defineEditorDocument
273
282
  } from "../chunk-XBQX76CT.js";
@@ -653,6 +662,18 @@ function brushStickerRefs(brush) {
653
662
  const refs = brush.engine === "sticker" ? [...brush.sticker.stamps] : brush.engine === "particle" ? [...brush.particle.dotTextures] : brush.engine === "dab" && brush.stamp ? [brush.stamp] : [];
654
663
  return [...new Set(refs.filter((r) => r.includes(":") && !r.startsWith("editor:")))];
655
664
  }
665
+ function sprayDroplets(d, spray, seed) {
666
+ const count = 1 + Math.round(spray.density * 31);
667
+ const key = d.index * 131 + Math.round(d.travel * 8);
668
+ const out = [];
669
+ for (let i = 0; i < count; i++) {
670
+ const n = key * 64 + i;
671
+ const a = rand01(seed, n, 61) * Math.PI * 2;
672
+ const rr = Math.sqrt(rand01(seed, n, 67)) * d.radius;
673
+ 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 });
674
+ }
675
+ return out;
676
+ }
656
677
 
657
678
  // src/paint-editor/engine.ts
658
679
  function penOrientation(p) {
@@ -921,14 +942,7 @@ var DryStroke = class {
921
942
  this.mark(d, d, rects);
922
943
  continue;
923
944
  }
924
- const count = 1 + Math.round(spray.density * 31);
925
- const key = d.index * 131 + Math.round(d.travel * 8);
926
- for (let i = 0; i < count; i++) {
927
- const a = rand01(seed, key * 64 + i, 61) * Math.PI * 2;
928
- const rr = Math.sqrt(rand01(seed, key * 64 + i, 67)) * d.radius;
929
- const drop = { ...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, key * 64 + i, 71))), roundness: 1, angle: rand01(seed, key * 64 + i, 73) * Math.PI * 2 };
930
- this.mark(drop, d, rects);
931
- }
945
+ for (const drop of sprayDroplets(d, spray, seed)) this.mark(drop, d, rects);
932
946
  }
933
947
  return rects;
934
948
  }
@@ -1019,6 +1033,7 @@ var PaintDabStroke = class {
1019
1033
  constructor(brush, options) {
1020
1034
  this.brush = brush;
1021
1035
  this.options = options;
1036
+ this.stamp = brush.engine === "dab" && brush.stamp ? stampSampler(brush.stamp, options.stampArt) : null;
1022
1037
  this.smoother = new PenSmoother(brush.tip.smoothing, brush.pressureSmoothing);
1023
1038
  this.copies = options.copies?.length ? options.copies : [IDENTITY_COPY];
1024
1039
  const exchanging = brush.engine === "mixer" || brush.engine === "dab";
@@ -1062,6 +1077,8 @@ var PaintDabStroke = class {
1062
1077
  paint = [0, 0, 0];
1063
1078
  lastRaw = null;
1064
1079
  lastFed = null;
1080
+ /** The stamp tip's shape (dab brushes), or `null` for the round computed tip. */
1081
+ stamp;
1065
1082
  /** What copy 0's brush carries after the dabs so far (mixer and wet-capable dab brushes). */
1066
1083
  get load() {
1067
1084
  return this.states[0].exchange?.load ?? null;
@@ -1094,8 +1111,9 @@ var PaintDabStroke = class {
1094
1111
  return rects;
1095
1112
  }
1096
1113
  paintDabs(copy, dabs, rects) {
1097
- for (const d of dabs) {
1098
- const rect = dabRect(d, this.brush.tip.hardness);
1114
+ const spray = this.brush.engine === "dab" ? this.brush.spray : null;
1115
+ for (const d of spray ? dabs.flatMap((dab) => sprayDroplets(dab, spray, (this.options.seed ?? 0) + copy * 7919)) : dabs) {
1116
+ const rect = this.stamp ? stampDabRect(d) : dabRect(d, this.brush.tip.hardness);
1099
1117
  if (rect.width <= 0 || rect.height <= 0) continue;
1100
1118
  this.dab(copy, d, rect);
1101
1119
  rects.push(rect);
@@ -1111,7 +1129,8 @@ var PaintDabStroke = class {
1111
1129
  }
1112
1130
  const tip = this.tip.subarray(0, n);
1113
1131
  tip.fill(0);
1114
- rasterizeDab({ ...d, alpha: 1 }, this.brush.tip.hardness, rect, tip);
1132
+ if (this.stamp) rasterizeStampDab({ ...d, alpha: 1 }, this.stamp, d.index, rect, tip);
1133
+ else rasterizeDab({ ...d, alpha: 1 }, this.brush.tip.hardness, rect, tip);
1115
1134
  if (!(this.depth > 0 && this.options.grain)) return { tip, cov: tip };
1116
1135
  const cov = this.cov.subarray(0, n);
1117
1136
  const grain = this.options.grain;
@@ -2958,7 +2977,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
2958
2977
  seed: init.seed,
2959
2978
  copies: this.copies,
2960
2979
  paint: init.paint ?? null,
2961
- effect
2980
+ effect,
2981
+ stampArt: init.stampArt
2962
2982
  });
2963
2983
  } else {
2964
2984
  const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt };
@@ -5205,6 +5225,55 @@ function engineChanges(base, next) {
5205
5225
  return rest;
5206
5226
  }
5207
5227
 
5228
+ // src/paint-editor/path-samples.ts
5229
+ var FLATTEN_TOLERANCE = 0.1;
5230
+ var MAX_PATH_SAMPLES = 2e4;
5231
+ function profileAt(profile, t) {
5232
+ if (profile.length === 1) return profile[0];
5233
+ const at = t * (profile.length - 1);
5234
+ const i = Math.min(profile.length - 2, Math.floor(at));
5235
+ return profile[i] + (profile[i + 1] - profile[i]) * (at - i);
5236
+ }
5237
+ function samplePath(d, options = {}) {
5238
+ const spacing = options.spacing ?? 2;
5239
+ if (!(spacing >= 0.25 && spacing <= 100)) throw new Error(`spacing ${spacing} is out of range; use 0.25..100 document pixels.`);
5240
+ const profile = options.pressure;
5241
+ 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.");
5242
+ let contours;
5243
+ try {
5244
+ contours = pathToContours(d);
5245
+ } catch (e) {
5246
+ throw new Error(`path is not SVG path data (${e instanceof Error ? e.message : String(e)}); give e.g. "M 10 10 C 40 0 80 60 120 30".`);
5247
+ }
5248
+ const out = [];
5249
+ for (const contour of contours) {
5250
+ if (!contour.segments.length) continue;
5251
+ const line = flattenChain(contour.segments, FLATTEN_TOLERANCE);
5252
+ if (contour.closed && line.length) line.push(line[0]);
5253
+ const lengths = [0];
5254
+ for (let i = 1; i < line.length; i++) lengths.push(lengths[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]));
5255
+ const total = lengths[lengths.length - 1];
5256
+ const n = Math.max(1, Math.ceil(total / spacing));
5257
+ if (n + 1 > MAX_PATH_SAMPLES) throw new Error(`The path is ${Math.round(total)} px long: too many samples at spacing ${spacing}; use a larger spacing (at least ${Math.ceil(total / (MAX_PATH_SAMPLES - 1))}).`);
5258
+ const points = [];
5259
+ let j = 0;
5260
+ for (let k = 0; k <= n; k++) {
5261
+ const at = total * k / n;
5262
+ while (j < line.length - 2 && lengths[j + 1] < at) j++;
5263
+ const span = lengths[j + 1] - lengths[j] || 1;
5264
+ const f = line.length > 1 ? Math.min(1, Math.max(0, (at - lengths[j]) / span)) : 0;
5265
+ const a = line[j];
5266
+ const b = line[Math.min(j + 1, line.length - 1)];
5267
+ const x = Math.round((a[0] + (b[0] - a[0]) * f) * 1e3) / 1e3;
5268
+ const y = Math.round((a[1] + (b[1] - a[1]) * f) * 1e3) / 1e3;
5269
+ points.push(profile ? { x, y, pressure: Math.round(profileAt(profile, k / n) * 1e3) / 1e3 } : { x, y });
5270
+ }
5271
+ out.push(points);
5272
+ }
5273
+ if (!out.length) throw new Error('path has no segments to brush; give SVG path data such as "M 10 10 L 200 80".');
5274
+ return out;
5275
+ }
5276
+
5208
5277
  // src/paint-editor/live-strokes.ts
5209
5278
  var LIVE_STROKE_ENGINES = ["dab", "particle", "bristle", "mixer"];
5210
5279
  function liveStrokeRefusal(brush) {
@@ -7841,6 +7910,47 @@ var PaintEditorController = class _PaintEditorController {
7841
7910
  else this.emit([]);
7842
7911
  return result;
7843
7912
  }
7913
+ /**
7914
+ * Brushes vector path data (SVG `d`, document pixels) with the active brush, or `brushId` /
7915
+ * `brush`, as if drawn by hand: one recorded stroke per subpath, sampled every `spacing` px
7916
+ * with an optional `pressure` profile along each. All of it is one undo step. A given `seed`
7917
+ * seeds the first subpath; the next ones take the following seeds.
7918
+ */
7919
+ strokePath(init) {
7920
+ this.requireIdle("painting");
7921
+ if (typeof init.path !== "string" || !init.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".');
7922
+ let subpaths;
7923
+ try {
7924
+ subpaths = samplePath(init.path, init);
7925
+ } catch (e) {
7926
+ throw new PaintEditorError(e instanceof Error ? e.message : String(e));
7927
+ }
7928
+ const { path: _path, spacing: _spacing, pressure: _pressure, ...stroke } = init;
7929
+ const before = this.doc;
7930
+ let doc = this.doc;
7931
+ let bounds = null;
7932
+ let dabs = 0;
7933
+ const strokeIds = [];
7934
+ let last = null;
7935
+ try {
7936
+ subpaths.forEach((points, i) => {
7937
+ last = this.runStroke(doc, { ...stroke, ...stroke.seed !== void 0 ? { seed: stroke.seed + i } : {}, start: points[0] }, points);
7938
+ doc = last.doc;
7939
+ bounds = unionRect(bounds, last.result.bounds);
7940
+ dabs += last.result.dabs;
7941
+ if (last.result.strokeId) strokeIds.push(last.result.strokeId);
7942
+ });
7943
+ } catch (e) {
7944
+ this.wetOp = null;
7945
+ throw e;
7946
+ }
7947
+ const run = last;
7948
+ if (run?.result.mixerLoad) this.keepLoad(run.brush, run.result.mixerLoad);
7949
+ if (doc !== before || this.wetOp) this.commit(`${run?.brush.name ?? "Brush"} path stroke`, this.withWetEvents(doc), before);
7950
+ else this.emit([]);
7951
+ const layerId = run?.result.layerId ?? stroke.layerId ?? this.doc.activeLayerId ?? "";
7952
+ return { layerId, bounds, dabs, strokeIds, ...run?.result.mixerLoad ? { mixerLoad: run.result.mixerLoad } : {}, ...run?.result.wet !== void 0 ? { wet: run.result.wet } : {} };
7953
+ }
7844
7954
  /** Checks stroke samples (finite x and y, pressure 0..1) and returns them; throws a sentence naming the bad point. */
7845
7955
  checkPoints(points) {
7846
7956
  if (!Array.isArray(points) || !points.length) throw new PaintEditorError("points is empty; give at least one {x, y} in document pixels.");
@@ -11563,6 +11673,41 @@ function createPaintEditorToolProvider(controller, options = {}) {
11563
11673
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11564
11674
  }
11565
11675
  });
11676
+ add({
11677
+ name: "stroke_path",
11678
+ title: "Brush along a path",
11679
+ 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).",
11680
+ inputSchema: {
11681
+ type: "object",
11682
+ properties: {
11683
+ 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.' },
11684
+ 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." },
11685
+ 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." },
11686
+ layerId: LAYER_ID4,
11687
+ brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
11688
+ 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 },
11689
+ color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
11690
+ paint: { ...PAINT_SOURCE_JSON_SCHEMA, description: "What the strokes paint with instead of the editor's paint: a colour, or gradient paint that changes colour as each stroke goes." },
11691
+ seed: { type: "integer", minimum: 0, description: "Jitter seed of the first subpath, the next ones take the following seeds (default new ones); the same seed and path give the same marks." }
11692
+ },
11693
+ required: ["path"],
11694
+ additionalProperties: false
11695
+ },
11696
+ annotations: { title: "Brush along a path", ...WRITE5 },
11697
+ coordinates: { out: ["bounds"] },
11698
+ handler: async (a) => {
11699
+ await c.wetIdle();
11700
+ const base = a.brushId ? c.getBrush(a.brushId) : c.getState().brush;
11701
+ const brush = a.brush ? c.brushChanges(a.brush, a.brushId) : void 0;
11702
+ if (base) {
11703
+ const refs = brushStickerRefs(normalizePaintBrush({ ...brush ?? {}, id: base.id }, base));
11704
+ if (refs.length) await c.prepareStickers(refs);
11705
+ }
11706
+ 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;
11707
+ 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 });
11708
+ return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11709
+ }
11710
+ });
11566
11711
  add({
11567
11712
  name: "get_live_stroke",
11568
11713
  title: "Get the stroke being painted",
@@ -12178,44 +12323,47 @@ var PaintGesture = class {
12178
12323
  }
12179
12324
  };
12180
12325
  var round2 = (v) => Math.round(v * 100) / 100;
12326
+ var PAINT_SHORTCUTS = [
12327
+ { id: "undo", label: "Undo", group: "edit", keys: ["Mod+Z"], tool: "undo_paint" },
12328
+ { id: "redo", label: "Redo", group: "edit", keys: ["Mod+Shift+Z", "Mod+Y"], tool: "redo_paint" },
12329
+ ...PAINT_TOOLS.map((t) => ({ id: `tool.${t.id}`, label: t.label, group: "tools", keys: [t.key.toUpperCase()] })),
12330
+ { id: "brush.smaller", label: "Smaller brush", group: "brush", keys: ["["], tool: "set_brush" },
12331
+ { id: "brush.larger", label: "Larger brush", group: "brush", keys: ["]"], tool: "set_brush" },
12332
+ { id: "colour.swap", label: "Swap colours", group: "colour", keys: ["X"] },
12333
+ { id: "view.mirror", label: "Mirror the view", group: "view", keys: ["M"], tool: "set_view" },
12334
+ { id: "view.reset", label: "Reset the view", group: "view", keys: ["Escape"], tool: "set_view" }
12335
+ ];
12336
+ function paintShortcutFor(e) {
12337
+ if (!e.key) return null;
12338
+ const parts = [...e.ctrlKey || e.metaKey ? ["Mod"] : [], ...e.shiftKey && (e.ctrlKey || e.metaKey) ? ["Shift"] : []];
12339
+ const key = e.key.length === 1 ? e.key.toUpperCase() : e.key[0].toUpperCase() + e.key.slice(1).toLowerCase();
12340
+ const combo = [...parts, key].join("+");
12341
+ return PAINT_SHORTCUTS.find((s) => s.keys.includes(combo)) ?? null;
12342
+ }
12181
12343
  function handlePaintShortcut(controller, gesture, e) {
12182
- const mod = e.ctrlKey || e.metaKey;
12183
- const key = e.key.toLowerCase();
12184
- if (mod && key === "z") {
12185
- if (controller.getState().stroking) return true;
12186
- if (e.shiftKey) controller.redo();
12187
- else controller.undo();
12188
- return true;
12189
- }
12190
- if (mod && key === "y") {
12191
- controller.redo();
12344
+ const shortcut = paintShortcutFor(e);
12345
+ if (!shortcut) return false;
12346
+ const id = shortcut.id;
12347
+ if (id === "undo" || id === "redo") {
12348
+ if (controller.getState().stroking && !(id === "redo" && e.key.toLowerCase() === "y")) return true;
12349
+ if (id === "undo") controller.undo();
12350
+ else controller.redo();
12192
12351
  return true;
12193
12352
  }
12194
- if (mod) return false;
12195
- const tool = PAINT_TOOLS.find((t) => t.key === key);
12196
- if (tool) {
12197
- gesture.tool = tool.id;
12353
+ if (id.startsWith("tool.")) {
12354
+ gesture.tool = id.slice(5);
12198
12355
  return true;
12199
12356
  }
12200
- const brush = controller.getState().brush;
12201
- if (key === "[" || key === "]") {
12357
+ if (id === "brush.smaller" || id === "brush.larger") {
12358
+ const brush = controller.getState().brush;
12202
12359
  const step = Math.max(1, Math.round(brush.tip.size * 0.15));
12203
- controller.setBrush({ tip: { size: Math.max(1, brush.tip.size + (key === "]" ? step : -step)) } });
12204
- return true;
12205
- }
12206
- if (key === "x") {
12207
- controller.swapColors();
12208
- return true;
12209
- }
12210
- if (key === "m") {
12211
- controller.setView({ mirror: !controller.getState().view.mirror });
12360
+ controller.setBrush({ tip: { size: Math.max(1, brush.tip.size + (id === "brush.larger" ? step : -step)) } });
12212
12361
  return true;
12213
12362
  }
12214
- if (key === "escape") {
12215
- controller.resetView();
12216
- return true;
12217
- }
12218
- return false;
12363
+ if (id === "colour.swap") controller.swapColors();
12364
+ else if (id === "view.mirror") controller.setView({ mirror: !controller.getState().view.mirror });
12365
+ else if (id === "view.reset") controller.resetView();
12366
+ return true;
12219
12367
  }
12220
12368
  var PaintCanvasPresenter = class {
12221
12369
  constructor(controller) {
@@ -12461,6 +12609,12 @@ function toggleDotTexture(textures, stamp) {
12461
12609
  if (textures.includes(stamp)) return textures.filter((s) => s !== stamp);
12462
12610
  return textures.length < MAX_DOT_TEXTURES ? [...textures, stamp] : [...textures];
12463
12611
  }
12612
+ var PATH_STROKE_TAPERS = [
12613
+ { id: "even", label: "Even" },
12614
+ { id: "both", label: "Taper both ends", pressure: [0.2, 1, 0.2] },
12615
+ { id: "in", label: "Fade in", pressure: [0.2, 1] },
12616
+ { id: "out", label: "Fade out", pressure: [1, 0.2] }
12617
+ ];
12464
12618
  var STROKE_SLIDERS = [
12465
12619
  { path: "tip.size", label: "Size", min: 1, max: 400, step: 1 },
12466
12620
  { path: "tip.opacity", label: "Opacity", min: 0, max: 1, step: 0.01 },
@@ -13171,6 +13325,127 @@ function createPaintProposalToolProvider(proposals, options = {}) {
13171
13325
  tools: [proposeTool, listTool, acceptTool, rejectTool]
13172
13326
  };
13173
13327
  }
13328
+
13329
+ // src/paint-editor/contract.ts
13330
+ var PAINT_CONTRACT_FORMAT_VERSION = 1;
13331
+ var POINT2 = { type: "object", properties: { x: { type: "number", description: "Document x in pixels." }, y: { type: "number", description: "Document y in pixels." } }, required: ["x", "y"] };
13332
+ var PAINT_DOCUMENT_JSON_SCHEMA = {
13333
+ type: "object",
13334
+ description: "A saved painting, as the paint editor's save tools and autosave write it.",
13335
+ properties: {
13336
+ format: { type: "string", const: EDITOR_DOCUMENT_FORMAT, description: `Always "${EDITOR_DOCUMENT_FORMAT}".` },
13337
+ kind: { type: "string", const: "paint", description: 'Always "paint".' },
13338
+ formatVersion: { type: "integer", minimum: 1, description: "Version of the envelope for this kind; readers migrate older ones." },
13339
+ name: { type: "string", description: "Display name, when it has one." },
13340
+ document: {
13341
+ type: "object",
13342
+ description: "The painting (`SerializedPaintDocument`).",
13343
+ properties: {
13344
+ format: { type: "string", const: PAINT_FORMAT, description: `Always "${PAINT_FORMAT}".` },
13345
+ version: { type: "integer", minimum: 1, maximum: PAINT_SCHEMA_VERSION, description: `Painting version (${PAINT_SCHEMA_VERSION}; version 1 strokes hold flat engine brushes and are migrated).` },
13346
+ width: { type: "integer", minimum: 1, description: "Canvas width in pixels." },
13347
+ height: { type: "integer", minimum: 1, description: "Canvas height in pixels." },
13348
+ dpi: { type: "number", exclusiveMinimum: 0, description: "Pixels per inch." },
13349
+ paper: {
13350
+ type: "object",
13351
+ description: "The paper settings; the paper is regenerated from them, never stored as pixels.",
13352
+ properties: {
13353
+ preset: { type: "string", description: "The paper preset it started from (custom when none)." },
13354
+ name: { type: "string", description: "Display name." },
13355
+ pattern: { type: "string", description: "Grain pattern id." },
13356
+ seed: { type: "number", description: "Grain seed." },
13357
+ color: { type: "string", description: "Paper colour #rrggbb." },
13358
+ scale: { type: "number", description: "Grain scale (0.25 to 8)." },
13359
+ depth: { type: "number", minimum: 0, maximum: 1, description: "Grain depth for dry media." }
13360
+ },
13361
+ required: ["preset", "pattern", "seed", "color"]
13362
+ },
13363
+ layers: {
13364
+ type: "array",
13365
+ description: "Bottom to top: the image editor's serialized layers (pixel layers keep their pixels in `assets`).",
13366
+ items: { type: "object", properties: { id: { type: "string", description: "Layer id." }, name: { type: "string", description: "Layer name." }, type: { type: "string", description: "pixel, group, \u2026" } }, required: ["id", "type"] }
13367
+ },
13368
+ assets: { type: "object", additionalProperties: { type: "string" }, description: "Asset id \u2192 PNG data URL or an external reference." },
13369
+ activeLayerId: { type: ["string", "null"], description: "The active layer." },
13370
+ strokes: {
13371
+ type: "array",
13372
+ description: "Recorded strokes, oldest first (missing when saved without them).",
13373
+ items: {
13374
+ type: "object",
13375
+ properties: {
13376
+ id: { type: "string", description: "Stroke id." },
13377
+ layerId: { type: "string", description: "The layer it painted on." },
13378
+ color: { type: "string", description: "The paint colour as recorded at stroke start." },
13379
+ seed: { type: "number", description: "Seed of its jitter and grain, so a replay is exact." },
13380
+ points: { type: "array", items: POINT2, description: "The samples, with pressure, tilt and time when recorded." },
13381
+ brush: { $ref: "../brushes/brush.schema.json", description: "The facet brush it painted with." }
13382
+ },
13383
+ required: ["id", "layerId", "points", "brush"]
13384
+ }
13385
+ }
13386
+ },
13387
+ required: ["format", "version", "width", "height", "dpi", "paper", "layers", "assets", "activeLayerId"]
13388
+ }
13389
+ },
13390
+ required: ["format", "kind", "formatVersion", "document"]
13391
+ };
13392
+ function toolList(provider) {
13393
+ const tools = typeof provider.tools === "function" ? provider.tools() : provider.tools;
13394
+ return {
13395
+ id: provider.id,
13396
+ title: provider.title,
13397
+ ...provider.skill ? { skill: provider.skill } : {},
13398
+ tools: tools.map((t) => ({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: t.annotations }))
13399
+ };
13400
+ }
13401
+ function uiTables() {
13402
+ return {
13403
+ tools: PAINT_TOOLS,
13404
+ brushOptions: BRUSH_OPTION_CONTROLS,
13405
+ blendMenu: BLEND_MENU_GROUPS,
13406
+ layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
13407
+ sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
13408
+ pathStrokeTapers: PATH_STROKE_TAPERS,
13409
+ padSampleSizes: PAD_SAMPLE_SIZES,
13410
+ wetGpu: WET_GPU_OPTIONS,
13411
+ labels: {
13412
+ wetTools: WET_TOOL_LABELS,
13413
+ wetMedia: WET_MEDIUM_LABELS,
13414
+ impastoTools: IMPASTO_TOOL_LABELS,
13415
+ particleKinds: PARTICLE_KIND_LABELS,
13416
+ particleRenders: PARTICLE_RENDER_LABELS,
13417
+ ribbonPairs: RIBBON_PAIR_LABELS,
13418
+ dotTextureOrders: DOT_TEXTURE_ORDER_LABELS,
13419
+ dotRotations: DOT_ROTATION_LABELS,
13420
+ stamps: STAMP_LABELS
13421
+ }
13422
+ };
13423
+ }
13424
+ function paintContracts(options) {
13425
+ const controller = new PaintEditorController({ width: 64, height: 64 });
13426
+ controller.setEffectsProvider(options.effects);
13427
+ const providers = [
13428
+ createPaintEditorToolProvider(controller),
13429
+ createPaintEffectsToolProvider(controller),
13430
+ createPaintBrushLibraryToolProvider(controller),
13431
+ createPaintProposalToolProvider(createPaintProposals({ controller }))
13432
+ ].map(toolList);
13433
+ const document = createPaintEditorDocument(controller);
13434
+ const files = {
13435
+ "document.schema.json": { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "document.schema.json", ...PAINT_DOCUMENT_JSON_SCHEMA },
13436
+ "tools.json": { providers },
13437
+ "commands.json": PAINT_SHORTCUTS.map((s) => ({ ...s, params: { type: "object", properties: {}, additionalProperties: false } })),
13438
+ "ui.json": uiTables()
13439
+ };
13440
+ files["manifest.json"] = {
13441
+ domain: "paint",
13442
+ formatVersion: PAINT_CONTRACT_FORMAT_VERSION,
13443
+ document: { kind: document.kind, formatVersion: document.formatVersion, format: PAINT_FORMAT, version: PAINT_SCHEMA_VERSION },
13444
+ counts: { providers: providers.length, tools: providers.reduce((n, p) => n + p.tools.length, 0), commands: PAINT_SHORTCUTS.length, layerEffects: LAYER_EFFECT_KINDS.length },
13445
+ files: Object.keys(files).sort()
13446
+ };
13447
+ return files;
13448
+ }
13174
13449
  export {
13175
13450
  BACKLIGHT_SPILL,
13176
13451
  BLEND_MENU_GROUPS,
@@ -13225,6 +13500,7 @@ export {
13225
13500
  MAX_DOT_TEXTURES,
13226
13501
  MAX_IMAGE_SIDE,
13227
13502
  MAX_LIVE_EFFECT_SOURCES,
13503
+ MAX_PATH_SAMPLES,
13228
13504
  MAX_STICKER_STAMPS,
13229
13505
  MAX_ZOOM,
13230
13506
  MIN_CANVAS_SIDE,
@@ -13238,6 +13514,8 @@ export {
13238
13514
  PAINT_BRUSH_CATEGORIES,
13239
13515
  PAINT_BRUSH_ENGINES,
13240
13516
  PAINT_COLOR_JSON_SCHEMA,
13517
+ PAINT_CONTRACT_FORMAT_VERSION,
13518
+ PAINT_DOCUMENT_JSON_SCHEMA,
13241
13519
  PAINT_EFFECT_PLACES,
13242
13520
  PAINT_FORMAT,
13243
13521
  PAINT_GRADIENT_FULL_SPEED,
@@ -13251,6 +13529,7 @@ export {
13251
13529
  PAINT_GRADIENT_SPACES,
13252
13530
  PAINT_GRADIENT_SPACE_LABELS,
13253
13531
  PAINT_SCHEMA_VERSION,
13532
+ PAINT_SHORTCUTS,
13254
13533
  PAINT_SOURCE_JSON_SCHEMA,
13255
13534
  PAINT_TARGET,
13256
13535
  PAINT_TOOLS,
@@ -13265,6 +13544,7 @@ export {
13265
13544
  PARTICLE_KIND_LABELS,
13266
13545
  PARTICLE_RENDERS,
13267
13546
  PARTICLE_RENDER_LABELS,
13547
+ PATH_STROKE_TAPERS,
13268
13548
  PaintCanvasPresenter,
13269
13549
  PaintDabStroke,
13270
13550
  PaintDirector,
@@ -13425,9 +13705,11 @@ export {
13425
13705
  overPixels,
13426
13706
  padSampleRadius,
13427
13707
  paintAgentStrokePoints,
13708
+ paintContracts,
13428
13709
  paintGradientMappingFor,
13429
13710
  paintPlaybackPanel,
13430
13711
  paintProposalGhost,
13712
+ paintShortcutFor,
13431
13713
  paintSourceColor,
13432
13714
  paintSourceCss,
13433
13715
  paintToMultiColor,
@@ -13473,6 +13755,7 @@ export {
13473
13755
  sampleArt,
13474
13756
  sampleEffectAt,
13475
13757
  sampleEffectField,
13758
+ samplePath,
13476
13759
  samplePressure,
13477
13760
  scalePaintGradient,
13478
13761
  scaleStencil,
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pen samples along vector path data, so a path can be brushed like a hand-drawn stroke
3
+ * (`strokePath`, the `stroke_path` tool): each subpath becomes one stroke, sampled at an even
4
+ * spacing, with an optional pressure profile spread along its length (a taper).
5
+ */
6
+ import type { PenPoint } from './engine.cjs';
7
+ /** The most samples one subpath may have (the stroke tools' point limit). */
8
+ export declare const MAX_PATH_SAMPLES = 20000;
9
+ export interface PathSampleOptions {
10
+ /** Distance between samples in document pixels (default 2). */
11
+ spacing?: number;
12
+ /** Pressure along each subpath, spread evenly from its start to its end (default full pressure). */
13
+ pressure?: readonly number[];
14
+ }
15
+ /**
16
+ * Samples SVG path data (document pixels) into pen points, one array per subpath. Closed
17
+ * subpaths end where they start. Throws a sentence when the path has nothing to brush.
18
+ */
19
+ export declare function samplePath(d: string, options?: PathSampleOptions): PenPoint[][];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pen samples along vector path data, so a path can be brushed like a hand-drawn stroke
3
+ * (`strokePath`, the `stroke_path` tool): each subpath becomes one stroke, sampled at an even
4
+ * spacing, with an optional pressure profile spread along its length (a taper).
5
+ */
6
+ import type { PenPoint } from './engine.js';
7
+ /** The most samples one subpath may have (the stroke tools' point limit). */
8
+ export declare const MAX_PATH_SAMPLES = 20000;
9
+ export interface PathSampleOptions {
10
+ /** Distance between samples in document pixels (default 2). */
11
+ spacing?: number;
12
+ /** Pressure along each subpath, spread evenly from its start to its end (default full pressure). */
13
+ pressure?: readonly number[];
14
+ }
15
+ /**
16
+ * Samples SVG path data (document pixels) into pen points, one array per subpath. Closed
17
+ * subpaths end where they start. Throws a sentence when the path has nothing to brush.
18
+ */
19
+ export declare function samplePath(d: string, options?: PathSampleOptions): PenPoint[][];
@@ -43,3 +43,20 @@ export declare function brushStickerRefs(brush: {
43
43
  dotTextures: readonly string[];
44
44
  };
45
45
  }): string[];
46
+ /**
47
+ * Spray marks: the droplets of dab `d`, spread evenly over its disc, each a fraction `size` of
48
+ * the tip (with ±40% variation) and turned at random. Seeded by the stroke `seed`, the dab's
49
+ * index and its travel, so replays lay the same droplets.
50
+ */
51
+ export declare function sprayDroplets<D extends {
52
+ x: number;
53
+ y: number;
54
+ radius: number;
55
+ roundness: number;
56
+ angle: number;
57
+ index: number;
58
+ travel: number;
59
+ }>(d: D, spray: {
60
+ density: number;
61
+ size: number;
62
+ }, seed: number): D[];