@pixerate/editor 0.245.0 → 0.248.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 (50) hide show
  1. package/dist/animation-editor/index.cjs +59 -59
  2. package/dist/animation-editor/index.js +3 -3
  3. package/dist/{brushstrokeRender-QA5WVIDT.cjs → brushstrokeRender-DTDPFBLC.cjs} +5 -5
  4. package/dist/{brushstrokeRender-4542T5HP.js → brushstrokeRender-QLCXROJK.js} +2 -2
  5. package/dist/canvas/index.cjs +8 -8
  6. package/dist/canvas/index.js +1 -1
  7. package/dist/{chunk-OSTKS3YN.js → chunk-5GLYUSNT.js} +1 -1
  8. package/dist/{chunk-4T47Z4J3.cjs → chunk-6G6ZDDDB.cjs} +400 -27
  9. package/dist/chunk-7T7JWTVE.cjs +192 -0
  10. package/dist/{chunk-SD23XMVJ.cjs → chunk-IBDU6EGZ.cjs} +35 -35
  11. package/dist/{chunk-IZL5V5RK.cjs → chunk-L6REDLNB.cjs} +2 -2
  12. package/dist/{chunk-L5PB7EG3.js → chunk-NZ3O3A5C.js} +1 -1
  13. package/dist/{chunk-4EQY4TZJ.js → chunk-Q2HA52V4.js} +1 -1
  14. package/dist/chunk-SC2RZ5EF.js +192 -0
  15. package/dist/{chunk-HZ547SGQ.cjs → chunk-TPWB4Y6L.cjs} +1 -1
  16. package/dist/{chunk-TBJEMELS.js → chunk-VNBUDVD4.js} +398 -25
  17. package/dist/{chunk-M6JORA5Z.cjs → chunk-WVS5KQDH.cjs} +442 -87
  18. package/dist/{chunk-5RFENYO5.js → chunk-XNRBMYNB.js} +1 -1
  19. package/dist/{chunk-BDIH4DYR.cjs → chunk-XWTRATIM.cjs} +23 -23
  20. package/dist/{chunk-7YQNKSQ7.js → chunk-ZFSLEOSQ.js} +357 -2
  21. package/dist/image-editor/effect-tools.d.cts +37 -4
  22. package/dist/image-editor/effect-tools.d.ts +37 -4
  23. package/dist/image-editor/index.cjs +1059 -1552
  24. package/dist/image-editor/index.d.cts +1 -1
  25. package/dist/image-editor/index.d.ts +1 -1
  26. package/dist/image-editor/index.js +275 -768
  27. package/dist/paint-editor/controller.d.cts +34 -1
  28. package/dist/paint-editor/controller.d.ts +34 -1
  29. package/dist/paint-editor/index.cjs +426 -257
  30. package/dist/paint-editor/index.js +184 -15
  31. package/dist/paint-editor/procedural.d.cts +82 -5
  32. package/dist/paint-editor/procedural.d.ts +82 -5
  33. package/dist/paint-editor/tools.d.cts +1 -1
  34. package/dist/paint-editor/tools.d.ts +1 -1
  35. package/dist/paint-editor/ui.d.cts +15 -2
  36. package/dist/paint-editor/ui.d.ts +15 -2
  37. package/dist/{image-editor/layers/effects-ui.d.cts → raster/effects/ui.d.cts} +32 -5
  38. package/dist/{image-editor/layers/effects-ui.d.ts → raster/effects/ui.d.ts} +32 -5
  39. package/dist/raster/index.cjs +13 -1
  40. package/dist/raster/index.d.cts +1 -0
  41. package/dist/raster/index.d.ts +1 -0
  42. package/dist/raster/index.js +12 -0
  43. package/dist/vector/index.cjs +962 -962
  44. package/dist/vector/index.js +4 -4
  45. package/package.json +1 -1
  46. package/skills/pixerate-paint-editor/SKILL.md +18 -27
  47. package/skills/pixerate-paint-editor/references/gradient-paint.md +23 -0
  48. package/skills/pixerate-paint-editor/references/layer-effects.md +28 -0
  49. package/skills/pixerate-paint-editor/references/light-paint.md +10 -2
  50. package/skills/pixerate-paint-editor/references/particle-brushes.md +66 -0
@@ -964,9 +964,56 @@ var MarkStroke = class {
964
964
  const rect = dabRect(dab, hardness);
965
965
  const n = rect.width * rect.height;
966
966
  if (n <= 0) return;
967
+ const vals = this.scratch(n);
968
+ rasterizeDab(dab, hardness, rect, vals);
969
+ this.lay(rect, vals, dab.opacity, pressure, x, y, rects);
970
+ }
971
+ /**
972
+ * Stamps a textured mark: `kind` from the sticker stamps over a square of half-size `r`, turned
973
+ * `rotation` radians clockwise, with flow `alpha` towards `opacity`. Small marks are supersampled.
974
+ */
975
+ stamp(x, y, r, kind, rotation, seed, alpha, opacity, pressure, rects) {
976
+ if (!(r > 0.05) || !(alpha > 0)) return;
977
+ const reach = Math.max(0.5, r) * Math.SQRT2 + 1;
978
+ const rect = { x: Math.floor(x - reach), y: Math.floor(y - reach), width: 0, height: 0 };
979
+ rect.width = Math.ceil(x + reach) - rect.x;
980
+ rect.height = Math.ceil(y + reach) - rect.y;
981
+ const n = rect.width * rect.height;
982
+ const vals = this.scratch(n);
983
+ const ss = r < 10 ? 3 : 1;
984
+ const c = Math.cos(rotation);
985
+ const s = Math.sin(rotation);
986
+ const inv = 1 / Math.max(0.5, r);
987
+ const a = Math.min(1, alpha);
988
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
989
+ for (let xx = 0; xx < rect.width; xx++, k++) {
990
+ let sum = 0;
991
+ for (let sy = 0; sy < ss; sy++) {
992
+ for (let sx = 0; sx < ss; sx++) {
993
+ const dx = rect.x + xx + (sx + 0.5) / ss - x;
994
+ const dy = rect.y + yy + (sy + 0.5) / ss - y;
995
+ sum += stampCoverage(kind, (dx * c + dy * s) * inv, (dy * c - dx * s) * inv, seed);
996
+ }
997
+ }
998
+ vals[k] = sum / (ss * ss) * a;
999
+ }
1000
+ }
1001
+ this.lay(rect, vals, Math.min(1, opacity), pressure, x, y, rects);
1002
+ }
1003
+ /** A scratch buffer of `n` values (reused; valid until the next mark). */
1004
+ scratch(n) {
967
1005
  if (this.buf.length < n) this.buf = new Float32Array(n);
968
1006
  const vals = this.buf.subarray(0, n);
969
- rasterizeDab(dab, hardness, rect, vals);
1007
+ vals.fill(0);
1008
+ return vals;
1009
+ }
1010
+ /**
1011
+ * Lays coverage `vals` over `rect` towards `opacity`: paper grain, then effect paint or the
1012
+ * colour of a mark at (x, y) (gradient paint), into the stroke's coverage.
1013
+ */
1014
+ lay(rect, vals, opacity, pressure, x, y, rects) {
1015
+ const n = rect.width * rect.height;
1016
+ if (n <= 0) return;
970
1017
  const grain = this.options.grain;
971
1018
  if (this.depth > 0 && grain) {
972
1019
  for (let yy = 0, k = 0; yy < rect.height; yy++)
@@ -976,11 +1023,11 @@ var MarkStroke = class {
976
1023
  if (this.field.length < n * 3) this.field = new Float32Array(n * 3);
977
1024
  const field = this.field.subarray(0, n * 3);
978
1025
  sampleEffectField(this.options.effect, rect, vals, field, this.pen);
979
- this.coverage.accumulate(rect, vals, dab.opacity, null, field);
1026
+ this.coverage.accumulate(rect, vals, opacity, null, field);
980
1027
  } else {
981
1028
  const color = this.markColor(x, y);
982
1029
  if (color && color.alpha < 1) for (let k = 0; k < n; k++) vals[k] *= color.alpha;
983
- this.coverage.accumulate(rect, vals, dab.opacity, color ? color.rgb : null);
1030
+ this.coverage.accumulate(rect, vals, opacity, color ? color.rgb : null);
984
1031
  }
985
1032
  rects.push(rect);
986
1033
  this.dabCount++;
@@ -993,14 +1040,41 @@ var MarkStroke = class {
993
1040
  for (let i = 1; i <= n; i++) this.mark(x0 + (x1 - x0) * i / n, y0 + (y1 - y0) * i / n, r, alpha, opacity, pressure, rects);
994
1041
  }
995
1042
  };
996
- var PARTICLE_KINDS = ["string", "spring", "orbit", "flow"];
997
- var PARTICLE_RENDERS = ["dot", "line", "sequence"];
998
- var DEFAULT_PARTICLE = { kind: "spring", render: "line", count: 12, size: 0.08, spread: 0.5, stiffness: 0.4, damping: 0.25, speed: 0.5, gravity: 0, life: 0.5, interval: 0.3 };
1043
+ var PARTICLE_KINDS = ["string", "spring", "orbit", "flow", "elastic", "fuzzy"];
1044
+ var PARTICLE_RENDERS = ["dot", "line", "sequence", "ribbon"];
1045
+ var RIBBON_PAIRS = ["adjacent", "first-last"];
1046
+ var DOT_TEXTURE_ORDERS = ["sequence", "random"];
1047
+ var DOT_ROTATIONS = ["follow", "random", "fixed"];
1048
+ var MAX_DOT_TEXTURES = 4;
1049
+ var DEFAULT_PARTICLE = {
1050
+ kind: "spring",
1051
+ render: "line",
1052
+ count: 12,
1053
+ size: 0.08,
1054
+ spread: 0.5,
1055
+ stiffness: 0.4,
1056
+ damping: 0.25,
1057
+ speed: 0.5,
1058
+ gravity: 0,
1059
+ life: 0.5,
1060
+ interval: 0.3,
1061
+ ribbonFill: 0.5,
1062
+ ribbonPairs: "adjacent",
1063
+ waveAmplitude: 0,
1064
+ waveLength: 60,
1065
+ waveSpeed: 0,
1066
+ wavePhaseJitter: 0,
1067
+ dotTextures: [],
1068
+ dotTextureOrder: "sequence",
1069
+ dotRotation: "random"
1070
+ };
999
1071
  var unit3 = (v, d) => typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : d;
1072
+ var oneOf = (v, all, d) => all.includes(v) ? v : d;
1000
1073
  function normalizeParticle(raw = {}, base = DEFAULT_PARTICLE) {
1074
+ const textures = Array.isArray(raw.dotTextures) ? raw.dotTextures.filter((t) => STAMP_KINDS.includes(t)).slice(0, MAX_DOT_TEXTURES) : [...base.dotTextures ?? []];
1001
1075
  return {
1002
- kind: PARTICLE_KINDS.includes(raw.kind) ? raw.kind : base.kind,
1003
- render: PARTICLE_RENDERS.includes(raw.render) ? raw.render : base.render,
1076
+ kind: oneOf(raw.kind, PARTICLE_KINDS, base.kind),
1077
+ render: oneOf(raw.render, PARTICLE_RENDERS, base.render),
1004
1078
  count: typeof raw.count === "number" && Number.isFinite(raw.count) ? Math.min(64, Math.max(1, Math.round(raw.count))) : base.count,
1005
1079
  size: unit3(raw.size, base.size),
1006
1080
  spread: unit3(raw.spread, base.spread),
@@ -1009,9 +1083,61 @@ function normalizeParticle(raw = {}, base = DEFAULT_PARTICLE) {
1009
1083
  speed: unit3(raw.speed, base.speed),
1010
1084
  gravity: typeof raw.gravity === "number" && Number.isFinite(raw.gravity) ? Math.min(1, Math.max(-1, raw.gravity)) : base.gravity,
1011
1085
  life: unit3(raw.life, base.life),
1012
- interval: unit3(raw.interval, base.interval)
1086
+ interval: unit3(raw.interval, base.interval),
1087
+ ribbonFill: unit3(raw.ribbonFill, base.ribbonFill ?? DEFAULT_PARTICLE.ribbonFill),
1088
+ ribbonPairs: oneOf(raw.ribbonPairs, RIBBON_PAIRS, base.ribbonPairs ?? DEFAULT_PARTICLE.ribbonPairs),
1089
+ waveAmplitude: unit3(raw.waveAmplitude, base.waveAmplitude ?? 0),
1090
+ waveLength: typeof raw.waveLength === "number" && Number.isFinite(raw.waveLength) ? Math.min(2e3, Math.max(4, raw.waveLength)) : base.waveLength ?? DEFAULT_PARTICLE.waveLength,
1091
+ waveSpeed: unit3(raw.waveSpeed, base.waveSpeed ?? 0),
1092
+ wavePhaseJitter: unit3(raw.wavePhaseJitter, base.wavePhaseJitter ?? 0),
1093
+ dotTextures: textures,
1094
+ dotTextureOrder: oneOf(raw.dotTextureOrder, DOT_TEXTURE_ORDERS, base.dotTextureOrder ?? DEFAULT_PARTICLE.dotTextureOrder),
1095
+ dotRotation: oneOf(raw.dotRotation, DOT_ROTATIONS, base.dotRotation ?? DEFAULT_PARTICLE.dotRotation)
1013
1096
  };
1014
1097
  }
1098
+ var FUZZY_AMPLITUDE = 0.12;
1099
+ var NOMINAL_MS_PER_PX = 2;
1100
+ var RIBBON_SUB = 4;
1101
+ function chainDamp(damp, k) {
1102
+ return Math.min(damp, 1 / (1 + 0.8 * Math.sqrt(Math.max(0, k))) ** 2);
1103
+ }
1104
+ function addPolygonArea(pts, rect, out, weight = 1) {
1105
+ const n = pts.length >> 1;
1106
+ if (n < 3) return;
1107
+ let y0 = Infinity;
1108
+ let y1 = -Infinity;
1109
+ for (let i = 1; i < pts.length; i += 2) {
1110
+ if (pts[i] < y0) y0 = pts[i];
1111
+ if (pts[i] > y1) y1 = pts[i];
1112
+ }
1113
+ const r0 = Math.max(0, Math.floor(y0) - rect.y);
1114
+ const r1 = Math.min(rect.height - 1, Math.floor(y1) - rect.y);
1115
+ const xs = [];
1116
+ const w = weight / RIBBON_SUB;
1117
+ for (let row = r0; row <= r1; row++) {
1118
+ const base = row * rect.width;
1119
+ for (let sub = 0; sub < RIBBON_SUB; sub++) {
1120
+ const y = rect.y + row + (sub + 0.5) / RIBBON_SUB;
1121
+ xs.length = 0;
1122
+ for (let i = 0, j = n - 1; i < n; j = i++) {
1123
+ const yi = pts[i * 2 + 1];
1124
+ const yj = pts[j * 2 + 1];
1125
+ if (yi <= y !== yj <= y) xs.push(pts[i * 2] + (y - yi) / (yj - yi) * (pts[j * 2] - pts[i * 2]));
1126
+ }
1127
+ if (xs.length < 2) continue;
1128
+ xs.sort((a, b) => a - b);
1129
+ for (let k = 0; k + 1 < xs.length; k += 2) {
1130
+ const a = Math.max(rect.x, xs[k]);
1131
+ const b = Math.min(rect.x + rect.width, xs[k + 1]);
1132
+ if (!(b > a)) continue;
1133
+ for (let px = Math.floor(a); px < b; px++) {
1134
+ const cover = Math.min(b, px + 1) - Math.max(a, px);
1135
+ if (cover > 0) out[base + px - rect.x] += cover * w;
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1015
1141
  function curl(x, y, seed) {
1016
1142
  const n = (u, v) => {
1017
1143
  const x0 = Math.floor(u);
@@ -1032,12 +1158,51 @@ function curl(x, y, seed) {
1032
1158
  }
1033
1159
  var ParticleStroke = class extends MarkStroke {
1034
1160
  sets;
1161
+ /** Ribbon pairs per copy: particles in their order around the pen (non-flow kinds). */
1162
+ orders;
1035
1163
  s;
1036
1164
  spawn = 0;
1165
+ /** Textured dots drawn so far, per copy (sequence order, random picks). */
1166
+ dots;
1167
+ /** Ribbon fill: the area each pixel has had so far this stroke (telescoped into the coverage). */
1168
+ filled = /* @__PURE__ */ new Map();
1169
+ pts = [];
1037
1170
  constructor(brush, options) {
1038
1171
  super(brush, options);
1039
1172
  this.s = brush.particle;
1040
1173
  this.sets = this.copies.map(() => []);
1174
+ this.orders = this.copies.map(() => []);
1175
+ this.dots = this.copies.map(() => 0);
1176
+ }
1177
+ /** Strand kinds: a chain of masses behind each particle. */
1178
+ get strands() {
1179
+ return this.s.kind === "string" || this.s.kind === "elastic" || this.s.kind === "fuzzy";
1180
+ }
1181
+ newParticle(x, y, ox, oy, k, phase, i, copy) {
1182
+ const s = this.s;
1183
+ const fuzzy = s.kind === "fuzzy";
1184
+ const jitter = fuzzy ? 1 : s.wavePhaseJitter;
1185
+ return {
1186
+ x,
1187
+ y,
1188
+ vx: 0,
1189
+ vy: 0,
1190
+ ox,
1191
+ oy,
1192
+ k,
1193
+ phase,
1194
+ age: 0,
1195
+ run: 0,
1196
+ bead: 0,
1197
+ alive: true,
1198
+ wavePhase: jitter > 0 ? jitter * Math.PI * 2 * rand01(this.seed, i, 43 + copy) : 0,
1199
+ waveScale: fuzzy ? 0.6 + 0.8 * rand01(this.seed, i, 47 + copy) : 1,
1200
+ dx: x,
1201
+ dy: y,
1202
+ px: NaN,
1203
+ py: NaN,
1204
+ fade: 1
1205
+ };
1041
1206
  }
1042
1207
  init(copy, d) {
1043
1208
  const s = this.s;
@@ -1048,18 +1213,28 @@ var ParticleStroke = class extends MarkStroke {
1048
1213
  const a = rand01(this.seed, i, 5 + copy) * Math.PI * 2;
1049
1214
  const ox = Math.cos(a) * r;
1050
1215
  const oy = Math.sin(a) * r;
1051
- const p = { x: d.x + ox, y: d.y + oy, vx: 0, vy: 0, ox, oy, k: 0.6 + 0.8 * rand01(this.seed, i, 7), phase: a, age: 0, run: 0, bead: 0, alive: true };
1052
- if (s.kind === "string") p.strand = Array.from({ length: 6 }, () => ({ x: d.x + ox, y: d.y + oy, vx: 0, vy: 0 }));
1216
+ const p = this.newParticle(d.x + ox, d.y + oy, ox, oy, 0.6 + 0.8 * rand01(this.seed, i, 7), a, i, copy);
1217
+ if (this.strands) p.strand = Array.from({ length: 6 }, () => ({ x: d.x + ox, y: d.y + oy, vx: 0, vy: 0 }));
1053
1218
  if (s.kind === "orbit") {
1054
1219
  p.ox = Math.max(1, r + size * 0.15);
1055
1220
  p.oy = 0;
1056
1221
  }
1057
1222
  list.push(p);
1058
1223
  }
1224
+ this.orders[copy] = list.map((_, i) => i).sort((i, j) => list[i].phase - list[j].phase || i - j);
1225
+ }
1226
+ /** The sideways wave of particle `p` at pen dab `d` (px along the normal to the pen's direction). */
1227
+ wave(p, d, size) {
1228
+ const s = this.s;
1229
+ const amp = s.kind === "fuzzy" ? Math.max(FUZZY_AMPLITUDE, s.waveAmplitude) : s.waveAmplitude;
1230
+ if (!(amp > 0)) return 0;
1231
+ const ms = d.time ?? d.travel * NOMINAL_MS_PER_PX;
1232
+ const phase = 2 * Math.PI * d.travel / (s.waveLength * p.waveScale) - s.waveSpeed * 0.025 * ms + p.wavePhase;
1233
+ return amp * size * Math.sin(phase);
1059
1234
  }
1060
1235
  advance(copy, d, rects) {
1061
1236
  const s = this.s;
1062
- const list = this.sets[copy];
1237
+ let list = this.sets[copy];
1063
1238
  if (d.index === 0 && !list.length) this.init(copy, d);
1064
1239
  const size = Math.max(1, d.radius * 2);
1065
1240
  const markR = Math.max(0.5, size * s.size / 2);
@@ -1068,6 +1243,8 @@ var ParticleStroke = class extends MarkStroke {
1068
1243
  const g = s.gravity * 0.15;
1069
1244
  const op = d.opacity;
1070
1245
  const al = Math.max(0.05, d.alpha);
1246
+ const nx = Math.sin(d.direction);
1247
+ const ny = Math.cos(d.direction);
1071
1248
  if (s.kind === "flow") {
1072
1249
  this.spawn += s.count / 8;
1073
1250
  while (this.spawn >= 1) {
@@ -1075,15 +1252,13 @@ var ParticleStroke = class extends MarkStroke {
1075
1252
  const i = this.dabCount + list.length * 31 + Math.floor(d.index);
1076
1253
  const a = rand01(this.seed, i, 11 + copy) * Math.PI * 2;
1077
1254
  const r = rand01(this.seed, i, 13 + copy) * s.spread * size * 0.5;
1078
- list.push({ x: d.x + Math.cos(a) * r, y: d.y + Math.sin(a) * r, vx: 0, vy: 0, ox: 0, oy: 0, k: 1, phase: a, age: 0, run: 0, bead: 0, alive: true });
1255
+ list.push(this.newParticle(d.x + Math.cos(a) * r, d.y + Math.sin(a) * r, 0, 0, 1, a, i, copy));
1079
1256
  }
1080
1257
  }
1081
1258
  const lifeSteps = 8 + 120 * s.life;
1082
1259
  for (let i = 0; i < list.length; i++) {
1083
1260
  const p = list[i];
1084
1261
  if (!p.alive) continue;
1085
- const px = p.x;
1086
- const py = p.y;
1087
1262
  switch (s.kind) {
1088
1263
  case "spring": {
1089
1264
  p.vx = (p.vx + (d.x + p.ox - p.x) * k * p.k) * damp;
@@ -1092,13 +1267,38 @@ var ParticleStroke = class extends MarkStroke {
1092
1267
  p.y += p.vy;
1093
1268
  break;
1094
1269
  }
1095
- case "string": {
1270
+ case "string":
1271
+ case "fuzzy": {
1096
1272
  const strand = p.strand;
1273
+ const dm = s.kind === "fuzzy" ? chainDamp(damp, k * p.k) : damp;
1097
1274
  let ax = d.x + p.ox;
1098
1275
  let ay = d.y + p.oy;
1099
1276
  for (const m of strand) {
1100
- m.vx = (m.vx + (ax - m.x) * k * p.k + 0) * damp;
1101
- m.vy = (m.vy + (ay - m.y) * k * p.k + g) * damp;
1277
+ m.vx = (m.vx + (ax - m.x) * k * p.k + 0) * dm;
1278
+ m.vy = (m.vy + (ay - m.y) * k * p.k + g) * dm;
1279
+ m.x += m.vx;
1280
+ m.y += m.vy;
1281
+ ax = m.x;
1282
+ ay = m.y;
1283
+ }
1284
+ p.x = strand[strand.length - 1].x;
1285
+ p.y = strand[strand.length - 1].y;
1286
+ break;
1287
+ }
1288
+ case "elastic": {
1289
+ const strand = p.strand;
1290
+ const rest = size * 0.12;
1291
+ const ke = Math.min(0.9, (0.1 + 0.6 * s.stiffness) * p.k);
1292
+ const dm = chainDamp(damp, ke);
1293
+ let ax = d.x + p.ox;
1294
+ let ay = d.y + p.oy;
1295
+ for (const m of strand) {
1296
+ const ex = ax - m.x;
1297
+ const ey = ay - m.y;
1298
+ const len = Math.hypot(ex, ey);
1299
+ const pull = len > rest ? (len - rest) / len : 0;
1300
+ m.vx = (m.vx + ex * pull * ke) * dm;
1301
+ m.vy = (m.vy + ey * pull * ke + g) * dm;
1102
1302
  m.x += m.vx;
1103
1303
  m.y += m.vy;
1104
1304
  ax = m.x;
@@ -1127,11 +1327,21 @@ var ParticleStroke = class extends MarkStroke {
1127
1327
  break;
1128
1328
  }
1129
1329
  }
1330
+ const w = this.wave(p, d, size);
1331
+ const qx = w ? p.x + nx * w : p.x;
1332
+ const qy = w ? p.y + ny * w : p.y;
1333
+ const px = p.dx;
1334
+ const py = p.dy;
1335
+ p.px = p.dx;
1336
+ p.py = p.dy;
1337
+ p.dx = qx;
1338
+ p.dy = qy;
1130
1339
  const fade = s.kind === "flow" ? 1 - p.age / lifeSteps : 1;
1131
- const moved = Math.hypot(p.x - px, p.y - py);
1340
+ p.fade = fade;
1341
+ const moved = Math.hypot(qx - px, qy - py);
1132
1342
  if (d.index === 0 && s.kind !== "flow") continue;
1133
- if (s.render === "dot") this.mark(p.x, p.y, markR * fade, al * fade, op, d.pressure, rects);
1134
- else if (s.render === "line") this.segment(px, py, p.x, p.y, markR * fade, al * fade, op, d.pressure, rects);
1343
+ if (s.render === "dot") this.dot(copy, qx, qy, qx - px, qy - py, markR * fade, al * fade, op, d, rects);
1344
+ else if (s.render === "line" || s.render === "ribbon") this.segment(px, py, qx, qy, markR * fade, al * fade, op, d.pressure, rects);
1135
1345
  else {
1136
1346
  p.run += moved;
1137
1347
  const gap = Math.max(markR * 2, size * (0.05 + s.interval));
@@ -1139,13 +1349,101 @@ var ParticleStroke = class extends MarkStroke {
1139
1349
  p.run -= gap;
1140
1350
  p.bead++;
1141
1351
  const t = moved > 0 ? 1 - p.run / Math.max(moved, 1e-6) : 1;
1142
- const bx = px + (p.x - px) * Math.min(1, Math.max(0, t));
1143
- const by = py + (p.y - py) * Math.min(1, Math.max(0, t));
1144
- this.mark(bx, by, markR * (0.7 + 0.6 * (p.bead % 3 / 2)) * fade, al * fade, op, d.pressure, rects);
1352
+ const bx = px + (qx - px) * Math.min(1, Math.max(0, t));
1353
+ const by = py + (qy - py) * Math.min(1, Math.max(0, t));
1354
+ this.dot(copy, bx, by, qx - px, qy - py, markR * (0.7 + 0.6 * (p.bead % 3 / 2)) * fade, al * fade, op, d, rects);
1145
1355
  }
1146
1356
  }
1147
1357
  }
1148
- if (s.kind === "flow" && list.length > 512) this.sets[copy] = list.filter((p) => p.alive);
1358
+ if (s.render === "ribbon" && !(d.index === 0 && s.kind !== "flow")) this.ribbon(copy, list, op, al, d, rects);
1359
+ if (s.kind === "flow" && list.length > 512) {
1360
+ list = list.filter((p) => p.alive);
1361
+ this.sets[copy] = list;
1362
+ }
1363
+ }
1364
+ /** A dot (or bead): round, or a textured stamp turned by the particle's motion (mx, my), at random or to the tip angle. */
1365
+ dot(copy, x, y, mx, my, r, alpha, opacity, d, rects) {
1366
+ const s = this.s;
1367
+ const textures = s.dotTextures;
1368
+ if (!textures.length) return this.mark(x, y, r, alpha, opacity, d.pressure, rects);
1369
+ const n = this.dots[copy]++;
1370
+ const pick = s.dotTextureOrder === "random" ? Math.min(textures.length - 1, Math.floor(rand01(this.seed, n, 31 + copy) * textures.length)) : n % textures.length;
1371
+ const rotation = s.dotRotation === "random" ? rand01(this.seed, n, 37 + copy) * Math.PI * 2 : s.dotRotation === "follow" ? mx || my ? Math.atan2(my, mx) : -d.direction : -this.brush.tip.angle * Math.PI / 180;
1372
+ this.stamp(x, y, r, textures[pick], rotation, this.seed + n * 2654435761 >>> 0, alpha, opacity, d.pressure, rects);
1373
+ }
1374
+ /**
1375
+ * Ribbon fill for one step: the band between each pair of joined particles, from where they
1376
+ * were drawn last step to where they are drawn now. Bands of one step, and of consecutive steps,
1377
+ * share edges exactly, so the fill has no seams; it is telescoped into the coverage
1378
+ * (`telescope`), so it does not build up at the joins.
1379
+ */
1380
+ ribbon(copy, list, opacity, alpha, d, rects) {
1381
+ const s = this.s;
1382
+ if (!(s.ribbonFill > 0)) return;
1383
+ const live = (p) => !!p && p.alive && !Number.isNaN(p.px);
1384
+ let order;
1385
+ if (s.kind === "flow") order = list.filter(live);
1386
+ else order = this.orders[copy].map((i) => list[i]).filter(live);
1387
+ if (order.length < 2) return;
1388
+ const pairs = [];
1389
+ if (s.ribbonPairs === "first-last") pairs.push([order[0], order[order.length - 1]]);
1390
+ else for (let i = 0; i + 1 < order.length; i++) pairs.push([order[i], order[i + 1]]);
1391
+ const pts = this.pts;
1392
+ const ceil = Math.min(1, opacity * s.ribbonFill);
1393
+ for (const [a, b] of pairs) {
1394
+ pts.length = 0;
1395
+ pts.push(a.px, a.py, a.dx, a.dy, b.dx, b.dy, b.px, b.py);
1396
+ let x0 = Infinity;
1397
+ let y0 = Infinity;
1398
+ let x1 = -Infinity;
1399
+ let y1 = -Infinity;
1400
+ for (let i = 0; i < 8; i += 2) {
1401
+ x0 = Math.min(x0, pts[i]);
1402
+ x1 = Math.max(x1, pts[i]);
1403
+ y0 = Math.min(y0, pts[i + 1]);
1404
+ y1 = Math.max(y1, pts[i + 1]);
1405
+ }
1406
+ const rect = { x: Math.floor(x0), y: Math.floor(y0), width: Math.floor(x1) - Math.floor(x0) + 1, height: Math.floor(y1) - Math.floor(y0) + 1 };
1407
+ const area = this.scratch(rect.width * rect.height);
1408
+ addPolygonArea(pts, rect, area, Math.min(a.fade, b.fade) * alpha);
1409
+ if (this.telescope(rect, area)) this.lay(rect, area, ceil, d.pressure, (a.dx + b.dx) / 2, (a.dy + b.dy) / 2, rects);
1410
+ }
1411
+ }
1412
+ /**
1413
+ * Turns the area newly filled over `rect` into coverage to lay towards the fill's ceiling: the
1414
+ * share of what was left unfilled, so after any number of bands (in any grouping) a pixel's
1415
+ * coverage is the ceiling times the area filled there. False when nothing was added.
1416
+ */
1417
+ telescope(rect, area) {
1418
+ let any = false;
1419
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
1420
+ const gy = rect.y + yy;
1421
+ const ty = Math.floor(gy / TILE);
1422
+ let t;
1423
+ let tx = NaN;
1424
+ for (let xx = 0; xx < rect.width; xx++, k++) {
1425
+ const add = area[k];
1426
+ if (!(add > 0)) continue;
1427
+ const gx = rect.x + xx;
1428
+ const ntx = Math.floor(gx / TILE);
1429
+ if (ntx !== tx || !t) {
1430
+ tx = ntx;
1431
+ const key = tileKey(tx, ty);
1432
+ t = this.filled.get(key);
1433
+ if (!t) {
1434
+ t = new Float32Array(TILE * TILE);
1435
+ this.filled.set(key, t);
1436
+ }
1437
+ }
1438
+ const ti = (gy - ty * TILE) * TILE + (gx - tx * TILE);
1439
+ const before = t[ti];
1440
+ const after = Math.min(1, before + add);
1441
+ t[ti] = after;
1442
+ area[k] = before < 1 ? (after - before) / (1 - before) : 0;
1443
+ any = true;
1444
+ }
1445
+ }
1446
+ return any;
1149
1447
  }
1150
1448
  };
1151
1449
  var DEFAULT_BRISTLE = { count: 32, stiffness: 0.5, splay: 0.5, thickness: 0.06, load: 0.6, jitter: 0.4 };
@@ -1897,6 +2195,66 @@ var BUILTIN_BRUSHES = [
1897
2195
  particle: { kind: "flow", render: "dot", count: 8, size: 0.06, spread: 0.7, speed: 0.9, life: 0.3 },
1898
2196
  light: { amount: 1 }
1899
2197
  }),
2198
+ builtin({
2199
+ // Silk of light: strands dragged behind the pen on slow waves, the bands between them filled.
2200
+ id: "light-ribbon",
2201
+ name: "Light ribbon",
2202
+ category: "light",
2203
+ engine: "particle",
2204
+ tip: { size: 80, hardness: 0.8, opacity: 0.9, flow: 1, smoothing: 0.35, pressureSize: false, pressureOpacity: false },
2205
+ particle: { kind: "string", render: "ribbon", count: 5, size: 0.015, spread: 1, stiffness: 0.05, damping: 0.3, ribbonFill: 0.45, waveAmplitude: 0.25, waveLength: 220, wavePhaseJitter: 0.25 },
2206
+ light: { amount: 0.8 }
2207
+ }),
2208
+ builtin({
2209
+ // Flames: short-lived bands thrown off the pen, rising and curling in the flow.
2210
+ id: "flame-ribbon",
2211
+ name: "Flame ribbon",
2212
+ category: "light",
2213
+ engine: "particle",
2214
+ tip: { size: 50, hardness: 0.6, opacity: 0.85, flow: 1, smoothing: 0.25, pressureSize: true, pressureOpacity: false },
2215
+ particle: { kind: "flow", render: "ribbon", count: 4, size: 0.012, spread: 0.5, speed: 0.6, gravity: -0.6, life: 0.2, ribbonFill: 0.5 },
2216
+ light: { amount: 1 }
2217
+ }),
2218
+ builtin({
2219
+ // A neon tube that lags and snaps back, with a slow shimmer.
2220
+ id: "neon-strand",
2221
+ name: "Neon strand",
2222
+ category: "light",
2223
+ engine: "particle",
2224
+ tip: { size: 40, hardness: 0.9, opacity: 1, flow: 1, smoothing: 0.4, pressureSize: false, pressureOpacity: false },
2225
+ particle: { kind: "elastic", render: "line", count: 3, size: 0.05, spread: 0.25, stiffness: 0.6, damping: 0.3, waveAmplitude: 0.06, waveLength: 120 },
2226
+ light: { amount: 1 }
2227
+ }),
2228
+ builtin({
2229
+ // Smoke lit from within: thin bands thrown into a slow, rising flow.
2230
+ id: "smoke-ribbon",
2231
+ name: "Smoke ribbon",
2232
+ category: "light",
2233
+ engine: "particle",
2234
+ tip: { size: 70, hardness: 0.3, opacity: 0.45, flow: 1, smoothing: 0.3, pressureSize: true, pressureOpacity: false },
2235
+ particle: { kind: "flow", render: "ribbon", count: 3, size: 5e-3, spread: 0.5, speed: 0.15, gravity: -0.15, life: 0.35, ribbonFill: 0.25, waveAmplitude: 0.1, waveLength: 140, wavePhaseJitter: 1 },
2236
+ light: { amount: 0.3 }
2237
+ }),
2238
+ builtin({
2239
+ // Out-of-focus lights: soft discs and rings spaced along springy paths.
2240
+ id: "bokeh-dots",
2241
+ name: "Bokeh dots",
2242
+ category: "light",
2243
+ engine: "particle",
2244
+ tip: { size: 80, hardness: 0.7, opacity: 0.6, flow: 1, smoothing: 0.3, pressureSize: true, pressureOpacity: false },
2245
+ particle: { kind: "spring", render: "sequence", count: 3, size: 0.3, spread: 1, stiffness: 0.15, damping: 0.4, interval: 1, dotTextures: ["dot", "dot", "ring"], dotTextureOrder: "random" },
2246
+ light: { amount: 0.9 }
2247
+ }),
2248
+ builtin({
2249
+ // Sparks: twinkles and stars thrown into the flow, spaced along it.
2250
+ id: "twinkle-sparks",
2251
+ name: "Twinkle sparks",
2252
+ category: "light",
2253
+ engine: "particle",
2254
+ tip: { size: 50, hardness: 0.8, opacity: 1, flow: 1, smoothing: 0.1, pressureSize: true, pressureOpacity: false },
2255
+ particle: { kind: "flow", render: "sequence", count: 3, size: 0.25, spread: 0.8, speed: 0.9, life: 0.35, interval: 0.6, dotTextures: ["sparkle", "sparkle", "star"], dotTextureOrder: "random", dotRotation: "random" },
2256
+ light: { amount: 1 }
2257
+ }),
1900
2258
  builtin({
1901
2259
  id: "glow-wash",
1902
2260
  name: "Glow wash",
@@ -1908,6 +2266,16 @@ var BUILTIN_BRUSHES = [
1908
2266
  wet: { tool: "paint", water: 0.65, pigment: 0.75, body: 0, rewet: 0.1 },
1909
2267
  light: { amount: 0.7 }
1910
2268
  }),
2269
+ builtin({
2270
+ // Fluorescent ink: a transparent chisel marker that multiplies (text under it stays dark) and
2271
+ // glows a little brighter than the paper. Turn the layer's spill off (set_light_spill radius 0).
2272
+ id: "highlighter",
2273
+ name: "Highlighter",
2274
+ category: "light",
2275
+ tip: { size: 30, hardness: 0.85, spacing: 0.04, angle: 45, roundness: 0.35, opacity: 0.6, flow: 1, smoothing: 0.3, pressureSize: false, pressureOpacity: false, blend: "multiply" },
2276
+ grain: 0.05,
2277
+ light: { amount: 0.5 }
2278
+ }),
1911
2279
  builtin({
1912
2280
  id: "sticker-stars",
1913
2281
  name: "Star stickers",
@@ -2070,8 +2438,13 @@ export {
2070
2438
  stickerAt,
2071
2439
  PARTICLE_KINDS,
2072
2440
  PARTICLE_RENDERS,
2441
+ RIBBON_PAIRS,
2442
+ DOT_TEXTURE_ORDERS,
2443
+ DOT_ROTATIONS,
2444
+ MAX_DOT_TEXTURES,
2073
2445
  DEFAULT_PARTICLE,
2074
2446
  normalizeParticle,
2447
+ addPolygonArea,
2075
2448
  ParticleStroke,
2076
2449
  DEFAULT_BRISTLE,
2077
2450
  normalizeBristle,