@pixerate/editor 0.275.1 → 0.277.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 (81) hide show
  1. package/dist/animation-editor/index.cjs +62 -62
  2. package/dist/animation-editor/index.js +6 -6
  3. package/dist/brushes/index.cjs +84 -71
  4. package/dist/brushes/index.js +20 -7
  5. package/dist/brushes/patch.d.cts +4 -4
  6. package/dist/brushes/patch.d.ts +4 -4
  7. package/dist/brushes/profiles.d.cts +4 -0
  8. package/dist/brushes/profiles.d.ts +4 -0
  9. package/dist/brushes/schema.d.cts +2 -0
  10. package/dist/brushes/schema.d.ts +2 -0
  11. package/dist/brushes/types.d.cts +32 -1
  12. package/dist/brushes/types.d.ts +32 -1
  13. package/dist/{brushstrokeRender-2RQL46WB.cjs → brushstrokeRender-7HOMKZNO.cjs} +21 -21
  14. package/dist/{brushstrokeRender-WGY4DZHF.js → brushstrokeRender-EIK3625T.js} +5 -5
  15. package/dist/canvas/index.cjs +13 -13
  16. package/dist/canvas/index.js +1 -1
  17. package/dist/{chunk-T6KNJVBP.cjs → chunk-4NZTK7LM.cjs} +35 -35
  18. package/dist/{chunk-LMG57L5A.js → chunk-CTHWXW5M.js} +121 -8
  19. package/dist/{chunk-43XJC3PP.js → chunk-DWPFGWW5.js} +1 -1
  20. package/dist/{chunk-MSWLJI4G.js → chunk-FNIWHSLI.js} +2 -2
  21. package/dist/{chunk-JDXWLCXF.cjs → chunk-H3EME3S5.cjs} +2 -2
  22. package/dist/{chunk-Y2ISSVPH.js → chunk-HHX7ST6Z.js} +167 -9
  23. package/dist/{chunk-6QNDGMK7.cjs → chunk-HRUK5TFW.cjs} +187 -45
  24. package/dist/{chunk-NIBFKXMD.cjs → chunk-IFWLZZCW.cjs} +619 -2
  25. package/dist/{chunk-W6JDUXP3.js → chunk-J23JOHPR.js} +1 -1
  26. package/dist/{chunk-HVO6XCI4.js → chunk-JPB65KQ6.js} +1 -1
  27. package/dist/{chunk-Y354ETAE.cjs → chunk-JVFGK3DK.cjs} +69 -58
  28. package/dist/{chunk-ACTHSQOI.js → chunk-LDZOXESA.js} +618 -1
  29. package/dist/{chunk-IEEY5RJV.js → chunk-LZE2QZCT.js} +1 -1
  30. package/dist/{chunk-DHC4E2DU.cjs → chunk-MITQGHN3.cjs} +121 -8
  31. package/dist/{chunk-75SYKUOT.js → chunk-N4MCWQZ7.js} +1 -1
  32. package/dist/{chunk-G5TCMKX5.js → chunk-NJLPW4MM.js} +17 -6
  33. package/dist/{chunk-STLTOISW.cjs → chunk-NPPMKMCU.cjs} +17 -17
  34. package/dist/{chunk-OLZXXAER.cjs → chunk-PEQWARCP.cjs} +408 -250
  35. package/dist/{chunk-ZM7IKQ4X.js → chunk-PPDUCZZV.js} +152 -10
  36. package/dist/{chunk-RAPNRK4R.cjs → chunk-QBZEC2JJ.cjs} +88 -88
  37. package/dist/{chunk-RJGW3RQS.js → chunk-ROURHJYT.js} +1 -1
  38. package/dist/{chunk-62MFUHSI.cjs → chunk-UQXULAMQ.cjs} +16 -16
  39. package/dist/{chunk-YYZC2EQO.cjs → chunk-VNF7A2JA.cjs} +23 -23
  40. package/dist/{chunk-C6VB7CPY.cjs → chunk-WFSYZO5Z.cjs} +1 -1
  41. package/dist/image-editor/index.cjs +880 -852
  42. package/dist/image-editor/index.js +36 -8
  43. package/dist/paint/index.cjs +33 -5
  44. package/dist/paint/index.d.cts +1 -0
  45. package/dist/paint/index.d.ts +1 -0
  46. package/dist/paint/index.js +32 -4
  47. package/dist/paint/live-patterns.d.cts +149 -0
  48. package/dist/paint/live-patterns.d.ts +149 -0
  49. package/dist/paint/wet/world.d.cts +13 -0
  50. package/dist/paint/wet/world.d.ts +13 -0
  51. package/dist/paint-editor/brush-model.d.cts +21 -1
  52. package/dist/paint-editor/brush-model.d.ts +21 -1
  53. package/dist/paint-editor/controller.d.cts +102 -1
  54. package/dist/paint-editor/controller.d.ts +102 -1
  55. package/dist/paint-editor/document.d.cts +8 -1
  56. package/dist/paint-editor/document.d.ts +8 -1
  57. package/dist/paint-editor/ferro-tools.d.cts +10 -0
  58. package/dist/paint-editor/ferro-tools.d.ts +10 -0
  59. package/dist/paint-editor/ferro.d.cts +134 -0
  60. package/dist/paint-editor/ferro.d.ts +134 -0
  61. package/dist/paint-editor/index.cjs +2291 -904
  62. package/dist/paint-editor/index.d.cts +2 -0
  63. package/dist/paint-editor/index.d.ts +2 -0
  64. package/dist/paint-editor/index.js +1533 -146
  65. package/dist/paint-editor/stroke.d.cts +14 -0
  66. package/dist/paint-editor/stroke.d.ts +14 -0
  67. package/dist/paint-editor/thick.d.cts +15 -2
  68. package/dist/paint-editor/thick.d.ts +15 -2
  69. package/dist/paint-editor/ui.d.cts +7 -1
  70. package/dist/paint-editor/ui.d.ts +7 -1
  71. package/dist/paint-editor/wet-tools.d.cts +42 -0
  72. package/dist/paint-editor/wet-tools.d.ts +42 -0
  73. package/dist/paint-editor/wet.d.cts +22 -1
  74. package/dist/paint-editor/wet.d.ts +22 -1
  75. package/dist/vector/index.cjs +1002 -1002
  76. package/dist/vector/index.js +7 -7
  77. package/package.json +1 -1
  78. package/skills/pixerate-brushes/SKILL.md +1 -0
  79. package/skills/pixerate-paint-editor/SKILL.md +11 -29
  80. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  81. package/skills/pixerate-paint-editor/references/ferrofluid.md +36 -0
@@ -14,7 +14,7 @@ import {
14
14
  mergeBrushPatch,
15
15
  multiColorSliders,
16
16
  patchBrushValue
17
- } from "../chunk-G5TCMKX5.js";
17
+ } from "../chunk-NJLPW4MM.js";
18
18
  import {
19
19
  createStickerTools,
20
20
  describeStickerRef
@@ -22,7 +22,7 @@ import {
22
22
  import {
23
23
  BRUSH_PREVIEW_DARK_PAPER,
24
24
  brushPreviewsOnDark
25
- } from "../chunk-RJGW3RQS.js";
25
+ } from "../chunk-ROURHJYT.js";
26
26
  import {
27
27
  toVideoFfmpeg
28
28
  } from "../chunk-S2XPXA47.js";
@@ -42,7 +42,7 @@ import {
42
42
  resolveExternalAssets,
43
43
  serializeLayers,
44
44
  serializeLayersExternal
45
- } from "../chunk-MSWLJI4G.js";
45
+ } from "../chunk-FNIWHSLI.js";
46
46
  import "../chunk-ADMRNWR7.js";
47
47
  import "../chunk-WJ4WQJCC.js";
48
48
  import {
@@ -68,7 +68,7 @@ import "../chunk-ZIF4UJJ6.js";
68
68
  import {
69
69
  createPigmentTools,
70
70
  pointerPressure
71
- } from "../chunk-HVO6XCI4.js";
71
+ } from "../chunk-JPB65KQ6.js";
72
72
  import "../chunk-VWEWY4FY.js";
73
73
  import "../chunk-HVQVHUTV.js";
74
74
  import {
@@ -125,7 +125,7 @@ import {
125
125
  wetPaperInput,
126
126
  wetPigmentOf,
127
127
  withStrokePath
128
- } from "../chunk-Y2ISSVPH.js";
128
+ } from "../chunk-HHX7ST6Z.js";
129
129
  import {
130
130
  BRUSH_ID,
131
131
  BRUSH_PRESETS,
@@ -137,6 +137,7 @@ import {
137
137
  DEFAULT_BRISTLE,
138
138
  DEFAULT_BRUSH_ID,
139
139
  DEFAULT_DYNAMICS,
140
+ DEFAULT_FERRO,
140
141
  DEFAULT_FLUORESCENCE,
141
142
  DEFAULT_GHOST,
142
143
  DEFAULT_IMPASTO,
@@ -182,6 +183,7 @@ import {
182
183
  normalizeBristle,
183
184
  normalizeBrushSpec,
184
185
  normalizeDynamics,
186
+ normalizeFerro,
185
187
  normalizeFluorescence,
186
188
  normalizeGhost,
187
189
  normalizeImpasto,
@@ -198,7 +200,7 @@ import {
198
200
  readBrushSource,
199
201
  resolveBrush,
200
202
  usesPaintExchange
201
- } from "../chunk-ZM7IKQ4X.js";
203
+ } from "../chunk-PPDUCZZV.js";
202
204
  import {
203
205
  STAMP_KINDS,
204
206
  StickersSlot,
@@ -248,15 +250,18 @@ import {
248
250
  normalizeWetParams,
249
251
  pigmentOf,
250
252
  wetSupport
251
- } from "../chunk-LMG57L5A.js";
252
- import "../chunk-W6JDUXP3.js";
253
+ } from "../chunk-CTHWXW5M.js";
254
+ import "../chunk-J23JOHPR.js";
253
255
  import {
254
256
  CoverageMap,
257
+ DEFAULT_LIVE_PATTERNS,
255
258
  DEFAULT_PAINT_GRADIENT,
256
259
  DEFAULT_PAINT_GRADIENT_MAPPING,
257
260
  EMPTY_MIXER_LOAD,
258
261
  GRAIN_PATTERNS,
259
262
  GRAIN_TILE,
263
+ LIVE_PATTERN_KINDS,
264
+ LIVE_PATTERN_PRESETS,
260
265
  MAX_BRUSH_SIZE,
261
266
  PAINT_COLOR_JSON_SCHEMA,
262
267
  PAINT_GRADIENT_FULL_SPEED,
@@ -288,6 +293,7 @@ import {
288
293
  isGradientPaint,
289
294
  loadMixer,
290
295
  normalizeHexColor,
296
+ normalizeLivePatterns,
291
297
  paintGradientMappingFor,
292
298
  paintSourceColor,
293
299
  paintSourceCss,
@@ -300,7 +306,7 @@ import {
300
306
  scalePaintGradient,
301
307
  solidPaint,
302
308
  updatePaintGradientStop
303
- } from "../chunk-ACTHSQOI.js";
309
+ } from "../chunk-LDZOXESA.js";
304
310
  import {
305
311
  TileableNoise,
306
312
  hash2,
@@ -408,6 +414,442 @@ function createPaintDocument(options = {}) {
408
414
  return { canvas, paper: normalizePaper(options.paper ?? {}), layers: [layer], activeLayerId: layer.id, strokes: [] };
409
415
  }
410
416
 
417
+ // src/paint-editor/ferro.ts
418
+ var DEFAULT_MAGNET_STRENGTH = 0.75;
419
+ var DEFAULT_MAGNET_RADIUS = 72;
420
+ var FERRO_CARRIER_RGB = [17, 24, 39];
421
+ var FERRO_DEFAULT_GLOSS = 224;
422
+ var FERRO_DEFAULT_METALLIC = 166;
423
+ var FERRO_DEFAULT_RIM = 64;
424
+ var SQRT3_HALF = 0.8660254037844386;
425
+ function normalizeMagnet(raw, base) {
426
+ const x = raw.x ?? base?.x;
427
+ const y = raw.y ?? base?.y;
428
+ const strength = raw.strength ?? base?.strength ?? DEFAULT_MAGNET_STRENGTH;
429
+ const radius = raw.radius ?? base?.radius ?? DEFAULT_MAGNET_RADIUS;
430
+ if (typeof x !== "number" || !Number.isFinite(x)) {
431
+ throw new Error(`x ${String(x)} is not a finite coordinate; pass a number in canvas pixels.`);
432
+ }
433
+ if (typeof y !== "number" || !Number.isFinite(y)) {
434
+ throw new Error(`y ${String(y)} is not a finite coordinate; pass a number in canvas pixels.`);
435
+ }
436
+ if (typeof strength !== "number" || !Number.isFinite(strength) || strength < -1 || strength > 1) {
437
+ throw new Error(`strength ${String(strength)} is out of range; use -1..1.`);
438
+ }
439
+ if (typeof radius !== "number" || !Number.isFinite(radius) || radius < 8 || radius > 2e3) {
440
+ throw new Error(`radius ${String(radius)} is out of range; use 8..2000 px.`);
441
+ }
442
+ return { id: raw.id, x, y, strength, radius };
443
+ }
444
+ function emptyFerro() {
445
+ return new TiledSurface(1, /* @__PURE__ */ new Map(), 0);
446
+ }
447
+ var full = (t, channels) => {
448
+ if (t.data.length === TILE * TILE * channels) return t.data;
449
+ const out = new Uint8ClampedArray(TILE * TILE * channels);
450
+ const u = t.uniform ?? 0;
451
+ if (channels === 1) out.fill(u & 255);
452
+ else {
453
+ const bytes = [u >>> 24 & 255, u >>> 16 & 255, u >>> 8 & 255, u & 255];
454
+ for (let i = 0; i < out.length; i += 4) out.set(bytes, i);
455
+ }
456
+ return out;
457
+ };
458
+ function ferroToShadow(ferro) {
459
+ const tiles = /* @__PURE__ */ new Map();
460
+ for (const [key, tile] of ferro.tiles) {
461
+ const f = full(tile, 1);
462
+ const d = new Uint8ClampedArray(TILE * TILE * 4);
463
+ for (let i = 0, j = 0; i < f.length; i++, j += 4) {
464
+ if (!f[i]) continue;
465
+ d[j] = d[j + 1] = d[j + 2] = 255;
466
+ d[j + 3] = f[i];
467
+ }
468
+ const t = finishTile(d, 4, 0);
469
+ if (t) tiles.set(key, t);
470
+ }
471
+ return new TiledSurface(4, tiles, 0);
472
+ }
473
+ function shadowToFerro(shadow) {
474
+ const tiles = /* @__PURE__ */ new Map();
475
+ for (const [key, tile] of shadow.tiles) {
476
+ const s = full(tile, 4);
477
+ const d = new Uint8ClampedArray(TILE * TILE);
478
+ for (let i = 0, j = 0; i < d.length; i++, j += 4) {
479
+ if (s[j + 3]) d[i] = Math.round((s[j] + s[j + 1] + s[j + 2]) / 3 * s[j + 3] / 255);
480
+ }
481
+ const t = finishTile(d, 1, 0);
482
+ if (t) tiles.set(key, t);
483
+ }
484
+ return new TiledSurface(1, tiles, 0);
485
+ }
486
+ function ferroTwinBrush(brush) {
487
+ return normalizePaintBrush(
488
+ {
489
+ tip: { ...brush.tip, blend: "normal" },
490
+ behavior: { ...brush.behavior, mixing: "rgb" },
491
+ paint: null,
492
+ ...brush.engine === "mixer" ? { mixer: { ...brush.mixer, mixing: "rgb" } } : {},
493
+ ...brush.engine === "blender" ? { blender: { ...brush.blender, mixing: "rgb" } } : {},
494
+ ...brush.engine === "smudge" ? { smudge: { ...brush.smudge, mixing: "rgb" } } : {}
495
+ },
496
+ brush
497
+ );
498
+ }
499
+ function scaleFerro(ferro, k) {
500
+ if (k >= 1) return ferro;
501
+ const tiles = /* @__PURE__ */ new Map();
502
+ if (k > 0) {
503
+ for (const [key, tile] of ferro.tiles) {
504
+ const d = Uint8ClampedArray.from(full(tile, 1), (v) => Math.round(v * k));
505
+ const t = finishTile(d, 1, 0);
506
+ if (t) tiles.set(key, t);
507
+ }
508
+ }
509
+ return new TiledSurface(1, tiles, 0);
510
+ }
511
+ function mergeFerro(lower, upper, upperColor) {
512
+ if (!upper?.tiles.size) return lower;
513
+ const keys = /* @__PURE__ */ new Set([...lower?.tiles.keys() ?? [], ...upper.tiles.keys()]);
514
+ const tiles = /* @__PURE__ */ new Map();
515
+ for (const key of keys) {
516
+ const lo = lower?.tiles.get(key);
517
+ const up = upper.tiles.get(key);
518
+ const col = upperColor.tiles.get(key);
519
+ const l = lo ? full(lo, 1) : null;
520
+ const u = up ? full(up, 1) : null;
521
+ const c = col ? full(col, 4) : null;
522
+ const d = new Uint8ClampedArray(TILE * TILE);
523
+ for (let i = 0; i < d.length; i++) {
524
+ const uf = u ? u[i] : 0;
525
+ const ca = c ? c[i * 4 + 3] : 0;
526
+ const a = Math.max(ca, uf) / 255;
527
+ d[i] = Math.min(255, Math.round(uf + (l ? l[i] : 0) * (1 - a)));
528
+ }
529
+ const t = finishTile(d, 1, 0);
530
+ if (t) tiles.set(key, t);
531
+ }
532
+ return new TiledSurface(1, tiles, 0);
533
+ }
534
+ function ferroFieldAt(x, y, magnets) {
535
+ let bx = 0;
536
+ let by = 0;
537
+ let bz = 0;
538
+ for (let i = 0; i < magnets.length; i++) {
539
+ const m = magnets[i];
540
+ if (m.strength === 0) continue;
541
+ const invR = 1 / Math.max(8, m.radius);
542
+ const u = (x - m.x) * invR;
543
+ const v = (y - m.y) * invR;
544
+ const r2 = u * u + v * v;
545
+ const s = 1 + r2;
546
+ const d = 1 / (s * s * Math.sqrt(s));
547
+ bx += m.strength * 1.5 * u * d;
548
+ by += m.strength * 1.5 * v * d;
549
+ bz += m.strength * (1 - 0.5 * r2) * d;
550
+ }
551
+ const magnitude = Math.hypot(bx, by, bz);
552
+ return { bx, by, bz, magnitude };
553
+ }
554
+ function hexWave(px, py, k) {
555
+ const c0 = Math.cos(k * px);
556
+ const c1 = Math.cos(k * (-0.5 * px + SQRT3_HALF * py));
557
+ const c2 = Math.cos(k * (-0.5 * px - SQRT3_HALF * py));
558
+ return (c0 + c1 + c2 + 1.5) / 4.5;
559
+ }
560
+ function ferroSpikeHeightAt(x, y, ferroByte, magnets, settings = DEFAULT_FERRO) {
561
+ if (ferroByte <= 0 || magnets.length === 0) return 0;
562
+ const field = ferroFieldAt(x, y, magnets);
563
+ const bc = Math.max(0.01, settings.threshold);
564
+ if (field.magnitude <= bc) return 0;
565
+ const amp = Math.sqrt(1 - bc / field.magnitude) * (ferroByte / 255);
566
+ const wl = Math.max(4, settings.wavelength);
567
+ const k = 2 * Math.PI / wl;
568
+ const w0 = hexWave(x, y, k);
569
+ const sz = field.bz < 0 ? -1 : 1;
570
+ const tx = sz * field.bx / field.magnitude;
571
+ const ty = sz * field.by / field.magnitude;
572
+ const shift = 0.32 * wl * amp * w0;
573
+ const w = Math.max(0, hexWave(x - tx * shift, y - ty * shift, k));
574
+ const profile = 0.15 + 0.85 * w * Math.sqrt(w);
575
+ return Math.min(255, Math.max(1, Math.round(255 * amp * profile)));
576
+ }
577
+ function advectFerro(ferro, magnets, offset = { x: 0, y: 0 }, steps = 4) {
578
+ if (!ferro.tiles.size || !magnets.length || steps <= 0) return ferro;
579
+ const b = ferro.bounds;
580
+ if (!b) return ferro;
581
+ let active = false;
582
+ for (let i = 0; i < magnets.length; i++) {
583
+ if (magnets[i].strength !== 0) {
584
+ active = true;
585
+ break;
586
+ }
587
+ }
588
+ if (!active) return ferro;
589
+ const pad = Math.min(32, Math.max(8, steps * 4));
590
+ const rect = {
591
+ x: b.x - pad,
592
+ y: b.y - pad,
593
+ width: b.width + 2 * pad,
594
+ height: b.height + 2 * pad
595
+ };
596
+ const w = rect.width;
597
+ const h = rect.height;
598
+ const raw = readRegion(ferro, rect);
599
+ let cur = new Float64Array(w * h);
600
+ for (let i = 0; i < raw.length; i++) cur[i] = raw[i];
601
+ const vx = new Float64Array(w * h);
602
+ const vy = new Float64Array(w * h);
603
+ for (let gy = 0; gy < h; gy++) {
604
+ const docY = rect.y + gy + offset.y + 0.5;
605
+ for (let gx = 0; gx < w; gx++) {
606
+ const docX = rect.x + gx + offset.x + 0.5;
607
+ let fx = 0;
608
+ let fy = 0;
609
+ for (let mIdx = 0; mIdx < magnets.length; mIdx++) {
610
+ const m = magnets[mIdx];
611
+ if (m.strength === 0) continue;
612
+ const dx = m.x - docX;
613
+ const dy = m.y - docY;
614
+ const r = Math.hypot(dx, dy);
615
+ if (r <= 1e-6) continue;
616
+ const R = Math.max(8, m.radius);
617
+ const u = r / R;
618
+ const s = 1 + u * u;
619
+ const damp = Math.min(1, r * 0.5);
620
+ const pull = Math.abs(m.strength) * damp * 1.5 / (s * Math.sqrt(s));
621
+ fx += dx / r * pull;
622
+ fy += dy / r * pull;
623
+ }
624
+ const mag = Math.hypot(fx, fy);
625
+ if (mag > 2.5) {
626
+ const scale = 2.5 / mag;
627
+ fx *= scale;
628
+ fy *= scale;
629
+ }
630
+ const idx = gy * w + gx;
631
+ vx[idx] = fx;
632
+ vy[idx] = fy;
633
+ }
634
+ }
635
+ for (let step = 0; step < steps; step++) {
636
+ const next = new Float64Array(w * h);
637
+ for (let gy = 0; gy < h; gy++) {
638
+ for (let gx = 0; gx < w; gx++) {
639
+ const idx = gy * w + gx;
640
+ const val = cur[idx];
641
+ if (val <= 0) continue;
642
+ const nx = Math.min(w - 1, Math.max(0, gx + vx[idx]));
643
+ const ny = Math.min(h - 1, Math.max(0, gy + vy[idx]));
644
+ const x0 = Math.floor(nx);
645
+ const y0 = Math.floor(ny);
646
+ const x1 = Math.min(w - 1, x0 + 1);
647
+ const y1 = Math.min(h - 1, y0 + 1);
648
+ const tx = nx - x0;
649
+ const ty = ny - y0;
650
+ next[y0 * w + x0] += val * (1 - tx) * (1 - ty);
651
+ next[y0 * w + x1] += val * tx * (1 - ty);
652
+ next[y1 * w + x0] += val * (1 - tx) * ty;
653
+ next[y1 * w + x1] += val * tx * ty;
654
+ }
655
+ }
656
+ for (let i = 0; i < next.length; i++) {
657
+ cur[i] = Math.min(255, next[i]);
658
+ }
659
+ }
660
+ const out = new Uint8ClampedArray(w * h);
661
+ for (let i = 0; i < out.length; i++) {
662
+ out[i] = Math.min(255, Math.max(0, Math.round(cur[i])));
663
+ }
664
+ return surfaceFromPixels({ width: w, height: h, data: out }, 1, rect.x, rect.y);
665
+ }
666
+ function ferroInfo(ferro, magnets = [], settings = DEFAULT_FERRO, offset = { x: 0, y: 0 }) {
667
+ if (!ferro || !ferro.tiles.size) {
668
+ return {
669
+ bounds: null,
670
+ peak: 0,
671
+ liquidPixels: 0,
672
+ maxSpikeHeight: 0,
673
+ spikePixels: 0,
674
+ magnetCount: magnets.length
675
+ };
676
+ }
677
+ let peak = 0;
678
+ let liquidPixels = 0;
679
+ let maxSpikeHeight = 0;
680
+ let spikePixels = 0;
681
+ for (const [key, tile] of ferro.tiles) {
682
+ const tx = key % 65536 - 32768;
683
+ const ty = Math.floor(key / 65536) - 32768;
684
+ const d = full(tile, 1);
685
+ for (let y = 0, i = 0; y < TILE; y++) {
686
+ const docY = ty * TILE + y + offset.y + 0.5;
687
+ for (let x = 0; x < TILE; x++, i++) {
688
+ const fb = d[i];
689
+ if (!fb) continue;
690
+ liquidPixels++;
691
+ if (fb > peak) peak = fb;
692
+ if (magnets.length > 0) {
693
+ const docX = tx * TILE + x + offset.x + 0.5;
694
+ const hs = ferroSpikeHeightAt(docX, docY, fb, magnets, settings);
695
+ if (hs > 0) {
696
+ spikePixels++;
697
+ if (hs > maxSpikeHeight) maxSpikeHeight = hs;
698
+ }
699
+ }
700
+ }
701
+ }
702
+ }
703
+ return {
704
+ bounds: peak ? ferro.bounds : null,
705
+ peak,
706
+ liquidPixels,
707
+ maxSpikeHeight,
708
+ spikePixels,
709
+ magnetCount: magnets.length
710
+ };
711
+ }
712
+ function ferroCacheKey(ferro) {
713
+ if (!ferro || !ferro.surface.tiles.size) return "";
714
+ const mKey = ferro.magnets.map((m) => `${m.id}:${m.x.toFixed(2)},${m.y.toFixed(2)},${m.strength.toFixed(3)},${m.radius.toFixed(1)}`).join(";");
715
+ const s = ferro.settings;
716
+ return `${ferro.offset.x},${ferro.offset.y}|${s.wavelength.toFixed(2)},${s.threshold.toFixed(3)}|${mKey}`;
717
+ }
718
+ function bakeFerroIntoThick(color, ferro, thick, magnets, settings = DEFAULT_FERRO, offset = { x: 0, y: 0 }) {
719
+ const baseThick = thick ?? emptyThickChannels();
720
+ if (!ferro.tiles.size) {
721
+ return { color, thick: baseThick, maxSpikeHeight: 0, spikePixels: 0, curedPixels: 0 };
722
+ }
723
+ const colorTiles = new Map(color.tiles);
724
+ const heightTiles = new Map(baseThick.height.tiles);
725
+ const materialTiles = new Map(baseThick.material.tiles);
726
+ let maxSpikeHeight = 0;
727
+ let spikePixels = 0;
728
+ let curedPixels = 0;
729
+ for (const [key, fTile] of ferro.tiles) {
730
+ const tx = key % 65536 - 32768;
731
+ const ty = Math.floor(key / 65536) - 32768;
732
+ const fd = full(fTile, 1);
733
+ const cTile = colorTiles.get(key);
734
+ const hTile = heightTiles.get(key);
735
+ const mTile = materialTiles.get(key);
736
+ const cd = cTile ? new Uint8ClampedArray(full(cTile, 4)) : new Uint8ClampedArray(TILE * TILE * 4);
737
+ const hd = hTile ? new Uint8ClampedArray(full(hTile, 1)) : new Uint8ClampedArray(TILE * TILE);
738
+ const md = mTile ? new Uint8ClampedArray(full(mTile, 4)) : new Uint8ClampedArray(TILE * TILE * 4);
739
+ let changedColor = false;
740
+ let changedHeight = false;
741
+ let changedMaterial = false;
742
+ for (let y = 0, p = 0; y < TILE; y++) {
743
+ const docY = ty * TILE + y + offset.y + 0.5;
744
+ for (let x = 0; x < TILE; x++, p++) {
745
+ const fb = fd[p];
746
+ if (!fb) continue;
747
+ curedPixels++;
748
+ const i = p * 4;
749
+ if (cd[i + 3] < fb) {
750
+ const a0 = cd[i + 3] / fb;
751
+ cd[i] = Math.round(cd[i] * a0 + FERRO_CARRIER_RGB[0] * (1 - a0));
752
+ cd[i + 1] = Math.round(cd[i + 1] * a0 + FERRO_CARRIER_RGB[1] * (1 - a0));
753
+ cd[i + 2] = Math.round(cd[i + 2] * a0 + FERRO_CARRIER_RGB[2] * (1 - a0));
754
+ cd[i + 3] = fb;
755
+ changedColor = true;
756
+ }
757
+ const docX = tx * TILE + x + offset.x + 0.5;
758
+ const hs = magnets.length > 0 ? ferroSpikeHeightAt(docX, docY, fb, magnets, settings) : 0;
759
+ if (hs > 0) {
760
+ spikePixels++;
761
+ if (hs > maxSpikeHeight) maxSpikeHeight = hs;
762
+ }
763
+ const bead = Math.max(hs, Math.round(fb * 28 / 255));
764
+ const nh = Math.min(255, hd[p] + bead);
765
+ if (nh !== hd[p]) {
766
+ hd[p] = nh;
767
+ changedHeight = true;
768
+ }
769
+ const w = fb / 255;
770
+ const ng = Math.max(md[i], Math.round(FERRO_DEFAULT_GLOSS * w));
771
+ const nm = Math.max(md[i + 1], Math.round(FERRO_DEFAULT_METALLIC * w));
772
+ const nr = Math.max(md[i + 2], Math.round(FERRO_DEFAULT_RIM * w));
773
+ const na = md[i + 3] || 255;
774
+ if (ng !== md[i] || nm !== md[i + 1] || nr !== md[i + 2] || na !== md[i + 3]) {
775
+ md[i] = ng;
776
+ md[i + 1] = nm;
777
+ md[i + 2] = nr;
778
+ md[i + 3] = na;
779
+ changedMaterial = true;
780
+ }
781
+ }
782
+ }
783
+ if (changedColor) {
784
+ const ct = finishTile(cd, 4, 0);
785
+ if (ct) colorTiles.set(key, ct);
786
+ }
787
+ if (changedHeight) {
788
+ const ht = finishTile(hd, 1, 0);
789
+ if (ht) heightTiles.set(key, ht);
790
+ }
791
+ if (changedMaterial) {
792
+ const mt = finishTile(md, 4, 0);
793
+ if (mt) materialTiles.set(key, mt);
794
+ }
795
+ }
796
+ return {
797
+ color: new TiledSurface(4, colorTiles, color.defaultValue),
798
+ thick: {
799
+ height: new TiledSurface(1, heightTiles, 0),
800
+ material: new TiledSurface(4, materialTiles, 0),
801
+ film: baseThick.film
802
+ },
803
+ maxSpikeHeight,
804
+ spikePixels,
805
+ curedPixels
806
+ };
807
+ }
808
+ function ferroToJSON(ferro, settings) {
809
+ const b = ferroInfo(ferro).bounds;
810
+ if (!b) return null;
811
+ const out = {
812
+ rect: { ...b },
813
+ ferro: pngDataUrl(b.width, b.height, readRegion(ferro, b), 1)
814
+ };
815
+ if (settings) {
816
+ if (settings.wavelength !== DEFAULT_FERRO.wavelength) out.wavelength = settings.wavelength;
817
+ if (settings.threshold !== DEFAULT_FERRO.threshold) out.threshold = settings.threshold;
818
+ }
819
+ return out;
820
+ }
821
+ function ferroFromJSON(raw) {
822
+ const r = raw;
823
+ if (!r || typeof r !== "object" || !r.rect || typeof r.ferro !== "string") {
824
+ throw new Error("not a saved ferrofluid channel");
825
+ }
826
+ const parsed = parseDataUrl(r.ferro);
827
+ if (!parsed) throw new Error("the ferrofluid channel is not a PNG data URL");
828
+ const img = decodePng(parsed.bytes, { keepGray: true });
829
+ const data = img.channels === 1 ? img.data : Uint8ClampedArray.from({ length: img.width * img.height }, (_, i) => img.data[i * 4]);
830
+ const surface = surfaceFromPixels({ width: img.width, height: img.height, data }, 1, r.rect.x, r.rect.y);
831
+ const settings = normalizeFerro({
832
+ amount: 0,
833
+ wavelength: r.wavelength,
834
+ threshold: r.threshold
835
+ });
836
+ return { surface, settings };
837
+ }
838
+ function magnetsFromJSON(raw) {
839
+ if (!Array.isArray(raw)) return [];
840
+ const out = [];
841
+ for (const item of raw) {
842
+ if (!item || typeof item !== "object") continue;
843
+ const m = item;
844
+ if (typeof m.id !== "string" || !m.id || typeof m.x !== "number" || typeof m.y !== "number") continue;
845
+ try {
846
+ out.push(normalizeMagnet({ id: m.id, x: m.x, y: m.y, strength: m.strength, radius: m.radius }));
847
+ } catch {
848
+ }
849
+ }
850
+ return out;
851
+ }
852
+
411
853
  // src/paint-editor/thick.ts
412
854
  var DEFAULT_LIGHTING = { azimuth: 315, elevation: 40, relief: 0.7 };
413
855
  function normalizeLighting(raw = {}, base = DEFAULT_LIGHTING) {
@@ -547,8 +989,14 @@ function lightTile(color, height, heightAround, material, light, film = null, vi
547
989
  }
548
990
  var LitCache = class {
549
991
  tiles = /* @__PURE__ */ new Map();
550
- /** The layer's pixels lit (tiles without relief or film nearby are the colour tiles themselves). */
551
- lit(color, ch, light, viewTilt = DEFAULT_VIEW_TILT) {
992
+ /**
993
+ * The layer's pixels lit (tiles without relief, film or ferrofluid nearby are the colour tiles
994
+ * themselves). Without ferrofluid this is the thick-paint / thin-film pass; with it, the liquid
995
+ * ferrofluid adds its carrier colour, glossy metallic material and (near magnets) Rosensweig
996
+ * spike heights before the same lighting.
997
+ */
998
+ lit(color, ch, light, viewTilt = DEFAULT_VIEW_TILT, ferro) {
999
+ if (ferro && ferro.surface.tiles.size > 0) return this.litFerro(color, ch, light, viewTilt, ferro);
552
1000
  const hasRelief = ch.height.tiles.size > 0 && light.relief > 0;
553
1001
  const hasFilm = ch.film.tiles.size > 0;
554
1002
  if (!hasRelief && !hasFilm) return color;
@@ -597,6 +1045,101 @@ var LitCache = class {
597
1045
  for (const key of [...this.tiles.keys()]) if (!keys.has(key)) this.tiles.delete(key);
598
1046
  return new TiledSurface(4, out, color.defaultValue);
599
1047
  }
1048
+ /**
1049
+ * The ferrofluid lighting pass: every tile with ferrofluid or thick relief nearby is lit, with the
1050
+ * liquid's carrier colour and gloss / metal material blended in and its spikes added to the
1051
+ * height field. Thick relief is lit even when `light.relief` is 0 here (the spikes need it), and
1052
+ * a thin film on the same layer still colours the lit tiles.
1053
+ */
1054
+ litFerro(color, ch, light, viewTilt, ferro) {
1055
+ const hasFerroSpikes = ferro.magnets.length > 0;
1056
+ const lk = `${lightingKey(light)}|${viewTiltKey(viewTilt)}`;
1057
+ const fk = ferroCacheKey(ferro);
1058
+ const out = new Map(color.tiles);
1059
+ const keys = /* @__PURE__ */ new Set();
1060
+ for (const key of color.tiles.keys()) keys.add(key);
1061
+ for (const key of ferro.surface.tiles.keys()) keys.add(key);
1062
+ for (const key of keys) {
1063
+ const tile = color.tiles.get(key) ?? null;
1064
+ const tx = key % 65536 - 32768;
1065
+ const ty = Math.floor(key / 65536) - 32768;
1066
+ const aroundH = (dx, dy) => ch.height.tiles.get(tileKey(tx + dx, ty + dy)) ?? null;
1067
+ const aroundF = (dx, dy) => ferro.surface.tiles.get(tileKey(tx + dx, ty + dy)) ?? null;
1068
+ const film = ch.film.tiles.get(key) ?? null;
1069
+ let any = !!film;
1070
+ for (let dy = -1; dy <= 1 && !any; dy++) {
1071
+ for (let dx = -1; dx <= 1 && !any; dx++) {
1072
+ if (aroundH(dx, dy) || aroundF(dx, dy)) any = true;
1073
+ }
1074
+ }
1075
+ if (!any) continue;
1076
+ const m = ch.material.tiles.get(key) ?? null;
1077
+ const f = ferro.surface.tiles.get(key) ?? null;
1078
+ if (!tile && !f) continue;
1079
+ const inputs = [tile, lk, m, fk, film];
1080
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) inputs.push(aroundH(dx, dy));
1081
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) inputs.push(aroundF(dx, dy));
1082
+ const cached = this.tiles.get(key);
1083
+ if (cached && cached.inputs.length === inputs.length && cached.inputs.every((v, i) => v === inputs[i])) {
1084
+ out.set(key, cached.tile);
1085
+ continue;
1086
+ }
1087
+ let cd = tile ? expand(tile, 4) : new Uint8ClampedArray(TILE * TILE * 4);
1088
+ let md = m ? expand(m, 4) : null;
1089
+ if (f) {
1090
+ const fd = expand(f, 1);
1091
+ cd = new Uint8ClampedArray(cd);
1092
+ const mBuf = md ? new Uint8ClampedArray(md) : new Uint8ClampedArray(TILE * TILE * 4);
1093
+ for (let p = 0; p < fd.length; p++) {
1094
+ const fb = fd[p];
1095
+ if (!fb) continue;
1096
+ const i = p * 4;
1097
+ if (cd[i + 3] < fb) {
1098
+ const a0 = cd[i + 3] / fb;
1099
+ cd[i] = Math.round(cd[i] * a0 + FERRO_CARRIER_RGB[0] * (1 - a0));
1100
+ cd[i + 1] = Math.round(cd[i + 1] * a0 + FERRO_CARRIER_RGB[1] * (1 - a0));
1101
+ cd[i + 2] = Math.round(cd[i + 2] * a0 + FERRO_CARRIER_RGB[2] * (1 - a0));
1102
+ cd[i + 3] = fb;
1103
+ }
1104
+ const w = fb / 255;
1105
+ mBuf[i] = Math.max(mBuf[i], Math.round(FERRO_DEFAULT_GLOSS * w));
1106
+ mBuf[i + 1] = Math.max(mBuf[i + 1], Math.round(FERRO_DEFAULT_METALLIC * w));
1107
+ mBuf[i + 2] = Math.max(mBuf[i + 2], Math.round(FERRO_DEFAULT_RIM * w));
1108
+ mBuf[i + 3] = mBuf[i + 3] || 255;
1109
+ }
1110
+ md = mBuf;
1111
+ }
1112
+ const buildHeightTile = (ntx, nty) => {
1113
+ const nkey = tileKey(ntx, nty);
1114
+ const ht = ch.height.tiles.get(nkey) ?? null;
1115
+ const ft = hasFerroSpikes ? ferro.surface.tiles.get(nkey) ?? null : null;
1116
+ if (!ht && !ft) return null;
1117
+ if (!ft) return expand(ht, 1);
1118
+ const hBase = ht ? expand(ht, 1) : null;
1119
+ const fData = expand(ft, 1);
1120
+ const outH = hBase ? new Uint8ClampedArray(hBase) : new Uint8ClampedArray(TILE * TILE);
1121
+ const ox = ntx * TILE + ferro.offset.x + 0.5;
1122
+ const oy = nty * TILE + ferro.offset.y + 0.5;
1123
+ for (let py = 0, p = 0; py < TILE; py++) {
1124
+ const docY = oy + py;
1125
+ for (let px = 0; px < TILE; px++, p++) {
1126
+ const fb = fData[p];
1127
+ if (!fb) continue;
1128
+ const hs = ferroSpikeHeightAt(ox + px, docY, fb, ferro.magnets, ferro.settings);
1129
+ if (hs > 0) outH[p] = Math.min(255, outH[p] + hs);
1130
+ }
1131
+ }
1132
+ return outH;
1133
+ };
1134
+ const hd = buildHeightTile(tx, ty);
1135
+ const data = lightTile(cd, hd, (dx, dy) => buildHeightTile(tx + dx, ty + dy), md, light, film ? expand(film, 4) : null, viewTilt);
1136
+ const lit = { data, uniform: null };
1137
+ this.tiles.set(key, { inputs, tile: lit });
1138
+ out.set(key, lit);
1139
+ }
1140
+ for (const key of [...this.tiles.keys()]) if (!keys.has(key)) this.tiles.delete(key);
1141
+ return new TiledSurface(4, out, color.defaultValue);
1142
+ }
600
1143
  };
601
1144
  function expand(t, channels) {
602
1145
  if (t.data.length === TILE * TILE * channels) return t.data;
@@ -920,27 +1463,27 @@ var ThickStrokeDriver = class {
920
1463
  const u = ((dx + 0.5 - d.x) * nxs + (dy + 0.5 - d.y) * nys) / r;
921
1464
  const h0 = this.getH(lx, ly);
922
1465
  let h = h0;
923
- const full2 = 255 * thick * k;
1466
+ const full3 = 255 * thick * k;
924
1467
  switch (im.tool) {
925
1468
  case "brush": {
926
1469
  const ridge = 0.6 + 0.4 * Math.sin(u * 9 + hash2(Math.round(u * 6), 3, seed) * 6.28) + 0.15 * (hash2(lx, ly, seed) - 0.5);
927
- const target = full2 * c * ridge;
1470
+ const target = full3 * c * ridge;
928
1471
  h = Math.max(h0 * (1 - 0.25 * c * im.drag), Math.min(255, h0 * 0.35 + target));
929
1472
  break;
930
1473
  }
931
1474
  case "tube": {
932
1475
  const dome = Math.sqrt(Math.max(0, c));
933
- h = Math.max(h0, Math.min(255, full2 * dome * 1.1));
1476
+ h = Math.max(h0, Math.min(255, full3 * dome * 1.1));
934
1477
  break;
935
1478
  }
936
1479
  case "gloop": {
937
1480
  const dome = Math.sqrt(Math.max(0, 1 - Math.min(1, Math.abs(u)) ** 2));
938
- h = Math.max(h0, Math.min(255, full2 * dome * (c > 0.2 ? 1 : c * 5)));
1481
+ h = Math.max(h0, Math.min(255, full3 * dome * (c > 0.2 ? 1 : c * 5)));
939
1482
  break;
940
1483
  }
941
1484
  case "roller": {
942
1485
  const stipple = 0.75 + 0.5 * hash2(lx >> 1, ly >> 1, seed);
943
- h = h0 + (Math.min(255, full2 * 0.6 * stipple) - h0) * Math.min(1, c * 1.5);
1486
+ h = h0 + (Math.min(255, full3 * 0.6 * stipple) - h0) * Math.min(1, c * 1.5);
944
1487
  break;
945
1488
  }
946
1489
  }
@@ -1064,7 +1607,7 @@ function normalizeLightSpill(raw, base = DEFAULT_LIGHT_SPILL) {
1064
1607
  function emptyLight() {
1065
1608
  return new TiledSurface(1, /* @__PURE__ */ new Map(), 0);
1066
1609
  }
1067
- var full = (t, channels) => {
1610
+ var full2 = (t, channels) => {
1068
1611
  if (t.data.length === TILE * TILE * channels) return t.data;
1069
1612
  const out = new Uint8ClampedArray(TILE * TILE * channels);
1070
1613
  const u = t.uniform ?? 0;
@@ -1078,7 +1621,7 @@ var full = (t, channels) => {
1078
1621
  function lightToShadow(light) {
1079
1622
  const tiles = /* @__PURE__ */ new Map();
1080
1623
  for (const [key, tile] of light.tiles) {
1081
- const l = full(tile, 1);
1624
+ const l = full2(tile, 1);
1082
1625
  const d = new Uint8ClampedArray(TILE * TILE * 4);
1083
1626
  for (let i = 0, j = 0; i < l.length; i++, j += 4) {
1084
1627
  if (!l[i]) continue;
@@ -1093,7 +1636,7 @@ function lightToShadow(light) {
1093
1636
  function shadowToLight(shadow) {
1094
1637
  const tiles = /* @__PURE__ */ new Map();
1095
1638
  for (const [key, tile] of shadow.tiles) {
1096
- const s = full(tile, 4);
1639
+ const s = full2(tile, 4);
1097
1640
  const d = new Uint8ClampedArray(TILE * TILE);
1098
1641
  for (let i = 0, j = 0; i < d.length; i++, j += 4) if (s[j + 3]) d[i] = Math.round((s[j] + s[j + 1] + s[j + 2]) / 3 * s[j + 3] / 255);
1099
1642
  const t = finishTile(d, 1, 0);
@@ -1128,7 +1671,7 @@ function scaleLight(light, k) {
1128
1671
  const tiles = /* @__PURE__ */ new Map();
1129
1672
  if (k > 0) {
1130
1673
  for (const [key, tile] of light.tiles) {
1131
- const d = Uint8ClampedArray.from(full(tile, 1), (v) => Math.round(v * k));
1674
+ const d = Uint8ClampedArray.from(full2(tile, 1), (v) => Math.round(v * k));
1132
1675
  const t = finishTile(d, 1, 0);
1133
1676
  if (t) tiles.set(key, t);
1134
1677
  }
@@ -1143,9 +1686,9 @@ function mergeLight(lower, upper, upperColor) {
1143
1686
  const lo = lower?.tiles.get(key);
1144
1687
  const up = upper.tiles.get(key);
1145
1688
  const col = upperColor.tiles.get(key);
1146
- const l = lo ? full(lo, 1) : null;
1147
- const u = up ? full(up, 1) : null;
1148
- const c = col ? full(col, 4) : null;
1689
+ const l = lo ? full2(lo, 1) : null;
1690
+ const u = up ? full2(up, 1) : null;
1691
+ const c = col ? full2(col, 4) : null;
1149
1692
  const d = new Uint8ClampedArray(TILE * TILE);
1150
1693
  for (let i = 0; i < d.length; i++) {
1151
1694
  const a = c ? c[i * 4 + 3] / 255 : 0;
@@ -1182,10 +1725,10 @@ var LightCache = class {
1182
1725
  out.set(key, cached.tile);
1183
1726
  continue;
1184
1727
  }
1185
- const c = full(tile, 4);
1186
- const ld = l ? full(l, 1) : null;
1187
- const md = m ? full(m, 4) : null;
1188
- const hd = h ? full(h, 1) : null;
1728
+ const c = full2(tile, 4);
1729
+ const ld = l ? full2(l, 1) : null;
1730
+ const md = m ? full2(m, 4) : null;
1731
+ const hd = h ? full2(h, 1) : null;
1189
1732
  const d = new Uint8ClampedArray(c);
1190
1733
  for (let y = 0, i = 0; y < TILE; y++) {
1191
1734
  for (let x = 0; x < TILE; x++, i++) {
@@ -2912,6 +3455,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2912
3455
  this.invisibleDaylight = brush.fluorescence.amount > 0 && brush.fluorescence.invisible && brush.engine !== "eraser" && brush.engine !== "blender" && brush.engine !== "smudge";
2913
3456
  if (init.wet) this.wetDriver = init.wet.driver;
2914
3457
  if (init.light && !init.wet && !init.sticker) this.lightTwin = this.makeLightTwin(init.light.surface);
3458
+ if (init.ferro && !init.wet && !init.sticker) this.ferroTwin = this.makeFerroTwin(init.ferro.surface);
2915
3459
  if (init.fluor && !init.wet && !init.sticker) this.fluorTwin = this.makeFluorTwin(init.fluor.surface);
2916
3460
  if (init.thermo && !init.wet && !init.sticker) this.thermoTwin = this.makeThermoTwin(init.thermo.surface);
2917
3461
  this.apply = {
@@ -2944,6 +3488,9 @@ var PaintStrokeSession = class _PaintStrokeSession {
2944
3488
  /** Light paint: the stroke's twin over the light channel, and what it left there. */
2945
3489
  lightTwin = null;
2946
3490
  lightResult = null;
3491
+ /** Ferrofluid paint: the stroke's twin over the ferrofluid channel, and what it left there. */
3492
+ ferroTwin = null;
3493
+ ferroResult = null;
2947
3494
  /** UV-fluorescent ink: the stroke's twin over the fluorescence channel, and what it left there. */
2948
3495
  fluorTwin = null;
2949
3496
  fluorResult = null;
@@ -3042,6 +3589,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
3042
3589
  paint,
3043
3590
  effect,
3044
3591
  light: null,
3592
+ ferro: null,
3045
3593
  fluor: null,
3046
3594
  thermo: null
3047
3595
  });
@@ -3065,7 +3613,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
3065
3613
  inputFilter: void 0,
3066
3614
  light: null,
3067
3615
  fluor: null,
3068
- thermo: null
3616
+ thermo: null,
3617
+ ferro: null
3069
3618
  });
3070
3619
  }
3071
3620
  /** The stroke's twin over the 4-channel thermochromic surface (see `heat.ts`). */
@@ -3100,7 +3649,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
3100
3649
  effect: null,
3101
3650
  light: null,
3102
3651
  fluor: null,
3103
- thermo: null
3652
+ thermo: null,
3653
+ ferro: null
3104
3654
  });
3105
3655
  }
3106
3656
  /** Light paint: the layer's light channel after the stroke, or `null` when the stroke has no twin or left it unchanged. */
@@ -3115,6 +3665,44 @@ var PaintStrokeSession = class _PaintStrokeSession {
3115
3665
  thermoChannel() {
3116
3666
  return this.thermoResult;
3117
3667
  }
3668
+ /** The stroke's twin over the ferrofluid channel: the same marks in grey at the brush's ferro amount (see `ferro.ts`). */
3669
+ makeFerroTwin(ferro) {
3670
+ const init = this.init;
3671
+ const grey = Math.round(init.brush.ferro.amount * 255);
3672
+ const g = grey / 255;
3673
+ const hex2 = "#" + grey.toString(16).padStart(2, "0").repeat(3);
3674
+ const host = {
3675
+ update: () => {
3676
+ },
3677
+ commit: (_s, surface) => {
3678
+ this.ferroResult = surface ? shadowToFerro(surface) : null;
3679
+ }
3680
+ };
3681
+ const effect = init.effect ? { source: init.effect.source, pixels: { ...init.effect.pixels, data: greyPixels(init.effect.pixels.data, grey) } } : null;
3682
+ const paint = init.paint ? { ...init.paint, stops: init.paint.stops.map((st) => ({ ...st, color: hex2 })) } : null;
3683
+ return new _PaintStrokeSession(host, {
3684
+ ...init,
3685
+ surface: ferroToShadow(ferro),
3686
+ lockTransparency: false,
3687
+ brush: ferroTwinBrush(init.brush),
3688
+ color: [grey, grey, grey, 255],
3689
+ colorHex: hex2,
3690
+ secondary: [grey, grey, grey, 255],
3691
+ inputFilter: void 0,
3692
+ mixerLoad: { reservoir: init.mixerLoad.reservoir ? [g, g, g] : null, amount: init.mixerLoad.amount, pickup: null },
3693
+ thick: init.thick,
3694
+ paint,
3695
+ effect,
3696
+ light: null,
3697
+ ferro: null,
3698
+ fluor: null,
3699
+ thermo: null
3700
+ });
3701
+ }
3702
+ /** Ferrofluid paint: the layer's ferrofluid channel after the stroke, or `null` when the stroke has no twin or left it unchanged. */
3703
+ ferroChannel() {
3704
+ return this.ferroResult;
3705
+ }
3118
3706
  tileTarget() {
3119
3707
  return {
3120
3708
  offset: this.init.offset,
@@ -3145,6 +3733,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
3145
3733
  this.lastRaw = points[points.length - 1];
3146
3734
  this.host.sampled?.(this, points);
3147
3735
  this.lightTwin?.push(points);
3736
+ this.ferroTwin?.push(points);
3148
3737
  this.fluorTwin?.push(points);
3149
3738
  this.thermoTwin?.push(points);
3150
3739
  if (this.wetDriver) {
@@ -3238,6 +3827,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
3238
3827
  this.update(this.dry ? this.dry.finish() : this.thickDriver ? this.thickDriver.finish() : this.stickerSpray ? this.stickerSpray.finish() : this.wet.finish());
3239
3828
  this.closed = true;
3240
3829
  if (this.lightTwin?.open) this.lightTwin.end();
3830
+ if (this.ferroTwin?.open) this.ferroTwin.end();
3241
3831
  if (this.fluorTwin?.open) this.fluorTwin.end();
3242
3832
  if (this.thermoTwin?.open) this.thermoTwin.end();
3243
3833
  const ch = this.base.channels;
@@ -3272,6 +3862,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
3272
3862
  if (this.fluorTwin?.open) this.fluorTwin.cancel();
3273
3863
  if (this.thermoTwin?.open) this.thermoTwin.cancel();
3274
3864
  this.lightResult = null;
3865
+ if (this.ferroTwin?.open) this.ferroTwin.cancel();
3866
+ this.ferroResult = null;
3275
3867
  this.fluorResult = null;
3276
3868
  this.thermoResult = null;
3277
3869
  this.host.commit(this, null, true);
@@ -3388,8 +3980,8 @@ function nearestOnStencil(s, p) {
3388
3980
  if (s.kind === "arc") {
3389
3981
  const c = s.center;
3390
3982
  const ang = Math.atan2(p.y - c.y, p.x - c.x);
3391
- const full2 = Math.abs(s.endAngle - s.startAngle) >= 360;
3392
- if (full2) {
3983
+ const full3 = Math.abs(s.endAngle - s.startAngle) >= 360;
3984
+ if (full3) {
3393
3985
  const q = { x: c.x + Math.cos(ang) * s.radius, y: c.y + Math.sin(ang) * s.radius };
3394
3986
  return { point: q, distance: Math.hypot(p.x - q.x, p.y - q.y) };
3395
3987
  }
@@ -3832,7 +4424,7 @@ function deserializePaintDocument(raw) {
3832
4424
  const wetEvents = Array.isArray(r.wetEvents) ? r.wetEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.clock) && typeof e.kind === "string") : [];
3833
4425
  const state = { canvas, paper, layers, activeLayerId, strokes: readStrokes(r.strokes, problems) };
3834
4426
  if (wetEvents.length) state.wetEvents = wetEvents;
3835
- const paintEvents = Array.isArray(r.paintEvents) ? r.paintEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.at) && typeof e.layerId === "string" && ["fill", "fill-region", "clear", "flatten", "stickers", "live-strokes"].includes(e.kind)) : [];
4427
+ const paintEvents = Array.isArray(r.paintEvents) ? r.paintEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.at) && typeof e.layerId === "string" && ["fill", "fill-region", "clear", "flatten", "stickers", "live-strokes", "magnets", "cure-ferrofluid"].includes(e.kind)) : [];
3836
4428
  if (paintEvents.length) state.paintEvents = paintEvents;
3837
4429
  const o = r.origin;
3838
4430
  if (o && typeof o === "object" && Number.isFinite(o.x) && Number.isFinite(o.y) && (o.x || o.y)) state.origin = { x: Math.round(o.x), y: Math.round(o.y) };
@@ -4764,6 +5356,7 @@ function liveStrokeRefusal(brush) {
4764
5356
  if (brush.light.amount > 0) return `The ${brush.name} brush lays light, which a stroke layer doesn't redraw yet; paint light on a pixel layer (add_layer), or set light.amount to 0 (set_brush).`;
4765
5357
  if (brush.fluorescence.amount > 0) return `The ${brush.name} brush lays UV-fluorescent ink, which a stroke layer doesn't redraw yet; paint it on a pixel layer (add_layer), or set fluorescence.amount to 0 (set_brush).`;
4766
5358
  if (brush.thermochromic.hotColor !== null) return `The ${brush.name} brush lays thermochromic ink, which a stroke layer doesn't redraw yet; paint it on a pixel layer (add_layer), or remove thermochromic (set_brush).`;
5359
+ if (brush.ferro.amount > 0) return `The ${brush.name} brush lays ferrofluid, which a stroke layer doesn't redraw; paint ferrofluid on a pixel layer (add_layer), or set ferro.amount to 0 (set_brush).`;
4767
5360
  if (brush.behavior.pickup > 0) return `The ${brush.name} brush picks up the paint under it (pickup ${brush.behavior.pickup}), so it can't be redrawn on its own; set behavior.pickup to 0 (set_brush) or paint on a pixel layer.`;
4768
5361
  return null;
4769
5362
  }
@@ -5032,6 +5625,12 @@ async function replayPainting(source, target, options = {}) {
5032
5625
  if (strokes >= maxStrokes && item.kind === "stroke") break;
5033
5626
  if (item.kind === "paint") {
5034
5627
  const e = item.event;
5628
+ if (e.kind === "magnets") {
5629
+ const d = e.data;
5630
+ const list = (d?.magnets ?? []).map((m) => s === 1 ? m : { ...m, x: m.x * s, y: m.y * s, radius: Math.min(2e3, Math.max(8, m.radius * s)) });
5631
+ target.replaceMagnets(list, Boolean(d?.advect));
5632
+ continue;
5633
+ }
5035
5634
  if (!target.getLayer(e.layerId)) continue;
5036
5635
  if (e.kind === "fill") target.fillLayer({ layerId: e.layerId, ...e.data });
5037
5636
  else if (e.kind === "fill-region") {
@@ -5046,6 +5645,7 @@ async function replayPainting(source, target, options = {}) {
5046
5645
  ...d.expand !== void 0 ? { expand: Math.max(-16, Math.min(16, d.expand * s)) } : {}
5047
5646
  });
5048
5647
  } else if (e.kind === "clear") target.clearLayer(e.layerId);
5648
+ else if (e.kind === "cure-ferrofluid") target.cureFerrofluid(e.layerId);
5049
5649
  else if (e.kind === "live-strokes") {
5050
5650
  const d = e.data;
5051
5651
  if (d?.rasterized) target.replaceLiveStrokes(e.layerId, null);
@@ -5091,6 +5691,26 @@ async function replayPainting(source, target, options = {}) {
5091
5691
  if (e.layerId && target.getLayer(e.layerId)) target.wetPaper({ layerId: e.layerId, rect: sr(d.rect), amount: d.amount });
5092
5692
  break;
5093
5693
  }
5694
+ case "live-patterns": {
5695
+ const d = e.data;
5696
+ if (e.layerId && target.getLayer(e.layerId)) {
5697
+ target.setLivePatterns(e.layerId, d ? { ...d, scale: Math.min(4, d.scale * s), enabled: true } : { enabled: false });
5698
+ }
5699
+ break;
5700
+ }
5701
+ case "seed-live-patterns": {
5702
+ const d = e.data;
5703
+ if (e.layerId && target.getLayer(e.layerId) && Array.isArray(d.points)) {
5704
+ target.seedLivePatterns({
5705
+ layerId: e.layerId,
5706
+ points: d.points.map((p) => ({ ...p, x: p.x * s, y: p.y * s })),
5707
+ radius: Math.min(512, d.radius * s),
5708
+ seed: d.seed,
5709
+ ...d.settings ? { ...d.settings, ...d.settings.scale ? { scale: Math.min(4, d.settings.scale * s) } : {} } : {}
5710
+ });
5711
+ }
5712
+ break;
5713
+ }
5094
5714
  case "ghost-release": {
5095
5715
  const d = e.data;
5096
5716
  if (e.layerId && target.getLayer(e.layerId)) target.wet.releaseGhost(e.layerId, d?.linger);
@@ -6339,6 +6959,9 @@ var PaintEditorController = class _PaintEditorController {
6339
6959
  litCaches = /* @__PURE__ */ new Map();
6340
6960
  /** The live impasto stroke's channels. */
6341
6961
  liveThick = null;
6962
+ /** The live ferrofluid stroke's channel. */
6963
+ liveFerro = null;
6964
+ nextMagnetId = 1;
6342
6965
  /** The UV / blacklight setting (saved with the document, not undo steps). */
6343
6966
  uvLight = { ...DEFAULT_UV_LIGHT };
6344
6967
  uvCaches = /* @__PURE__ */ new Map();
@@ -6410,6 +7033,16 @@ var PaintEditorController = class _PaintEditorController {
6410
7033
  for (const [id, surface] of lights) if (findLayer(this.doc.layers, id)) light = withLayerLight(light, id, surface);
6411
7034
  this.doc = { ...this.doc, ...light ? { light } : {} };
6412
7035
  },
7036
+ layerHeight: (id) => this.doc.thick?.[id]?.height ?? null,
7037
+ updateHeights: (heights) => {
7038
+ let thick = this.doc.thick;
7039
+ for (const [id, height] of heights) {
7040
+ if (!findLayer(this.doc.layers, id)) continue;
7041
+ const prev = thick?.[id] ?? emptyThickChannels();
7042
+ thick = { ...thick ?? {}, [id]: { ...prev, height } };
7043
+ }
7044
+ if (thick !== this.doc.thick) this.doc = { ...this.doc, ...thick ? { thick } : {} };
7045
+ },
6413
7046
  changed: () => {
6414
7047
  }
6415
7048
  },
@@ -6858,9 +7491,10 @@ var PaintEditorController = class _PaintEditorController {
6858
7491
  paper: options.paper === void 0 ? { ...this.doc.paper, offset: void 0 } : this.resolvePaper(options.paper)
6859
7492
  });
6860
7493
  this.extensions = {};
7494
+ this.nextMagnetId = 1;
6861
7495
  this.guides = { ...this.guides, symmetry: { ...this.guides.symmetry, center: { x: next.canvas.width / 2, y: next.canvas.height / 2 } }, tracing: null };
6862
7496
  this.tracingVersion++;
6863
- if (this.wet.world) {
7497
+ if (this.wet.world || this.wet.patterns.size) {
6864
7498
  this.beginWetOp();
6865
7499
  this.wet.reset();
6866
7500
  }
@@ -7015,7 +7649,7 @@ var PaintEditorController = class _PaintEditorController {
7015
7649
  describeLayer(l, parentId = findLayer(this.doc.layers, l.id)?.parent?.id ?? null, depth = findLayer(this.doc.layers, l.id)?.path.length ?? 0) {
7016
7650
  let bounds = null;
7017
7651
  if (l.type === "pixel") {
7018
- const b = unionRect(contentBounds(l.surface), this.doc.fluor?.[l.id] ? contentBounds(this.doc.fluor[l.id]) : null);
7652
+ const b = unionRect(unionRect(contentBounds(l.surface), this.doc.fluor?.[l.id] ? contentBounds(this.doc.fluor[l.id]) : null), this.doc.ferro?.[l.id]?.bounds ?? null);
7019
7653
  bounds = b ? { ...b, x: b.x + l.offset.x, y: b.y + l.offset.y } : null;
7020
7654
  } else for (const c of flattenLayers(l.children)) if (c.type === "pixel") bounds = unionRect(bounds, this.describeLayer(c, l.id, depth + 1).bounds);
7021
7655
  return {
@@ -7216,7 +7850,8 @@ var PaintEditorController = class _PaintEditorController {
7216
7850
  ...this.liveStrokesWithout(removedIds),
7217
7851
  ...this.lightWithout(removedIds),
7218
7852
  ...this.fluorWithout(removedIds),
7219
- ...this.thermoWithout(removedIds)
7853
+ ...this.thermoWithout(removedIds),
7854
+ ...this.ferroWithout(removedIds)
7220
7855
  });
7221
7856
  return { removed: id, activeLayerId: active };
7222
7857
  }
@@ -7232,12 +7867,16 @@ var PaintEditorController = class _PaintEditorController {
7232
7867
  const stickers = { ...this.doc.stickers ?? {} };
7233
7868
  const liveStrokes = { ...this.doc.liveStrokes ?? {} };
7234
7869
  const light = { ...this.doc.light ?? {} };
7870
+ const ferro = { ...this.doc.ferro ?? {} };
7871
+ const ferroSettings = { ...this.doc.ferroSettings ?? {} };
7235
7872
  const fluor = { ...this.doc.fluor ?? {} };
7236
7873
  const thermo = { ...this.doc.thermo ?? {} };
7237
7874
  const copy = (l) => {
7238
7875
  const id2 = newPaintLayerId();
7239
7876
  if (light[l.id]) light[id2] = light[l.id];
7240
7877
  if (this.lightSpills[l.id]) this.lightSpills[id2] = { ...this.lightSpills[l.id] };
7878
+ if (ferro[l.id]) ferro[id2] = ferro[l.id];
7879
+ if (ferroSettings[l.id]) ferroSettings[id2] = { ...ferroSettings[l.id] };
7241
7880
  if (fluor[l.id]) fluor[id2] = fluor[l.id];
7242
7881
  if (thermo[l.id]) thermo[id2] = thermo[l.id];
7243
7882
  if (thick[l.id]) thick[id2] = thick[l.id];
@@ -7258,7 +7897,9 @@ var PaintEditorController = class _PaintEditorController {
7258
7897
  ...this.doc.liveStrokes ? { liveStrokes } : {},
7259
7898
  ...this.doc.light ? { light } : {},
7260
7899
  ...this.doc.fluor ? { fluor } : {},
7261
- ...this.doc.thermo ? { thermo } : {}
7900
+ ...this.doc.thermo ? { thermo } : {},
7901
+ ...this.doc.ferro ? { ferro } : {},
7902
+ ...this.doc.ferroSettings ? { ferroSettings } : {}
7262
7903
  })
7263
7904
  );
7264
7905
  return this.describeLayer(dup);
@@ -7311,6 +7952,13 @@ var PaintEditorController = class _PaintEditorController {
7311
7952
  const mergedLight = this.doc.light?.[upper.id] || this.doc.light?.[below.id] ? mergeLight(toDoc(this.doc.light?.[below.id], below), upper.visible ? toDoc(this.doc.light?.[upper.id], upper) : void 0, upperDoc) : void 0;
7312
7953
  const lightAfter = this.lightWithout([upper.id, below.id]).light ?? (this.doc.light ? { ...this.doc.light } : void 0);
7313
7954
  const light = mergedLight ? { ...lightAfter ?? {}, [below.id]: mergedLight } : lightAfter;
7955
+ const mergedFerro = this.doc.ferro?.[upper.id] || this.doc.ferro?.[below.id] ? mergeFerro(toDoc(this.doc.ferro?.[below.id], below), upper.visible ? toDoc(this.doc.ferro?.[upper.id], upper) : void 0, translateSurface(upper.surface, upper.offset.x, upper.offset.y)) : void 0;
7956
+ const ferroRemoved = this.ferroWithout([upper.id, below.id]);
7957
+ const ferroAfter = ferroRemoved.ferro ?? (this.doc.ferro ? { ...this.doc.ferro } : void 0);
7958
+ const ferro = mergedFerro?.tiles.size ? { ...ferroAfter ?? {}, [below.id]: mergedFerro } : ferroAfter;
7959
+ const ferroSettingsAfter = ferroRemoved.ferroSettings ?? (this.doc.ferroSettings ? { ...this.doc.ferroSettings } : void 0);
7960
+ const mergedFerroSettings = upper.visible && this.doc.ferroSettings?.[upper.id] ? this.doc.ferroSettings[upper.id] : this.doc.ferroSettings?.[below.id];
7961
+ const ferroSettings = mergedFerro?.tiles.size && mergedFerroSettings ? { ...ferroSettingsAfter ?? {}, [below.id]: mergedFerroSettings } : ferroSettingsAfter;
7314
7962
  const mergedFluor = this.doc.fluor?.[upper.id] || this.doc.fluor?.[below.id] ? mergeFourChannel(toDoc(this.doc.fluor?.[below.id], below), upper.visible ? toDoc(this.doc.fluor?.[upper.id], upper) : void 0) : void 0;
7315
7963
  const fluorAfter = this.fluorWithout([upper.id, below.id]).fluor ?? (this.doc.fluor ? { ...this.doc.fluor } : void 0);
7316
7964
  const fluor = mergedFluor ? { ...fluorAfter ?? {}, [below.id]: mergedFluor } : fluorAfter;
@@ -7328,7 +7976,9 @@ var PaintEditorController = class _PaintEditorController {
7328
7976
  ...this.liveStrokesWithout([upper.id, below.id]),
7329
7977
  ...light ? { light } : {},
7330
7978
  ...fluor ? { fluor } : {},
7331
- ...thermo ? { thermo } : {}
7979
+ ...thermo ? { thermo } : {},
7980
+ ...ferro !== void 0 ? { ferro } : {},
7981
+ ...ferroSettings !== void 0 ? { ferroSettings } : {}
7332
7982
  })
7333
7983
  );
7334
7984
  return this.describeLayer(merged);
@@ -7353,7 +8003,8 @@ var PaintEditorController = class _PaintEditorController {
7353
8003
  ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {},
7354
8004
  ...this.lightWithout([l.id]),
7355
8005
  ...this.fluorWithout([l.id]),
7356
- ...this.thermoWithout([l.id])
8006
+ ...this.thermoWithout([l.id]),
8007
+ ...this.ferroWithout([l.id])
7357
8008
  },
7358
8009
  { kind: "clear", layerId: l.id }
7359
8010
  )
@@ -7403,6 +8054,9 @@ var PaintEditorController = class _PaintEditorController {
7403
8054
  const surface = this.applyCoverage(l, coverage, rect, apply);
7404
8055
  const lit = this.doc.light?.[l.id];
7405
8056
  const light = lit ? withLayerLight(this.doc.light, l.id, scaleLight(lit, 1 - opacity)) : void 0;
8057
+ const fer = this.doc.ferro?.[l.id];
8058
+ const ferro = fer ? withLayerFerro(this.doc.ferro, l.id, scaleFerro(fer, 1 - opacity)) : void 0;
8059
+ const ferroSettings = fer ? withLayerFerroSettings(this.doc.ferroSettings, ferro, l.id, this.doc.ferroSettings?.[l.id] ?? null) : void 0;
7406
8060
  const fl = this.doc.fluor?.[l.id];
7407
8061
  const fluor = fl ? withLayerLight(this.doc.fluor, l.id, scaleFluor(fl, 1 - opacity)) : void 0;
7408
8062
  const th = this.doc.thermo?.[l.id];
@@ -7415,7 +8069,8 @@ var PaintEditorController = class _PaintEditorController {
7415
8069
  layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })),
7416
8070
  ...light ? { light } : {},
7417
8071
  ...fluor ? { fluor } : {},
7418
- ...thermo ? { thermo } : {}
8072
+ ...thermo ? { thermo } : {},
8073
+ ...fer ? { ferro, ferroSettings } : {}
7419
8074
  }),
7420
8075
  { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }
7421
8076
  )
@@ -8591,6 +9246,10 @@ var PaintEditorController = class _PaintEditorController {
8591
9246
  if (brush.thermochromic.hotColor !== null && brush.engine === "sticker") {
8592
9247
  throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take thermochromic ink; paint thermochromic ink with a dab or particle brush (thermo-ink; list_brush_presets lists them), or set thermochromic.hotColor to null (set_brush).`);
8593
9248
  }
9249
+ if (brush.ferro.amount > 0) {
9250
+ if (brush.engine === "sticker") throw new PaintEditorError(`Sticker brushes spray objects and cannot lay ferrofluid (${brush.name}); paint ferrofluid with a dab or particle brush (ferrofluid, ferro-ink; list_brush_presets lists them), or set ferro.amount to 0 (set_brush).`);
9251
+ if (brush.engine === "wet") throw new PaintEditorError(`Wet brushes cannot lay ferrofluid (${brush.name}); paint ferrofluid with a dry or impasto brush (ferrofluid, ferro-ink), or set ferro.amount to 0 (set_brush).`);
9252
+ }
8594
9253
  if (doc.liveStrokes?.[layer.id]) {
8595
9254
  const why = liveStrokeRefusal(brush);
8596
9255
  if (why) throw new PaintEditorError(`Layer "${layer.name}" is a stroke layer. ${why}`);
@@ -8720,6 +9379,8 @@ var PaintEditorController = class _PaintEditorController {
8720
9379
  thick: needThick ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
8721
9380
  // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
8722
9381
  light: !wet && brush.engine !== "sticker" && (brush.light.amount > 0 || doc.light?.[layer.id]) ? { surface: doc.light?.[layer.id] ?? emptyLight() } : null,
9382
+ // Ferrofluid paint: a twin of the stroke paints the liquid ferrofluid channel.
9383
+ ferro: !wet && brush.engine !== "sticker" && (brush.ferro.amount > 0 || doc.ferro?.[layer.id]) ? { surface: doc.ferro?.[layer.id] ?? emptyFerro() } : null,
8723
9384
  fluor: brush.engine !== "sticker" && (brush.fluorescence.amount > 0 || doc.fluor?.[layer.id]) ? { surface: doc.fluor?.[layer.id] ?? emptyFluor() } : null,
8724
9385
  thermo: brush.engine !== "sticker" && (brush.thermochromic.hotColor !== null || doc.thermo?.[layer.id]) ? { surface: doc.thermo?.[layer.id] ?? emptyThermo() } : null,
8725
9386
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc), art: this.stickerArt.get, variant: (ref2) => this.stickerArt.variantFor(ref2, this.colorScheme === "dark") } : void 0,
@@ -8770,6 +9431,9 @@ var PaintEditorController = class _PaintEditorController {
8770
9431
  this.live = { layerId, surface };
8771
9432
  const ch = this.session?.thickChannels();
8772
9433
  this.liveThick = ch ? { layerId, channels: ch } : null;
9434
+ const fc = this.session?.ferroChannel();
9435
+ const fb = this.session?.init.brush.ferro;
9436
+ this.liveFerro = fc ? { layerId, surface: fc, settings: fb && fb.amount > 0 ? fb : this.doc.ferroSettings?.[layerId] ?? DEFAULT_FERRO } : null;
8773
9437
  const fl = this.session?.fluorChannel();
8774
9438
  this.liveFluor = fl ? { layerId, surface: fl } : null;
8775
9439
  const th = this.session?.thermoChannel();
@@ -8792,6 +9456,7 @@ var PaintEditorController = class _PaintEditorController {
8792
9456
  this.live = null;
8793
9457
  this.liveLayers = null;
8794
9458
  this.liveThick = null;
9459
+ this.liveFerro = null;
8795
9460
  this.liveFluor = null;
8796
9461
  this.liveThermo = null;
8797
9462
  const wetStroke = Boolean(s.init.wet);
@@ -8823,11 +9488,26 @@ var PaintEditorController = class _PaintEditorController {
8823
9488
  s.result.wet = this.wet.isWet(s.layerId);
8824
9489
  }
8825
9490
  const l = this.getLayer(s.layerId);
8826
- const thick = withLayerThick(this.doc.thick, l.id, s.thickChannels());
9491
+ let ch = s.thickChannels();
9492
+ if (wetStroke) {
9493
+ const f = this.wet.patterns.get(l.id);
9494
+ if (f?.enabled) {
9495
+ this.wet.settingsOf(l.id).livePatterns = { ...f.settings };
9496
+ if (f.settings.relief > 0 && f.tiles.size > 0) {
9497
+ const prevCh = ch ?? this.doc.thick?.[l.id] ?? emptyThickChannels();
9498
+ const nextHeight = f.applyReliefHeight(prevCh.height);
9499
+ if (nextHeight !== prevCh.height) ch = { ...prevCh, height: nextHeight };
9500
+ }
9501
+ }
9502
+ }
9503
+ const thick = withLayerThick(this.doc.thick, l.id, ch);
8827
9504
  const sprayed = s.stickers();
8828
9505
  const stickers = sprayed ? { ...this.doc.stickers ?? {}, [l.id]: [...this.doc.stickers?.[l.id] ?? [], ...sprayed] } : this.doc.stickers;
8829
9506
  const wetLight = wetStroke && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null;
8830
9507
  const light = withLayerLight(this.doc.light, l.id, s.lightChannel() ?? wetLight);
9508
+ const fc = s.ferroChannel();
9509
+ const ferro = fc ? withLayerFerro(this.doc.ferro, l.id, fc) : this.doc.ferro;
9510
+ const ferroSettings = fc ? withLayerFerroSettings(this.doc.ferroSettings, ferro, l.id, brush.ferro.amount > 0 ? brush.ferro : this.doc.ferroSettings?.[l.id] ?? DEFAULT_FERRO) : this.doc.ferroSettings;
8831
9511
  const fluor = withLayerLight(this.doc.fluor, l.id, s.fluorChannel());
8832
9512
  const thermo = withLayerLight(this.doc.thermo, l.id, s.thermoChannel());
8833
9513
  this.commit(
@@ -8842,7 +9522,9 @@ var PaintEditorController = class _PaintEditorController {
8842
9522
  ...stickers ? { stickers } : {},
8843
9523
  ...light ? { light } : {},
8844
9524
  ...fluor ? { fluor } : {},
8845
- ...thermo ? { thermo } : {}
9525
+ ...thermo ? { thermo } : {},
9526
+ ...ferro ? { ferro } : { ferro: void 0 },
9527
+ ...ferroSettings ? { ferroSettings } : { ferroSettings: void 0 }
8846
9528
  },
8847
9529
  stroke
8848
9530
  )
@@ -8927,6 +9609,7 @@ var PaintEditorController = class _PaintEditorController {
8927
9609
  this.live = null;
8928
9610
  this.liveLayers = null;
8929
9611
  this.liveThick = null;
9612
+ this.liveFerro = null;
8930
9613
  this.liveFluor = null;
8931
9614
  this.liveThermo = null;
8932
9615
  const masks = s.init.selection ? (s.maskStencils ?? this.guides.stencils).filter((x) => x.enabled && x.mode === "mask") : [];
@@ -8947,6 +9630,7 @@ var PaintEditorController = class _PaintEditorController {
8947
9630
  this.live = null;
8948
9631
  this.liveLayers = null;
8949
9632
  this.liveThick = null;
9633
+ this.liveFerro = null;
8950
9634
  this.liveFluor = null;
8951
9635
  this.liveThermo = null;
8952
9636
  this.emit();
@@ -8976,9 +9660,29 @@ var PaintEditorController = class _PaintEditorController {
8976
9660
  }
8977
9661
  const l = findLayer(out.layers, s.layerId).layer;
8978
9662
  const offset = s.init.wet ? { x: 0, y: 0 } : l.offset;
8979
- const thick = withLayerThick(out.thick, l.id, s.thickChannels());
9663
+ let ch = s.thickChannels();
9664
+ if (s.init.wet) {
9665
+ const f = this.wet.patterns.get(l.id);
9666
+ if (f?.enabled) {
9667
+ this.wet.settingsOf(l.id).livePatterns = { ...f.settings };
9668
+ if (f.settings.relief > 0 && f.tiles.size > 0) {
9669
+ const prevCh = ch ?? out.thick?.[l.id] ?? emptyThickChannels();
9670
+ const nextHeight = f.applyReliefHeight(prevCh.height);
9671
+ if (nextHeight !== prevCh.height) ch = { ...prevCh, height: nextHeight };
9672
+ }
9673
+ }
9674
+ }
9675
+ const thick = withLayerThick(out.thick, l.id, ch);
8980
9676
  const sprayed = s.stickers();
8981
9677
  const lit = s.lightChannel() ?? (s.init.wet && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null);
9678
+ const fc = s.ferroChannel();
9679
+ const nextFerro = fc ? withLayerFerro(out.ferro, l.id, fc) : out.ferro;
9680
+ const nextFerroSettings = fc ? withLayerFerroSettings(
9681
+ out.ferroSettings,
9682
+ nextFerro,
9683
+ l.id,
9684
+ s.init.brush.ferro.amount > 0 ? s.init.brush.ferro : out.ferroSettings?.[l.id] ?? DEFAULT_FERRO
9685
+ ) : out.ferroSettings;
8982
9686
  const fl = s.fluorChannel();
8983
9687
  const th = s.thermoChannel();
8984
9688
  out = withLiveStroke({
@@ -8988,6 +9692,7 @@ var PaintEditorController = class _PaintEditorController {
8988
9692
  ...thick ? { thick } : {},
8989
9693
  ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {},
8990
9694
  ...lit ? { light: withLayerLight(out.light, l.id, lit) } : {},
9695
+ ...fc ? { ferro: nextFerro, ferroSettings: nextFerroSettings } : {},
8991
9696
  ...fl ? { fluor: withLayerLight(out.fluor, l.id, fl) } : {},
8992
9697
  ...th ? { thermo: withLayerLight(out.thermo, l.id, th) } : {}
8993
9698
  }, stroke);
@@ -9382,6 +10087,178 @@ var PaintEditorController = class _PaintEditorController {
9382
10087
  this.commit("Flatten relief", this.withPaintEvent({ ...this.doc, ...t }, { kind: "flatten", layerId: l.id }));
9383
10088
  return { layerId: l.id, flattened: true };
9384
10089
  }
10090
+ // --- Ferrofluid paint and magnets -----------------------------------------------
10091
+ /** The document's `ferro` and `ferroSettings` without these layers. */
10092
+ ferroWithout(ids) {
10093
+ const out = {};
10094
+ const f = this.doc.ferro;
10095
+ if (f && ids.some((id) => f[id])) {
10096
+ const next = { ...f };
10097
+ for (const id of ids) delete next[id];
10098
+ out.ferro = Object.keys(next).length ? next : void 0;
10099
+ }
10100
+ const fs = this.doc.ferroSettings;
10101
+ if (fs && ids.some((id) => fs[id])) {
10102
+ const next = { ...fs };
10103
+ for (const id of ids) delete next[id];
10104
+ out.ferroSettings = Object.keys(next).length ? next : void 0;
10105
+ }
10106
+ return out;
10107
+ }
10108
+ /** Runs deterministic advection on all pixel layers with liquid ferrofluid toward `magnets`. */
10109
+ advectDocumentFerro(doc, magnets, steps = 4) {
10110
+ const f = doc.ferro;
10111
+ if (!f || !magnets.length) return f ? { ...f } : void 0;
10112
+ const out = {};
10113
+ for (const [id, surf] of Object.entries(f)) {
10114
+ const l = findLayer(doc.layers, id)?.layer;
10115
+ if (isPixelLayer(l) && surf.tiles.size > 0) {
10116
+ const next = advectFerro(surf, magnets, l.offset, steps);
10117
+ if (next.tiles.size > 0) out[id] = next;
10118
+ } else if (surf.tiles.size > 0) {
10119
+ out[id] = surf;
10120
+ }
10121
+ }
10122
+ return Object.keys(out).length ? out : void 0;
10123
+ }
10124
+ /** Lists the magnets on the canvas. */
10125
+ listMagnets() {
10126
+ return (this.doc.magnets ?? []).map((m) => ({ ...m }));
10127
+ }
10128
+ /** Places a dipole magnet on the canvas and advects liquid ferrofluid toward it (one undo step). */
10129
+ addMagnet(input) {
10130
+ this.requireIdle("adding a magnet");
10131
+ const existing = this.doc.magnets ?? [];
10132
+ const usedIds = new Set(existing.map((m) => m.id));
10133
+ while (usedIds.has(`mag${this.nextMagnetId}`)) this.nextMagnetId++;
10134
+ const id = `mag${this.nextMagnetId++}`;
10135
+ let magnet;
10136
+ try {
10137
+ magnet = normalizeMagnet({ id, x: input.x, y: input.y, strength: input.strength, radius: input.radius });
10138
+ } catch (e) {
10139
+ throw new PaintEditorError(e.message);
10140
+ }
10141
+ const magnets = [...existing, magnet];
10142
+ const ferro = this.advectDocumentFerro(this.doc, magnets, 4);
10143
+ const nextDoc = this.withPaintEvent(
10144
+ { ...this.doc, magnets, ...this.doc.ferro ? { ferro } : {} },
10145
+ { kind: "magnets", layerId: "", data: { magnets: magnets.map((m) => ({ ...m })), advect: true } }
10146
+ );
10147
+ this.commit("Add magnet", nextDoc);
10148
+ return { ...magnet };
10149
+ }
10150
+ /** Moves or retunes a magnet and advects liquid ferrofluid toward the magnets (one undo step). */
10151
+ updateMagnet(id, update, commitToHistory = true) {
10152
+ this.requireIdle("updating a magnet");
10153
+ if (update.x === void 0 && update.y === void 0 && update.strength === void 0 && update.radius === void 0) {
10154
+ throw new PaintEditorError("Pass at least one change: x, y, strength or radius.");
10155
+ }
10156
+ const existing = this.doc.magnets ?? [];
10157
+ const idx = existing.findIndex((m) => m.id === id);
10158
+ if (idx < 0) throw new PaintEditorError(`Unknown magnet "${id}". Call list_magnets for the ids.`);
10159
+ const current = existing[idx];
10160
+ let magnet;
10161
+ try {
10162
+ magnet = normalizeMagnet(
10163
+ {
10164
+ id,
10165
+ x: update.x ?? current.x,
10166
+ y: update.y ?? current.y,
10167
+ strength: update.strength ?? current.strength,
10168
+ radius: update.radius ?? current.radius
10169
+ },
10170
+ current
10171
+ );
10172
+ } catch (e) {
10173
+ throw new PaintEditorError(e.message);
10174
+ }
10175
+ const magnets = existing.map((m, i) => i === idx ? magnet : m);
10176
+ const last = this.undoStack[this.undoStack.length - 1];
10177
+ const amending = !commitToHistory && Boolean(last && last.label === "Update magnet" && !last.wet && !this.wetOp && !this.grouping);
10178
+ const baseDoc = amending && last ? last.before : this.doc;
10179
+ const ferro = this.advectDocumentFerro(baseDoc, magnets, 4);
10180
+ const nextDoc = this.withPaintEvent(
10181
+ { ...this.doc, paintEvents: baseDoc.paintEvents, magnets, ...baseDoc.ferro ? { ferro } : {} },
10182
+ { kind: "magnets", layerId: "", data: { magnets: magnets.map((m) => ({ ...m })), advect: true } }
10183
+ );
10184
+ this.commitOrAmend("Update magnet", nextDoc, commitToHistory);
10185
+ return { ...magnet };
10186
+ }
10187
+ /** Removes magnets by id, or all magnets when `ids` is omitted or empty (one undo step). Returns the removed ids. */
10188
+ removeMagnets(ids) {
10189
+ this.requireIdle("removing magnets");
10190
+ const existing = this.doc.magnets ?? [];
10191
+ if (ids?.length) {
10192
+ for (const id of ids) {
10193
+ if (!existing.some((m) => m.id === id)) throw new PaintEditorError(`Unknown magnet "${id}". Call list_magnets for the ids.`);
10194
+ }
10195
+ }
10196
+ const removed = ids?.length ? [...ids] : existing.map((m) => m.id);
10197
+ if (!removed.length) return [];
10198
+ const next = existing.filter((m) => !removed.includes(m.id));
10199
+ const nextDoc = this.withPaintEvent(
10200
+ { ...this.doc, magnets: next.length ? next : void 0 },
10201
+ { kind: "magnets", layerId: "", data: { magnets: next.map((m) => ({ ...m })), advect: false } }
10202
+ );
10203
+ this.commit("Remove magnets", nextDoc);
10204
+ return removed;
10205
+ }
10206
+ /** Replaces the canvas magnets (used by replay). */
10207
+ replaceMagnets(magnets, advect = false) {
10208
+ const next = magnets.map((m) => normalizeMagnet(m));
10209
+ const ferro = advect ? this.advectDocumentFerro(this.doc, next, 4) : this.doc.ferro;
10210
+ this.commit("Magnets", { ...this.doc, magnets: next.length ? next : void 0, ...this.doc.ferro ? { ferro } : {} });
10211
+ }
10212
+ /** Reads a layer's liquid ferrofluid coverage and active Rosensweig spike relief under the canvas magnets. */
10213
+ getFerrofluid(layerId) {
10214
+ const l = this.wetLayer(layerId, "read the ferrofluid of");
10215
+ const settings = this.doc.ferroSettings?.[l.id] ?? DEFAULT_FERRO;
10216
+ const magnets = this.doc.magnets ?? [];
10217
+ const info = ferroInfo(this.doc.ferro?.[l.id], magnets, settings, l.offset);
10218
+ const bounds = info.bounds ? { ...info.bounds, x: info.bounds.x + l.offset.x, y: info.bounds.y + l.offset.y } : null;
10219
+ return {
10220
+ layerId: l.id,
10221
+ ferro: info.peak > 0,
10222
+ bounds,
10223
+ peak: info.peak,
10224
+ coverage: info.liquidPixels,
10225
+ activeSpikeArea: info.spikePixels,
10226
+ maxSpikeHeight: info.maxSpikeHeight,
10227
+ magnetCount: info.magnetCount,
10228
+ wavelength: settings.wavelength,
10229
+ threshold: settings.threshold
10230
+ };
10231
+ }
10232
+ /** Cures a layer's liquid ferrofluid: bakes the current Rosensweig spikes and glossy metallic material into `doc.thick` and clears `doc.ferro` (one undo step). */
10233
+ cureFerrofluid(layerId) {
10234
+ this.requireIdle("curing ferrofluid");
10235
+ const l = this.paintTarget(layerId, "cure the ferrofluid of");
10236
+ const fSurf = this.doc.ferro?.[l.id];
10237
+ if (!fSurf || !fSurf.tiles.size) {
10238
+ return { layerId: l.id, cured: false, bounds: null, maxSpikeHeight: 0, activeSpikeArea: 0 };
10239
+ }
10240
+ const settings = this.doc.ferroSettings?.[l.id] ?? DEFAULT_FERRO;
10241
+ const magnets = this.doc.magnets ?? [];
10242
+ const fb = ferroInfo(fSurf, magnets, settings, l.offset).bounds;
10243
+ const bounds = fb ? { ...fb, x: fb.x + l.offset.x, y: fb.y + l.offset.y } : null;
10244
+ const baked = bakeFerroIntoThick(l.surface, fSurf, this.doc.thick?.[l.id], magnets, settings, l.offset);
10245
+ const removed = this.ferroWithout([l.id]);
10246
+ const nextDoc = {
10247
+ ...this.doc,
10248
+ layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: baked.color })),
10249
+ thick: { ...this.doc.thick ?? {}, [l.id]: baked.thick },
10250
+ ferro: removed.ferro,
10251
+ ferroSettings: removed.ferroSettings
10252
+ };
10253
+ this.commit("Cure ferrofluid", this.withPaintEvent(nextDoc, { kind: "cure-ferrofluid", layerId: l.id }));
10254
+ return {
10255
+ layerId: l.id,
10256
+ cured: true,
10257
+ bounds,
10258
+ maxSpikeHeight: baked.maxSpikeHeight,
10259
+ activeSpikeArea: baked.spikePixels
10260
+ };
10261
+ }
9385
10262
  // --- Replay: time-lapse and high-resolution export ------------------------------
9386
10263
  /**
9387
10264
  * A fresh, headless editor for replaying this painting at `scale` (canvas, dpi and paper grain
@@ -9640,6 +10517,149 @@ var PaintEditorController = class _PaintEditorController {
9640
10517
  if (this.wet.busy) return [];
9641
10518
  return this.wet.waterOverlay(rect);
9642
10519
  }
10520
+ layerLivePatternsInfo(layerId) {
10521
+ const info = this.wet.info(layerId);
10522
+ const stats = this.wet.livePatternStats(layerId);
10523
+ return {
10524
+ layerId,
10525
+ wet: info.wet,
10526
+ wetCells: info.wetCells,
10527
+ wetBounds: info.wetBounds,
10528
+ ...stats
10529
+ };
10530
+ }
10531
+ checkLivePatternsInput(input) {
10532
+ if (!input) return {};
10533
+ const out = {};
10534
+ if (input.pattern !== void 0) {
10535
+ if (!LIVE_PATTERN_KINDS.includes(input.pattern)) {
10536
+ throw new PaintEditorError(`pattern must be one of ${LIVE_PATTERN_KINDS.join(", ")}, not ${JSON.stringify(input.pattern)}.`);
10537
+ }
10538
+ out.pattern = input.pattern;
10539
+ }
10540
+ if (input.feed !== void 0) {
10541
+ if (!(Number.isFinite(input.feed) && input.feed >= 5e-3 && input.feed <= 0.1)) {
10542
+ throw new PaintEditorError(`feed ${input.feed} is out of range; use 0.005..0.1.`);
10543
+ }
10544
+ out.feed = input.feed;
10545
+ }
10546
+ if (input.kill !== void 0) {
10547
+ if (!(Number.isFinite(input.kill) && input.kill >= 0.02 && input.kill <= 0.08)) {
10548
+ throw new PaintEditorError(`kill ${input.kill} is out of range; use 0.02..0.08.`);
10549
+ }
10550
+ out.kill = input.kill;
10551
+ }
10552
+ if (input.scale !== void 0) {
10553
+ if (!(Number.isFinite(input.scale) && input.scale >= 0.25 && input.scale <= 4)) {
10554
+ throw new PaintEditorError(`scale ${input.scale} is out of range; use 0.25..4.`);
10555
+ }
10556
+ out.scale = input.scale;
10557
+ }
10558
+ if (input.contrast !== void 0) {
10559
+ if (!(Number.isFinite(input.contrast) && input.contrast >= 0 && input.contrast <= 2)) {
10560
+ throw new PaintEditorError(`contrast ${input.contrast} is out of range; use 0..2.`);
10561
+ }
10562
+ out.contrast = input.contrast;
10563
+ }
10564
+ if (input.secondColor !== void 0) {
10565
+ out.secondColor = input.secondColor === null ? null : parseColor(input.secondColor, "secondColor");
10566
+ }
10567
+ if (input.relief !== void 0) {
10568
+ if (!(Number.isFinite(input.relief) && input.relief >= 0 && input.relief <= 1)) {
10569
+ throw new PaintEditorError(`relief ${input.relief} is out of range; use 0..1.`);
10570
+ }
10571
+ out.relief = input.relief;
10572
+ }
10573
+ return out;
10574
+ }
10575
+ /**
10576
+ * Reads a layer's Gray-Scott reaction-diffusion live patterns (`layerId`, default the active
10577
+ * layer), plus all layers with wet paint or live patterns and the built-in Pearson presets.
10578
+ */
10579
+ getLivePatterns(layerId) {
10580
+ const l = this.wetLayer(layerId, "read the live patterns of");
10581
+ const primary = this.layerLivePatternsInfo(l.id);
10582
+ const ids = /* @__PURE__ */ new Set([l.id, ...this.wet.wetLayerIds()]);
10583
+ for (const [id, f] of this.wet.patterns) {
10584
+ if ((f.enabled || f.tiles.size > 0) && findLayer(this.doc.layers, id)) ids.add(id);
10585
+ }
10586
+ for (const [id, s] of this.wet.settings) {
10587
+ if (s.livePatterns && findLayer(this.doc.layers, id)) ids.add(id);
10588
+ }
10589
+ const layers = [...ids].map((id) => id === l.id ? primary : this.layerLivePatternsInfo(id));
10590
+ return {
10591
+ ...primary,
10592
+ layers,
10593
+ presets: LIVE_PATTERN_PRESETS,
10594
+ brushPresets: ["turing-wash", "coral-ink"]
10595
+ };
10596
+ }
10597
+ /**
10598
+ * Configures a layer's Gray-Scott reaction-diffusion live patterns (or clears them with
10599
+ * `enabled: false`). One undo step.
10600
+ */
10601
+ setLivePatterns(layerId, update) {
10602
+ this.requireIdle("changing live patterns");
10603
+ this.requireWetIdle("changing live patterns");
10604
+ const l = this.wetLayer(layerId, "set the live patterns of");
10605
+ if (isLayerLocked(l, "pixels")) throw new PaintEditorError(`Layer "${l.name}" has its pixels locked; unlock it first.`);
10606
+ if (update.enabled !== void 0 && typeof update.enabled !== "boolean") {
10607
+ throw new PaintEditorError("enabled must be true or false.");
10608
+ }
10609
+ const checked = this.checkLivePatternsInput(update);
10610
+ const before = this.doc;
10611
+ this.beginWetOp();
10612
+ const next = this.wet.setLivePatterns(l.id, update.enabled === false ? null : checked);
10613
+ this.logWet({ kind: "live-patterns", layerId: l.id, data: next });
10614
+ this.commit("Live patterns", this.withWetEvents(this.doc), before);
10615
+ return this.layerLivePatternsInfo(l.id);
10616
+ }
10617
+ /**
10618
+ * Seeds Gray-Scott reaction-diffusion activator (`V`) discs into a layer's live-pattern field so
10619
+ * patterns grow where the layer holds wet water and freeze where it dries. One undo step.
10620
+ */
10621
+ seedLivePatterns(options) {
10622
+ this.requireIdle("seeding live patterns");
10623
+ this.requireWetIdle("seeding live patterns");
10624
+ const l = this.wetLayer(options.layerId, "seed live patterns on");
10625
+ if (isLayerLocked(l, "pixels")) throw new PaintEditorError(`Layer "${l.name}" has its pixels locked; unlock it first.`);
10626
+ if (!Array.isArray(options.points) || !options.points.length) {
10627
+ throw new PaintEditorError("points is empty; give at least one {x, y} in document pixels.");
10628
+ }
10629
+ const { width, height } = this.doc.canvas;
10630
+ const points = options.points.map((p, i) => {
10631
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
10632
+ throw new PaintEditorError(`points[${i}] needs finite x and y in document pixels.`);
10633
+ }
10634
+ if (p.x < 0 || p.y < 0 || p.x >= width || p.y >= height) {
10635
+ throw new PaintEditorError(`points[${i}] (${p.x}, ${p.y}) is outside the ${width}\xD7${height} canvas.`);
10636
+ }
10637
+ if (p.strength !== void 0 && !(Number.isFinite(p.strength) && p.strength > 0 && p.strength <= 1)) {
10638
+ throw new PaintEditorError(`points[${i}].strength ${p.strength} is out of range; use above 0 up to 1.`);
10639
+ }
10640
+ return { x: p.x, y: p.y, ...p.strength !== void 0 ? { strength: p.strength } : {} };
10641
+ });
10642
+ const radius = options.radius ?? 16;
10643
+ if (!(Number.isFinite(radius) && radius >= 1 && radius <= 512)) {
10644
+ throw new PaintEditorError(`radius ${radius} is out of range; use 1..512 px.`);
10645
+ }
10646
+ const seed = options.seed ?? mix32(this.seedCounter++ * 2654435761 >>> 0) & 2147483647;
10647
+ if (!Number.isInteger(seed) || seed < 0) {
10648
+ throw new PaintEditorError(`seed ${seed} must be a whole number of 0 or more.`);
10649
+ }
10650
+ const hasSettings = options.pattern !== void 0 || options.feed !== void 0 || options.kill !== void 0 || options.scale !== void 0 || options.contrast !== void 0 || options.secondColor !== void 0 || options.relief !== void 0;
10651
+ const settings = hasSettings ? this.checkLivePatternsInput(options) : void 0;
10652
+ const before = this.doc;
10653
+ this.beginWetOp();
10654
+ const { seededCells } = this.wet.seedLivePatterns(l.id, points, radius, seed, settings);
10655
+ this.logWet({
10656
+ kind: "seed-live-patterns",
10657
+ layerId: l.id,
10658
+ data: { points, radius, seed, ...settings ? { settings } : {} }
10659
+ });
10660
+ this.commit("Seed live patterns", this.withWetEvents(this.doc), before);
10661
+ return { ...this.layerLivePatternsInfo(l.id), seededCells };
10662
+ }
9643
10663
  // --- Rendering -----------------------------------------------------------------
9644
10664
  sources() {
9645
10665
  return { width: this.doc.canvas.width, height: this.doc.canvas.height };
@@ -9741,20 +10761,33 @@ var PaintEditorController = class _PaintEditorController {
9741
10761
  };
9742
10762
  return visit(layers);
9743
10763
  }
9744
- /** The layers with thick paint lit (each thick layer's pixels replaced by its lit tiles). */
10764
+ /** The layers with thick paint and ferrofluid spikes lit (each thick or ferrofluid layer's pixels replaced by its lit tiles). */
9745
10765
  litLayers(layers) {
9746
10766
  const thick = this.doc.thick;
9747
10767
  const live = this.liveThick;
9748
- if (!thick && !live) return layers;
10768
+ const ferro = this.doc.ferro;
10769
+ const liveFerro = this.liveFerro;
10770
+ if (!thick && !live && !ferro && !liveFerro) return layers;
9749
10771
  let out = layers;
9750
- const ids = /* @__PURE__ */ new Set([...Object.keys(thick ?? {}), ...live ? [live.layerId] : []]);
10772
+ const ids = /* @__PURE__ */ new Set([
10773
+ ...Object.keys(thick ?? {}),
10774
+ ...live ? [live.layerId] : [],
10775
+ ...Object.keys(ferro ?? {}),
10776
+ ...liveFerro ? [liveFerro.layerId] : []
10777
+ ]);
10778
+ const magnets = this.doc.magnets ?? [];
9751
10779
  for (const id of ids) {
9752
- const ch = live?.layerId === id ? live.channels : thick?.[id];
9753
- const l = ch && findLayer(out, id)?.layer;
9754
- if (!ch || !isPixelLayer(l)) continue;
10780
+ const l = findLayer(out, id)?.layer;
10781
+ if (!isPixelLayer(l)) continue;
10782
+ const ch = (live?.layerId === id ? live.channels : thick?.[id]) ?? emptyThickChannels();
10783
+ const fSurf = liveFerro?.layerId === id ? liveFerro.surface : ferro?.[id];
10784
+ const fSet = (liveFerro?.layerId === id ? liveFerro.settings : this.doc.ferroSettings?.[id]) ?? DEFAULT_FERRO;
10785
+ const hasThick = ch.height.tiles.size > 0 || ch.material.tiles.size > 0 || ch.film.tiles.size > 0;
10786
+ const hasFerro = Boolean(fSurf && fSurf.tiles.size > 0);
10787
+ if (!hasThick && !hasFerro) continue;
9755
10788
  let cache = this.litCaches.get(id);
9756
10789
  if (!cache) this.litCaches.set(id, cache = new LitCache());
9757
- const lit = cache.lit(l.surface, ch, this.lighting, this.viewTilt);
10790
+ const lit = cache.lit(l.surface, ch, this.lighting, this.viewTilt, hasFerro ? { surface: fSurf, magnets, settings: fSet, offset: l.offset } : null);
9758
10791
  if (lit !== l.surface) out = mapLayer(out, id, () => ({ ...l, surface: lit }));
9759
10792
  }
9760
10793
  return out;
@@ -9843,13 +10876,13 @@ var PaintEditorController = class _PaintEditorController {
9843
10876
  }
9844
10877
  /** The canvas (or some layers), scaled so the longest side is at most `maxSide`. */
9845
10878
  renderPreview(options = {}) {
9846
- const full2 = this.compositePixels({ ...options, wet: options.wet ?? "display" });
10879
+ const full3 = this.compositePixels({ ...options, wet: options.wet ?? "display" });
9847
10880
  const side = Math.max(1, Math.round(options.maxSide ?? 512));
9848
- const k = Math.min(1, side / Math.max(full2.width, full2.height));
9849
- if (k >= 1) return full2;
9850
- const w = Math.max(1, Math.round(full2.width * k));
9851
- const h = Math.max(1, Math.round(full2.height * k));
9852
- return { width: w, height: h, data: downsampleRgba(full2.data, full2.width, full2.height, w, h) };
10881
+ const k = Math.min(1, side / Math.max(full3.width, full3.height));
10882
+ if (k >= 1) return full3;
10883
+ const w = Math.max(1, Math.round(full3.width * k));
10884
+ const h = Math.max(1, Math.round(full3.height * k));
10885
+ return { width: w, height: h, data: downsampleRgba(full3.data, full3.width, full3.height, w, h) };
9853
10886
  }
9854
10887
  // --- Save, load and export -------------------------------------------------------
9855
10888
  /** The document as JSON with inline PNG assets. */
@@ -9895,7 +10928,12 @@ var PaintEditorController = class _PaintEditorController {
9895
10928
  const savedGhost = ghost?.trigger === "agent" ? null : ghost;
9896
10929
  const heldEvap = this.heldEvaporation.get(id);
9897
10930
  const params = heldEvap !== void 0 ? { ...set.params, evaporation: heldEvap } : { ...set.params };
9898
- layers[id] = { params, flow: { ...set.flow }, ...savedGhost ? { ghost: savedGhost } : {} };
10931
+ layers[id] = {
10932
+ params,
10933
+ flow: { ...set.flow },
10934
+ ...savedGhost ? { ghost: savedGhost } : {},
10935
+ ...set.livePatterns ? { livePatterns: { ...set.livePatterns } } : {}
10936
+ };
9899
10937
  }
9900
10938
  const flat = this.wet.tilt.angle === 0;
9901
10939
  if (!Object.keys(layers).length && flat) return void 0;
@@ -9914,7 +10952,14 @@ var PaintEditorController = class _PaintEditorController {
9914
10952
  }
9915
10953
  const x = v;
9916
10954
  const ghost = readWetGhost(x.ghost);
9917
- this.wet.settings.set(id, { params: normalizeWetParams(x.params ?? {}), flow: normalizeFlow(x.flow ?? {}), ...ghost ? { ghost } : {} });
10955
+ const livePatterns = x.livePatterns ? normalizeLivePatterns(x.livePatterns) : null;
10956
+ this.wet.settings.set(id, {
10957
+ params: normalizeWetParams(x.params ?? {}),
10958
+ flow: normalizeFlow(x.flow ?? {}),
10959
+ ...ghost ? { ghost } : {},
10960
+ ...livePatterns ? { livePatterns } : {}
10961
+ });
10962
+ if (livePatterns) this.wet.patternFieldOf(id).configure(livePatterns);
9918
10963
  }
9919
10964
  }
9920
10965
  }
@@ -9953,6 +10998,16 @@ var PaintEditorController = class _PaintEditorController {
9953
10998
  const spills = Object.fromEntries(Object.entries(this.lightSpills).filter(([id]) => findLayer(this.doc.layers, id)));
9954
10999
  if (Object.keys(spills).length) ext.lightSpill = spills;
9955
11000
  else delete ext.lightSpill;
11001
+ const ferro = {};
11002
+ for (const [id, f] of Object.entries(this.doc.ferro ?? {})) {
11003
+ if (!findLayer(this.doc.layers, id)) continue;
11004
+ const saved = ferroToJSON(f, this.doc.ferroSettings?.[id]);
11005
+ if (saved) ferro[id] = saved;
11006
+ }
11007
+ if (Object.keys(ferro).length) ext.ferro = ferro;
11008
+ else delete ext.ferro;
11009
+ if (this.doc.magnets?.length) ext.magnets = this.doc.magnets.map((m) => ({ ...m }));
11010
+ else delete ext.magnets;
9956
11011
  const effectLayers = effectLayersToJSON(this.doc.effectLayers ?? {}, (id) => isPixelLayer(findLayer(this.doc.layers, id)?.layer));
9957
11012
  if (effectLayers) ext.effectLayers = effectLayers;
9958
11013
  else delete ext.effectLayers;
@@ -10090,6 +11145,25 @@ var PaintEditorController = class _PaintEditorController {
10090
11145
  if (heat) state = { ...state, heat };
10091
11146
  else problems.push("The heat field could not be read; the canvas loads cool.");
10092
11147
  }
11148
+ if (ext.ferro && typeof ext.ferro === "object") {
11149
+ const ferro = {};
11150
+ const ferroSettings = {};
11151
+ for (const [id, v] of Object.entries(ext.ferro)) {
11152
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer)) continue;
11153
+ try {
11154
+ const loaded = ferroFromJSON(v);
11155
+ ferro[id] = loaded.surface;
11156
+ ferroSettings[id] = loaded.settings;
11157
+ } catch (e) {
11158
+ problems.push(`The ferrofluid of layer "${id}" could not be read (${e.message}); it loads without ferrofluid.`);
11159
+ }
11160
+ }
11161
+ if (Object.keys(ferro).length) state = { ...state, ferro, ferroSettings };
11162
+ }
11163
+ if (ext.magnets !== void 0) {
11164
+ const magnets = magnetsFromJSON(ext.magnets);
11165
+ if (magnets.length) state = { ...state, magnets };
11166
+ }
10093
11167
  const raw = ext.layerChannels;
10094
11168
  if (!raw || typeof raw !== "object") return state;
10095
11169
  const thick = {};
@@ -10124,6 +11198,11 @@ var PaintEditorController = class _PaintEditorController {
10124
11198
  this.redoStack = [];
10125
11199
  this.resetMutations();
10126
11200
  this.doc = this.readThick(loaded.state, loaded.extensions, loaded.problems);
11201
+ this.nextMagnetId = 1;
11202
+ for (const m of this.doc.magnets ?? []) {
11203
+ const n = Number(/^mag(\d+)$/.exec(m.id)?.[1] ?? 0);
11204
+ if (n >= this.nextMagnetId) this.nextMagnetId = n + 1;
11205
+ }
10127
11206
  for (const list of Object.values(this.doc.liveStrokes ?? {})) {
10128
11207
  for (const s of list) {
10129
11208
  const n = Number(/^s(\d+)$/.exec(s.id)?.[1] ?? 0);
@@ -10134,6 +11213,8 @@ var PaintEditorController = class _PaintEditorController {
10134
11213
  delete this.extensions.layerChannels;
10135
11214
  delete this.extensions.light;
10136
11215
  delete this.extensions.lightSpill;
11216
+ delete this.extensions.ferro;
11217
+ delete this.extensions.magnets;
10137
11218
  delete this.extensions.lighting;
10138
11219
  delete this.extensions.stickers;
10139
11220
  delete this.extensions.liveStrokes;
@@ -10174,13 +11255,13 @@ var PaintEditorController = class _PaintEditorController {
10174
11255
  return encodePng(img.width, img.height, img.data);
10175
11256
  }
10176
11257
  scaled(options) {
10177
- const full2 = this.compositePixels(options);
11258
+ const full3 = this.compositePixels(options);
10178
11259
  const s = options.scale ?? 1;
10179
11260
  if (!(s > 0 && s <= 1)) throw new PaintEditorError(`scale ${s} is out of range; use above 0 up to 1 (exports never upscale pixels).`);
10180
- if (s === 1) return full2;
10181
- const w = Math.max(1, Math.round(full2.width * s));
10182
- const h = Math.max(1, Math.round(full2.height * s));
10183
- return { width: w, height: h, data: downsampleRgba(full2.data, full2.width, full2.height, w, h) };
11261
+ if (s === 1) return full3;
11262
+ const w = Math.max(1, Math.round(full3.width * s));
11263
+ const h = Math.max(1, Math.round(full3.height * s));
11264
+ return { width: w, height: h, data: downsampleRgba(full3.data, full3.width, full3.height, w, h) };
10184
11265
  }
10185
11266
  /**
10186
11267
  * The canvas as PNG, JPEG or WebP bytes. JPEG has no alpha, so it always includes the paper.
@@ -11383,11 +12464,11 @@ var PaintEditorController = class _PaintEditorController {
11383
12464
  width,
11384
12465
  height
11385
12466
  ) ?? { x: 0, y: 0, width, height };
11386
- const full2 = this.compositePixels({ wet: "display" });
12467
+ const full3 = this.compositePixels({ wet: "display" });
11387
12468
  const cropData = new Uint8ClampedArray(cropRect.width * cropRect.height * 4);
11388
12469
  for (let y = 0; y < cropRect.height; y++) {
11389
12470
  const srcRow = ((cropRect.y + y) * width + cropRect.x) * 4;
11390
- cropData.set(full2.data.subarray(srcRow, srcRow + cropRect.width * 4), y * cropRect.width * 4);
12471
+ cropData.set(full3.data.subarray(srcRow, srcRow + cropRect.width * 4), y * cropRect.width * 4);
11391
12472
  }
11392
12473
  const cropPngBase64 = bytesToBase64(encodePng(cropRect.width, cropRect.height, cropData));
11393
12474
  const preview = this.renderPreview({ wet: "display", maxSide: options.maxSide ?? 512 });
@@ -12018,6 +13099,19 @@ function withLayerThick(thick, layerId, next) {
12018
13099
  else delete out[layerId];
12019
13100
  return out;
12020
13101
  }
13102
+ function withLayerFerro(ferro, layerId, next) {
13103
+ if (!next) return ferro ? { ...ferro } : void 0;
13104
+ const out = { ...ferro ?? {} };
13105
+ if (next.tiles.size) out[layerId] = next;
13106
+ else delete out[layerId];
13107
+ return Object.keys(out).length ? out : void 0;
13108
+ }
13109
+ function withLayerFerroSettings(settings, ferro, layerId, next) {
13110
+ const out = { ...settings ?? {} };
13111
+ if (next && ferro?.[layerId]?.tiles.size) out[layerId] = { amount: next.amount, wavelength: next.wavelength, threshold: next.threshold };
13112
+ else if (!ferro?.[layerId]?.tiles.size) delete out[layerId];
13113
+ return Object.keys(out).length ? out : void 0;
13114
+ }
12021
13115
  var BACKLIGHT_LAYER_ID = "__backlight";
12022
13116
  function insertAfter(layers, id, node) {
12023
13117
  const i = layers.findIndex((l) => l.id === id);
@@ -12059,6 +13153,7 @@ function shiftDocument(doc, dx, dy) {
12059
13153
  for (const l of flattenLayers(doc.layers)) if (l.type === "pixel") layers = mapLayer(layers, l.id, () => ({ ...l, offset: movePoint(l.offset, dx, dy) }));
12060
13154
  const m = [1, 0, 0, 1, dx, dy];
12061
13155
  const moveStickers = (list) => list.map((s) => ({ ...s, x: Math.round((s.x + dx) * 100) / 100, y: Math.round((s.y + dy) * 100) / 100 }));
13156
+ const moveMagnets = (list) => list.map((mag) => ({ ...mag, x: Math.round((mag.x + dx) * 100) / 100, y: Math.round((mag.y + dy) * 100) / 100 }));
12062
13157
  const out = {
12063
13158
  ...doc,
12064
13159
  layers,
@@ -12074,11 +13169,14 @@ function shiftDocument(doc, dx, dy) {
12074
13169
  return s.stencils ? { ...moved, stencils: s.stencils.map((x) => translateStencil(x, dx, dy)) } : moved;
12075
13170
  };
12076
13171
  if (doc.stickers) out.stickers = Object.fromEntries(Object.entries(doc.stickers).map(([id, list]) => [id, moveStickers(list)]));
13172
+ if (doc.magnets) out.magnets = moveMagnets(doc.magnets);
12077
13173
  if (doc.liveStrokes) out.liveStrokes = Object.fromEntries(Object.entries(doc.liveStrokes).map(([id, list]) => [id, list.map(moveStroke)]));
12078
13174
  if (doc.paintEvents) {
12079
13175
  out.paintEvents = doc.paintEvents.map((e) => {
12080
13176
  const list = e.kind === "stickers" ? e.data?.stickers : void 0;
12081
13177
  if (list) return { ...e, data: { ...e.data, stickers: moveStickers(list) } };
13178
+ const magnets = e.kind === "magnets" ? e.data?.magnets : void 0;
13179
+ if (magnets) return { ...e, data: { ...e.data, magnets: moveMagnets(magnets) } };
12082
13180
  const strokes = e.kind === "live-strokes" ? e.data?.strokes : void 0;
12083
13181
  if (strokes) return { ...e, data: { ...e.data, strokes: strokes.map(moveStroke) } };
12084
13182
  if (e.kind === "fill-region" && e.data) {
@@ -12102,6 +13200,9 @@ function shiftDocument(doc, dx, dy) {
12102
13200
  if (e.kind === "dry" && typeof d.x === "number") return { ...e, data: moveRect(d, dx, dy) };
12103
13201
  if (e.kind === "wet-paper" && d.rect) return { ...e, data: { ...d, rect: moveRect(d.rect, dx, dy) } };
12104
13202
  if (e.kind === "flow" && d.center) return { ...e, data: { ...d, center: movePoint(d.center, dx, dy) } };
13203
+ if (e.kind === "seed-live-patterns" && Array.isArray(d.points)) {
13204
+ return { ...e, data: { ...d, points: d.points.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })) } };
13205
+ }
12105
13206
  return e;
12106
13207
  });
12107
13208
  }
@@ -12431,7 +13532,7 @@ function unionRect2(a, b) {
12431
13532
 
12432
13533
  // src/paint-editor/playback.ts
12433
13534
  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)$/;
12434
- var PAPER = /^(set_paper|set_lighting|flatten_relief|set_instant_dry)$/;
13535
+ var PAPER = /^(set_paper|set_lighting|flatten_relief|set_instant_dry|add_magnet|update_magnet|remove_magnets|cure_ferrofluid)$/;
12435
13536
  var LAYERS = /^(add_layer|update_layer|set_active_layer|remove_layer|duplicate_layer|move_layer|merge_layer_down|clear_layer|update_sticker|arrange_stickers|remove_stickers)$/;
12436
13537
  var PAD = /^(mixing_pad_stroke|pick_mixing_pad_color|clear_mixing_pad|mix_on_pad|save_mixing_pad|load_mixing_pad|remove_mixing_pad|mix_colors)$/;
12437
13538
  var COLOR = /^(set_color|set_paint|color_harmony|add_swatch|remove_swatch|load_mixer_brush|clean_mixer_brush)$/;
@@ -13139,6 +14240,43 @@ var WET_PARAM_SCHEMA = {
13139
14240
  bloom: UNIT("Blooms: water running into a drying wash pushes its pigment into cauliflower edges (default 0.4)."),
13140
14241
  bleed: UNIT("Colour bleed between touching wet areas (default 0.3).")
13141
14242
  };
14243
+ var LIVE_PATTERN_SCHEMA = {
14244
+ pattern: {
14245
+ type: "string",
14246
+ enum: [...LIVE_PATTERN_KINDS],
14247
+ description: "Pearson Gray-Scott preset: spots, stripes, coral, maze or worms (default spots). Sets feed and kill unless explicit feed/kill are also passed."
14248
+ },
14249
+ feed: {
14250
+ type: "number",
14251
+ minimum: 5e-3,
14252
+ maximum: 0.1,
14253
+ description: "Gray-Scott feed rate F, 0.005..0.1 (default: the pattern preset\u2019s feed rate)."
14254
+ },
14255
+ kill: {
14256
+ type: "number",
14257
+ minimum: 0.02,
14258
+ maximum: 0.08,
14259
+ description: "Gray-Scott kill rate k, 0.02..0.08 (default: the pattern preset\u2019s kill rate)."
14260
+ },
14261
+ scale: {
14262
+ type: "number",
14263
+ minimum: 0.25,
14264
+ maximum: 4,
14265
+ description: "Diffusion scale 0.25..4 (larger values grow broader pattern features; default 1)."
14266
+ },
14267
+ contrast: {
14268
+ type: "number",
14269
+ minimum: 0,
14270
+ maximum: 2,
14271
+ description: "How strongly V modulates the wet pigment or blends toward secondColor, 0..2 (default 0.85)."
14272
+ },
14273
+ secondColor: {
14274
+ type: ["string", "null"],
14275
+ pattern: "^#[0-9a-fA-F]{6}$",
14276
+ description: "Optional #rrggbb colour that high-V pattern ridges blend toward, or null to modulate pigment contrast (default null)."
14277
+ },
14278
+ relief: UNIT("Impasto ridge height 0..1 written from V into the layer\u2019s thick height map so light catches the pattern (default 0).")
14279
+ };
13142
14280
  function createPaintWetTools(c) {
13143
14281
  const stillWet = () => c.wetState().wet;
13144
14282
  return [
@@ -13302,6 +14440,73 @@ function createPaintWetTools(c) {
13302
14440
  inputSchema: { type: "object", properties: { visible: { type: "boolean", description: "Show (true) or hide (false) the overlay." } }, required: ["visible"], additionalProperties: false },
13303
14441
  annotations: { title: "Show the water", ...SET2 },
13304
14442
  handler: ({ visible }) => ({ visible: c.setWaterOverlay(visible), stillWet: stillWet() })
14443
+ },
14444
+ {
14445
+ name: "get_live_patterns",
14446
+ title: "Get live patterns",
14447
+ description: "Reads the Gray-Scott reaction-diffusion live patterns on a layer (layerId, default the active layer): whether live patterns are enabled, the active settings (pattern, feed, kill, scale, contrast, secondColor, relief), activeTiles, patternCells, wetPatternCells (pattern cells still holding water where the pattern can grow), meanV, maxV, bounds, wet coverage (wet, wetCells, wetBounds), layers (every layer with wet paint or live patterns), and presets (built-in Pearson feed/kill presets: spots, stripes, coral, maze, worms). Call it before or after advance_wet_paint and dry_paint to inspect pattern growth and freezing.",
14448
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID2 }, additionalProperties: false },
14449
+ annotations: { title: "Get live patterns", ...READ3 },
14450
+ coordinates: { out: ["wetBounds", "bounds", "layers[].wetBounds", "layers[].bounds"] },
14451
+ handler: ({ layerId }) => c.getLivePatterns(layerId)
14452
+ },
14453
+ {
14454
+ name: "set_live_patterns",
14455
+ title: "Set live patterns",
14456
+ description: "Configures a layer\u2019s Gray-Scott reaction-diffusion live patterns (or clears them with enabled: false). Patterns grow inside wet paint during advance_wet_paint and freeze when the paint dries (dry_paint). Choose a Pearson preset (spots, stripes, coral, maze, worms) or explicit feed and kill rates, scale, contrast, secondColor (#rrggbb or null to modulate pigment contrast) and relief (0..1, writes ridge height into thick paint so lighting catches the pattern). One undo step. Returns the layer\u2019s live-pattern stats and stillWet.",
14457
+ inputSchema: {
14458
+ type: "object",
14459
+ properties: {
14460
+ layerId: LAYER_ID2,
14461
+ enabled: { type: "boolean", description: "true (default) configures live patterns on the layer; false clears them." },
14462
+ ...LIVE_PATTERN_SCHEMA
14463
+ },
14464
+ additionalProperties: false
14465
+ },
14466
+ annotations: { title: "Set live patterns", ...SET2 },
14467
+ coordinates: { out: ["wetBounds", "bounds"] },
14468
+ handler: async ({ layerId, ...update }) => {
14469
+ await c.wetIdle();
14470
+ return { ...c.setLivePatterns(layerId, update), stillWet: stillWet() };
14471
+ }
14472
+ },
14473
+ {
14474
+ name: "seed_live_patterns",
14475
+ title: "Seed live patterns",
14476
+ description: 'Seeds Gray-Scott reaction-diffusion activator (V) discs at points on a layer so reaction-diffusion patterns (spots, stripes, coral, maze, worms) grow where the layer holds wet water (advance_wet_paint) and freeze where it dries (dry_paint). Painting with a wet brush that has a livePatterns facet (such as preset "turing-wash" or "coral-ink") also seeds along the stroke automatically. One undo step. Returns seededCells, the layer\u2019s live-pattern stats (patternCells, wetPatternCells, meanV, maxV, bounds) and stillWet.',
14477
+ inputSchema: {
14478
+ type: "object",
14479
+ properties: {
14480
+ layerId: LAYER_ID2,
14481
+ points: {
14482
+ type: "array",
14483
+ minItems: 1,
14484
+ maxItems: 1024,
14485
+ description: "Seed centres in document pixels where reaction-diffusion activator V is nucleated.",
14486
+ items: {
14487
+ type: "object",
14488
+ properties: {
14489
+ x: { type: "number", description: "Document x in pixels." },
14490
+ y: { type: "number", description: "Document y in pixels." },
14491
+ strength: { type: "number", exclusiveMinimum: 0, maximum: 1, description: "Seed strength 0..1 (default 1)." }
14492
+ },
14493
+ required: ["x", "y"],
14494
+ additionalProperties: false
14495
+ }
14496
+ },
14497
+ radius: { type: "number", minimum: 1, maximum: 512, description: "Seed disc radius in document pixels (default 16)." },
14498
+ seed: { type: "integer", minimum: 0, description: "Deterministic noise seed for the nucleated discs (default: next editor seed)." },
14499
+ ...LIVE_PATTERN_SCHEMA
14500
+ },
14501
+ required: ["points"],
14502
+ additionalProperties: false
14503
+ },
14504
+ annotations: { title: "Seed live patterns", ...WRITE3 },
14505
+ coordinates: { in: ["points"], out: ["wetBounds", "bounds"] },
14506
+ handler: async (args) => {
14507
+ await c.wetIdle();
14508
+ return { ...c.seedLivePatterns(args), stillWet: stillWet() };
14509
+ }
13305
14510
  }
13306
14511
  ];
13307
14512
  }
@@ -13518,8 +14723,111 @@ function createPaintThickTools(c) {
13518
14723
  ];
13519
14724
  }
13520
14725
 
13521
- // src/paint-editor/replay-tools.ts
14726
+ // src/paint-editor/ferro-tools.ts
13522
14727
  var READ5 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
14728
+ var SET4 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
14729
+ var WRITE5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
14730
+ var EMPTY3 = { type: "object", properties: {}, additionalProperties: false };
14731
+ var LAYER_ID4 = { type: "string", description: "Pixel layer id from list_layers (default the active layer)." };
14732
+ function createFerroTools(c) {
14733
+ return [
14734
+ {
14735
+ name: "add_magnet",
14736
+ title: "Place a magnet",
14737
+ description: "Places a dipole magnet on the canvas at (x, y) in document pixels. Liquid ferrofluid on every layer gathers toward the magnet (deterministic advection steps) and rises into hexagonal Rosensweig relief spikes along the magnetic field lines wherever |B| exceeds the layer\u2019s threshold, shaded live by set_lighting. One undo step. Returns the magnet (id, x, y, strength, radius).",
14738
+ inputSchema: {
14739
+ type: "object",
14740
+ properties: {
14741
+ x: { type: "number", description: "Magnet centre x in document pixels." },
14742
+ y: { type: "number", description: "Magnet centre y in document pixels." },
14743
+ strength: { type: "number", minimum: -1, maximum: 1, description: "Dipole strength -1..1 (default 0.8; negative flips the pole)." },
14744
+ radius: { type: "number", minimum: 4, maximum: 2048, description: "Dipole falloff radius in pixels (default 80)." }
14745
+ },
14746
+ required: ["x", "y"],
14747
+ additionalProperties: false
14748
+ },
14749
+ annotations: { title: "Place a magnet", ...WRITE5 },
14750
+ coordinates: { in: [""], out: [""] },
14751
+ handler: (a) => c.addMagnet(a)
14752
+ },
14753
+ {
14754
+ name: "update_magnet",
14755
+ title: "Move or tune a magnet",
14756
+ description: "Moves a magnet or changes its dipole strength (-1..1) or falloff radius (4..2048 px) by id from list_magnets. Liquid ferrofluid gathers toward the updated field and the lit relief spikes update at once. One undo step. Returns the updated magnet.",
14757
+ inputSchema: {
14758
+ type: "object",
14759
+ properties: {
14760
+ id: { type: "string", description: "Magnet id from list_magnets." },
14761
+ x: { type: "number", description: "Magnet centre x in document pixels." },
14762
+ y: { type: "number", description: "Magnet centre y in document pixels." },
14763
+ strength: { type: "number", minimum: -1, maximum: 1, description: "Dipole strength -1..1." },
14764
+ radius: { type: "number", minimum: 4, maximum: 2048, description: "Dipole falloff radius in pixels (4..2048)." }
14765
+ },
14766
+ required: ["id"],
14767
+ additionalProperties: false
14768
+ },
14769
+ annotations: { title: "Move or tune a magnet", ...SET4 },
14770
+ coordinates: { in: [""], out: [""] },
14771
+ handler: ({ id, ...u }) => c.updateMagnet(id, u)
14772
+ },
14773
+ {
14774
+ name: "remove_magnets",
14775
+ title: "Remove magnets",
14776
+ description: "Removes magnets by id from list_magnets, or every magnet on the canvas when ids is omitted or empty. Uncured ferrofluid relaxes as the field changes; cured relief stays. One undo step. Returns the removed magnet ids.",
14777
+ inputSchema: {
14778
+ type: "object",
14779
+ properties: {
14780
+ ids: {
14781
+ type: "array",
14782
+ items: { type: "string", description: "Magnet id from list_magnets." },
14783
+ description: "Magnet ids to remove (default: remove all magnets)."
14784
+ }
14785
+ },
14786
+ additionalProperties: false
14787
+ },
14788
+ annotations: { title: "Remove magnets", ...WRITE5 },
14789
+ handler: ({ ids }) => ({ removed: c.removeMagnets(ids) })
14790
+ },
14791
+ {
14792
+ name: "list_magnets",
14793
+ title: "List magnets",
14794
+ description: "Lists the magnets placed on the canvas in order: id, x, y (document pixels), strength (-1..1) and radius (px). Changes nothing.",
14795
+ inputSchema: EMPTY3,
14796
+ annotations: { title: "List magnets", ...READ5 },
14797
+ coordinates: { out: ["magnets[]"] },
14798
+ handler: () => ({ magnets: c.listMagnets() })
14799
+ },
14800
+ {
14801
+ name: "get_ferrofluid",
14802
+ title: "Read a layer\u2019s ferrofluid and spikes",
14803
+ description: "Reads a layer\u2019s liquid ferrofluid (laid by brushes with ferro.amount above 0, such as ferrofluid and ferro-ink) and its live Rosensweig spikes under the current magnets: whether it has any (ferro), its bounds in document pixels, peak liquid level (0..255), liquid coverage in pixels, activeSpikeArea (pixels currently raised into spikes), maxSpikeHeight (0..255), magnetCount, and the layer\u2019s capillary wavelength and threshold. Changes nothing.",
14804
+ inputSchema: {
14805
+ type: "object",
14806
+ properties: { layerId: LAYER_ID4 },
14807
+ additionalProperties: false
14808
+ },
14809
+ annotations: { title: "Read a layer\u2019s ferrofluid and spikes", ...READ5 },
14810
+ coordinates: { out: ["bounds"] },
14811
+ handler: ({ layerId }) => c.getFerrofluid(layerId)
14812
+ },
14813
+ {
14814
+ name: "cure_ferrofluid",
14815
+ title: "Cure ferrofluid into solid relief",
14816
+ description: "Cures a layer\u2019s liquid ferrofluid: bakes its current magnetic Rosensweig spikes into the layer\u2019s solid impasto height and dark glossy metallic material, and clears the liquid ferrofluid channel so moving or removing magnets no longer changes this layer. One undo step. Returns the layer id, whether anything was cured, the cured bounds, maxSpikeHeight and activeSpikeArea.",
14817
+ inputSchema: {
14818
+ type: "object",
14819
+ properties: { layerId: LAYER_ID4 },
14820
+ additionalProperties: false
14821
+ },
14822
+ annotations: { title: "Cure ferrofluid into solid relief", ...WRITE5 },
14823
+ coordinates: { out: ["bounds"] },
14824
+ handler: ({ layerId }) => c.cureFerrofluid(layerId)
14825
+ }
14826
+ ];
14827
+ }
14828
+
14829
+ // src/paint-editor/replay-tools.ts
14830
+ var READ6 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13523
14831
  function createPaintReplayTools(c) {
13524
14832
  return [
13525
14833
  {
@@ -13538,7 +14846,7 @@ function createPaintReplayTools(c) {
13538
14846
  },
13539
14847
  additionalProperties: false
13540
14848
  },
13541
- annotations: { title: "Export a time-lapse", ...READ5 },
14849
+ annotations: { title: "Export a time-lapse", ...READ6 },
13542
14850
  handler: async (a) => {
13543
14851
  await c.wetIdle();
13544
14852
  const tl = await c.timeLapse({ frames: a.frames, fps: a.fps, maxSide: a.maxSide, wetStepsPerFrame: a.wetStepsPerFrame, holdMs: a.holdMs });
@@ -13570,7 +14878,7 @@ function createPaintReplayTools(c) {
13570
14878
  required: ["scale"],
13571
14879
  additionalProperties: false
13572
14880
  },
13573
- annotations: { title: "Export at a higher resolution", ...READ5 },
14881
+ annotations: { title: "Export at a higher resolution", ...READ6 },
13574
14882
  handler: async ({ scale, paper }) => {
13575
14883
  await c.wetIdle();
13576
14884
  const img = await c.renderHighRes({ scale, paper });
@@ -13582,9 +14890,9 @@ function createPaintReplayTools(c) {
13582
14890
  }
13583
14891
 
13584
14892
  // src/paint-editor/mutation-tools.ts
13585
- var READ6 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13586
- var WRITE5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13587
- var SET4 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
14893
+ var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
14894
+ var WRITE6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
14895
+ var SET5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13588
14896
  var POINTS = {
13589
14897
  type: "array",
13590
14898
  minItems: 1,
@@ -13623,7 +14931,7 @@ function createMutationTools(c, options = {}) {
13623
14931
  },
13624
14932
  additionalProperties: false
13625
14933
  },
13626
- annotations: { title: "List pending mutations", ...READ6 },
14934
+ annotations: { title: "List pending mutations", ...READ7 },
13627
14935
  coordinates: { out: ["mutations[].bounds"] },
13628
14936
  handler: (a) => ({ mutations: c.listMutations({ includeAll: Boolean(a.includeAll) }) })
13629
14937
  },
@@ -13654,7 +14962,7 @@ function createMutationTools(c, options = {}) {
13654
14962
  required: ["id"],
13655
14963
  additionalProperties: false
13656
14964
  },
13657
- annotations: { title: "Read and claim a mutation", ...WRITE5 },
14965
+ annotations: { title: "Read and claim a mutation", ...WRITE6 },
13658
14966
  coordinates: { out: ["bounds", "strokes[].bounds", "strokes[].points[]", "cropPng.rect"] },
13659
14967
  handler: (a) => c.readMutation(a.id, {
13660
14968
  actor: options.actor?.id,
@@ -13731,7 +15039,7 @@ function createMutationTools(c, options = {}) {
13731
15039
  required: ["id", "strokes"],
13732
15040
  additionalProperties: false
13733
15041
  },
13734
- annotations: { title: "Answer a mutation with strokes", ...WRITE5 },
15042
+ annotations: { title: "Answer a mutation with strokes", ...WRITE6 },
13735
15043
  coordinates: { in: ["strokes[].points[]"], out: ["bounds"] },
13736
15044
  handler: async (a) => {
13737
15045
  await c.wetIdle();
@@ -13785,7 +15093,7 @@ function createMutationTools(c, options = {}) {
13785
15093
  required: ["id"],
13786
15094
  additionalProperties: false
13787
15095
  },
13788
- annotations: { title: "Decline a mutation", ...SET4 },
15096
+ annotations: { title: "Decline a mutation", ...SET5 },
13789
15097
  coordinates: { out: ["bounds"] },
13790
15098
  handler: (a) => c.declineMutation(a.id, a.reason)
13791
15099
  }
@@ -13793,8 +15101,8 @@ function createMutationTools(c, options = {}) {
13793
15101
  }
13794
15102
 
13795
15103
  // src/paint-editor/evolution-tools.ts
13796
- var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13797
- var WRITE6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
15104
+ var READ8 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
15105
+ var WRITE7 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13798
15106
  var UNIT2 = (description) => ({ type: "number", minimum: 0, maximum: 1, description });
13799
15107
  var EVOLUTION_MUTATION_SCHEMA = {
13800
15108
  type: "object",
@@ -13839,7 +15147,7 @@ function createPaintEvolutionTools(c) {
13839
15147
  required: ["strokeIds"],
13840
15148
  additionalProperties: false
13841
15149
  },
13842
- annotations: { title: "Breed stroke variants", ...WRITE6 },
15150
+ annotations: { title: "Breed stroke variants", ...WRITE7 },
13843
15151
  coordinates: { out: ["proposals[].bounds"] },
13844
15152
  handler: ({ strokeIds, count, mutation, seed }, context) => {
13845
15153
  const actor = context?.actor?.id;
@@ -13882,7 +15190,7 @@ function createPaintEvolutionTools(c) {
13882
15190
  required: ["proposalIds"],
13883
15191
  additionalProperties: false
13884
15192
  },
13885
- annotations: { title: "Breed from paint proposals", ...WRITE6 },
15193
+ annotations: { title: "Breed from paint proposals", ...WRITE7 },
13886
15194
  coordinates: { out: ["proposals[].bounds"] },
13887
15195
  handler: ({ proposalIds, count, mutation, seed }, context) => {
13888
15196
  const actor = context?.actor?.id;
@@ -13910,7 +15218,7 @@ function createPaintEvolutionTools(c) {
13910
15218
  required: ["proposalId"],
13911
15219
  additionalProperties: false
13912
15220
  },
13913
- annotations: { title: "Get a proposal\u2019s stroke genome", ...READ7 },
15221
+ annotations: { title: "Get a proposal\u2019s stroke genome", ...READ8 },
13914
15222
  coordinates: { out: ["bounds", "strokes[].points[]"] },
13915
15223
  handler: ({ proposalId }) => c.getProposalGenome(proposalId)
13916
15224
  }
@@ -13929,13 +15237,13 @@ function createPaintEvolutionToolProvider(controller, options = {}) {
13929
15237
  }
13930
15238
 
13931
15239
  // src/paint-editor/tools.ts
13932
- var READ8 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13933
- var WRITE7 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13934
- var SET5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
15240
+ var READ9 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
15241
+ var WRITE8 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
15242
+ var SET6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13935
15243
  var DESTRUCTIVE3 = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
13936
- var EMPTY3 = { type: "object", properties: {}, additionalProperties: false };
15244
+ var EMPTY4 = { type: "object", properties: {}, additionalProperties: false };
13937
15245
  var COLOR3 = { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: 'Colour #rrggbb, e.g. "#2a4b8d".' };
13938
- var LAYER_ID4 = { type: "string", description: "Layer id from list_layers (default the active layer)." };
15246
+ var LAYER_ID5 = { type: "string", description: "Layer id from list_layers (default the active layer)." };
13939
15247
  var TIME = { type: "number", minimum: 0, description: "Draw live effect paint (strokes on stroke layers painted with live effect paint) this many ms into its animation (default: as painted, its own time)." };
13940
15248
  var POINTS2 = {
13941
15249
  type: "array",
@@ -13978,8 +15286,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
13978
15286
  name: "get_canvas_info",
13979
15287
  title: "Get the canvas",
13980
15288
  description: "Reads the canvas: size in pixels and dpi, its origin (where (0, 0) is relative to the canvas it was extended from), bounds (the rectangle the painting covers, in the coordinates every other tool takes), the paper (preset, colour, grain depth), the active layer, the active brush id, the paint colour, the view (zoom, rotation, mirror), the hand, how many strokes are recorded, the undo / redo labels, and the blend modes. Call it first.",
13981
- inputSchema: EMPTY3,
13982
- annotations: { title: "Get the canvas", ...READ8 },
15289
+ inputSchema: EMPTY4,
15290
+ annotations: { title: "Get the canvas", ...READ9 },
13983
15291
  coordinates: { out: ["bounds"] },
13984
15292
  handler: () => {
13985
15293
  const s = c.getState();
@@ -14040,7 +15348,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14040
15348
  },
14041
15349
  additionalProperties: false
14042
15350
  },
14043
- annotations: { title: "Resize the canvas", ...SET5 },
15351
+ annotations: { title: "Resize the canvas", ...SET6 },
14044
15352
  handler: (a) => c.setCanvas({ width: a.width, height: a.height, dpi: a.dpi })
14045
15353
  });
14046
15354
  const EXTEND_SIDE = (side) => ({ type: "integer", minimum: 0, maximum: MAX_CANVAS_SIDE - 1, description: `Pixels to add on the ${side} (default 0).` });
@@ -14053,15 +15361,15 @@ function createPaintEditorToolProvider(controller, options = {}) {
14053
15361
  properties: { left: EXTEND_SIDE("left"), top: EXTEND_SIDE("top"), right: EXTEND_SIDE("right"), bottom: EXTEND_SIDE("bottom") },
14054
15362
  additionalProperties: false
14055
15363
  },
14056
- annotations: { title: "Extend the canvas", ...WRITE7 },
15364
+ annotations: { title: "Extend the canvas", ...WRITE8 },
14057
15365
  handler: (a) => c.extendCanvas({ left: a.left, top: a.top, right: a.right, bottom: a.bottom })
14058
15366
  });
14059
15367
  add({
14060
15368
  name: "list_papers",
14061
15369
  title: "List papers",
14062
15370
  description: "Lists the built-in paper library (generated grain, no textures): id, name, family, grain pattern, colour, grain depth and the wet-media properties (absorption, permeability, roughness). Use an id with set_paper or new_canvas.",
14063
- inputSchema: EMPTY3,
14064
- annotations: { title: "List papers", ...READ8 },
15371
+ inputSchema: EMPTY4,
15372
+ annotations: { title: "List papers", ...READ9 },
14065
15373
  handler: () => ({
14066
15374
  papers: PAPER_LIBRARY.map((p) => ({ id: p.preset, name: p.name, family: p.family, pattern: p.pattern, color: p.color, depth: p.depth, absorption: p.absorption, permeability: p.permeability, roughness: p.roughness })),
14067
15375
  families: [...PAPER_FAMILIES],
@@ -14072,8 +15380,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14072
15380
  name: "get_paper",
14073
15381
  title: "Get the paper",
14074
15382
  description: "Reads every setting of the current paper (preset, pattern, seed, colour, scale, tiling, depth, shading, dry and wet tuning, absorption, permeability, roughness, and metallic, backlight and backlightColor when set).",
14075
- inputSchema: EMPTY3,
14076
- annotations: { title: "Get the paper", ...READ8 },
15383
+ inputSchema: EMPTY4,
15384
+ annotations: { title: "Get the paper", ...READ9 },
14077
15385
  handler: () => JSON.parse(JSON.stringify(c.document.paper))
14078
15386
  });
14079
15387
  const tuning = {
@@ -14111,15 +15419,15 @@ function createPaintEditorToolProvider(controller, options = {}) {
14111
15419
  },
14112
15420
  additionalProperties: false
14113
15421
  },
14114
- annotations: { title: "Set the paper", ...SET5 },
15422
+ annotations: { title: "Set the paper", ...SET6 },
14115
15423
  handler: (a) => c.setPaper(a)
14116
15424
  });
14117
15425
  add({
14118
15426
  name: "list_layers",
14119
15427
  title: "List layers",
14120
15428
  description: "Lists the layers top first: id, name, type (pixel or group), visibility, opacity, blend, clipping, locks, whether it is active, the painted bounds in document pixels (null when empty), parent and depth.",
14121
- inputSchema: EMPTY3,
14122
- annotations: { title: "List layers", ...READ8 },
15429
+ inputSchema: EMPTY4,
15430
+ annotations: { title: "List layers", ...READ9 },
14123
15431
  coordinates: { out: ["layers[].bounds"] },
14124
15432
  handler: () => ({ layers: c.listLayers(), activeLayerId: c.document.activeLayerId })
14125
15433
  });
@@ -14137,7 +15445,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14137
15445
  },
14138
15446
  additionalProperties: false
14139
15447
  },
14140
- annotations: { title: "Add a layer", ...WRITE7 },
15448
+ annotations: { title: "Add a layer", ...WRITE8 },
14141
15449
  coordinates: { out: ["bounds"] },
14142
15450
  handler: (a) => c.addLayer({ name: a.name, type: a.type, aboveId: a.aboveId, parentId: a.parentId })
14143
15451
  });
@@ -14173,7 +15481,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14173
15481
  required: ["layerId"],
14174
15482
  additionalProperties: false
14175
15483
  },
14176
- annotations: { title: "Change a layer", ...SET5 },
15484
+ annotations: { title: "Change a layer", ...SET6 },
14177
15485
  coordinates: { out: ["bounds"] },
14178
15486
  handler: ({ layerId, ...u }) => c.updateLayer(layerId, u)
14179
15487
  });
@@ -14181,8 +15489,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14181
15489
  name: "list_blend_modes",
14182
15490
  title: "List blend modes",
14183
15491
  description: "Lists every layer and brush blend mode: id, label, group (standard Photoshop modes, pigment, or painterly), what the pigment and painterly modes do, and the standard mode PSD export writes in their place. Changes nothing. Read it before choosing a painterly mode for update_layer or a brush tip.",
14184
- inputSchema: EMPTY3,
14185
- annotations: { title: "List blend modes", ...READ8 },
15492
+ inputSchema: EMPTY4,
15493
+ annotations: { title: "List blend modes", ...READ9 },
14186
15494
  handler: () => ({
14187
15495
  modes: BLEND_MODES.map((m) => ({
14188
15496
  id: m.id,
@@ -14198,7 +15506,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14198
15506
  title: "Choose the active layer",
14199
15507
  description: "Makes a layer active: strokes, fills and clears without a layerId go to it. Editor state, not an undo step. Returns the layer.",
14200
15508
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers." } }, required: ["layerId"], additionalProperties: false },
14201
- annotations: { title: "Choose the active layer", ...SET5 },
15509
+ annotations: { title: "Choose the active layer", ...SET6 },
14202
15510
  coordinates: { out: ["bounds"] },
14203
15511
  handler: ({ layerId }) => {
14204
15512
  c.setActiveLayer(layerId);
@@ -14218,7 +15526,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14218
15526
  title: "Duplicate a layer",
14219
15527
  description: "Copies a layer (a group with its children) directly above it; the copy becomes active. One undo step. Returns the copy.",
14220
15528
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers." } }, required: ["layerId"], additionalProperties: false },
14221
- annotations: { title: "Duplicate a layer", ...WRITE7 },
15529
+ annotations: { title: "Duplicate a layer", ...WRITE8 },
14222
15530
  coordinates: { out: ["bounds"] },
14223
15531
  handler: ({ layerId }) => c.duplicateLayer(layerId)
14224
15532
  });
@@ -14236,7 +15544,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14236
15544
  required: ["layerId", "index"],
14237
15545
  additionalProperties: false
14238
15546
  },
14239
- annotations: { title: "Reorder a layer", ...WRITE7 },
15547
+ annotations: { title: "Reorder a layer", ...WRITE8 },
14240
15548
  coordinates: { out: ["bounds"] },
14241
15549
  handler: ({ layerId, index, parentId }) => c.moveLayer(layerId, index, parentId)
14242
15550
  });
@@ -14256,7 +15564,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14256
15564
  inputSchema: {
14257
15565
  type: "object",
14258
15566
  properties: {
14259
- layerId: LAYER_ID4,
15567
+ layerId: LAYER_ID5,
14260
15568
  color: { ...COLOR3, description: "Fill colour #rrggbb (default the paint colour)." },
14261
15569
  opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0..1 (default 1)." },
14262
15570
  grain: { type: "number", minimum: 0, maximum: 1, description: "Paper grain response 0..1 (default 0: flat)." },
@@ -14265,7 +15573,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14265
15573
  },
14266
15574
  additionalProperties: false
14267
15575
  },
14268
- annotations: { title: "Fill a layer", ...WRITE7 },
15576
+ annotations: { title: "Fill a layer", ...WRITE8 },
14269
15577
  coordinates: { out: ["bounds"] },
14270
15578
  handler: (a) => act({ kind: "fill", fill: a }, () => c.fillLayer(a))
14271
15579
  });
@@ -14293,7 +15601,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14293
15601
  additionalProperties: false
14294
15602
  }
14295
15603
  },
14296
- layerId: LAYER_ID4,
15604
+ layerId: LAYER_ID5,
14297
15605
  reference: {
14298
15606
  type: "string",
14299
15607
  description: 'Which pixels stop the fill: "layer" (default, the target layer itself), "all" or "composite" (all visible layers without paper), or a layer id from list_layers (e.g. an ink line-art layer above the flats layer).'
@@ -14312,7 +15620,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14312
15620
  },
14313
15621
  additionalProperties: false
14314
15622
  },
14315
- annotations: { title: "Fill a region (bucket / flats)", ...WRITE7 },
15623
+ annotations: { title: "Fill a region (bucket / flats)", ...WRITE8 },
14316
15624
  coordinates: { in: ["", "points[]"], out: ["bounds", "regions[].point", "regions[].bounds"] },
14317
15625
  handler: (a) => act({ kind: "fill-region", fill: a }, () => c.fillRegion(a))
14318
15626
  });
@@ -14336,7 +15644,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14336
15644
  },
14337
15645
  additionalProperties: false
14338
15646
  },
14339
- annotations: { title: "Find enclosed unfilled regions", ...READ8 },
15647
+ annotations: { title: "Find enclosed unfilled regions", ...READ9 },
14340
15648
  coordinates: { out: ["regions[].point", "regions[].centroid", "regions[].bounds"] },
14341
15649
  handler: (a) => c.findUnfilledRegions(a)
14342
15650
  });
@@ -14344,7 +15652,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14344
15652
  name: "clear_layer",
14345
15653
  title: "Clear a layer",
14346
15654
  description: "Removes every pixel of a pixel layer. One undo step. Returns the layer id and the bounds that were cleared (null if it was empty).",
14347
- inputSchema: { type: "object", properties: { layerId: LAYER_ID4 }, additionalProperties: false },
15655
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID5 }, additionalProperties: false },
14348
15656
  annotations: { title: "Clear a layer", ...DESTRUCTIVE3 },
14349
15657
  coordinates: { out: ["bounds"] },
14350
15658
  handler: ({ layerId }) => c.clearLayer(layerId)
@@ -14354,7 +15662,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14354
15662
  title: "Get a brush",
14355
15663
  description: "Reads a brush as facets (kind, medium, variety or tool, tip, marks, dynamics, behaviour, light, motion, colour: the brush library's form, as describe_brush returns it), with the engine it paints with and whether it is an unchanged built-in preset. Without id, the active brush as edited. Also returns strokePaint: what a stroke with it would paint now (its gradient colour, else the editor's paint from set_paint). Changes nothing.",
14356
15664
  inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." } }, additionalProperties: false },
14357
- annotations: { title: "Get a brush", ...READ8 },
15665
+ annotations: { title: "Get a brush", ...READ9 },
14358
15666
  handler: ({ id }) => {
14359
15667
  const st = c.getState();
14360
15668
  const b = id === void 0 ? st.brush : c.getBrush(id);
@@ -14368,7 +15676,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14368
15676
  title: "Set the brush",
14369
15677
  description: "Chooses the active brush (id, from list_brush_presets) and / or changes it with facets laid over it (objects merge, lists and values replace, marks of another kind replace the marks; the brush library's create_brush fields); unset removes whole facets first (unset colour drops a brush's own gradient). Strokes without a brush use it. Editor state, not an undo step: the changes stay on the active brush until the library's create_brush or update_brush stores a brush. Returns the active brush as facets, the engine it paints with, and problems (values clamped or dropped).",
14370
15678
  inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brush_presets to start from (default the active brush)." }, name: { type: "string", minLength: 1, maxLength: 80, description: "Display name." }, ...BRUSH_PATCH_PROPERTIES, unset: BRUSH_UNSET_PROPERTY }, additionalProperties: false },
14371
- annotations: { title: "Set the brush", ...SET5 },
15679
+ annotations: { title: "Set the brush", ...SET6 },
14372
15680
  handler: async ({ id, unset, ...patch }) => {
14373
15681
  const stamps = patch.tip?.stamps;
14374
15682
  if (stamps) await c.prepareStickers(stamps);
@@ -14389,7 +15697,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14389
15697
  },
14390
15698
  additionalProperties: false
14391
15699
  },
14392
- annotations: { title: "Set the paint colour", ...SET5 },
15700
+ annotations: { title: "Set the paint colour", ...SET6 },
14393
15701
  handler: ({ color, secondaryColor, locked }) => {
14394
15702
  if (color === void 0 && secondaryColor === void 0 && locked === void 0) throw new Error("Pass color, secondaryColor or locked.");
14395
15703
  if (color !== void 0) c.setColor(color);
@@ -14404,7 +15712,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14404
15712
  title: "Set the paint (colour or gradient)",
14405
15713
  description: 'Sets what strokes paint with: one colour, or gradient paint that changes colour as the stroke goes. Gradient paint: 2..16 stops sorted by offset (#rrggbb, or "paint" / "secondary" for the two editor colours), space pigment (default: mixes like paint) / oklab / srgb, and map: distance (default: the colour changes as the stroke is dragged out, 300 px then mirrored back), pressure, tilt, velocity, direction, time, across-tip or random. Every stroking brush samples it per dab (dab, mixer, watercolour and gouache, oil and impasto, particle, bristle, sticker); erasers, blenders and smudges ignore it, and a brush with its own gradient (get_brush paint) paints that instead. It also ends effect paint (set_effect_paint); set_color goes back to one colour. Editor state, not an undo step. Returns paint, color and secondaryColor.',
14406
15714
  inputSchema: { type: "object", properties: { paint: PAINT_SOURCE_JSON_SCHEMA }, required: ["paint"], additionalProperties: false },
14407
- annotations: { title: "Set the paint (colour or gradient)", ...SET5 },
15715
+ annotations: { title: "Set the paint (colour or gradient)", ...SET6 },
14408
15716
  handler: ({ paint }) => {
14409
15717
  const p = c.setPaint(paint);
14410
15718
  const s = c.getState();
@@ -14425,7 +15733,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14425
15733
  required: ["x", "y"],
14426
15734
  additionalProperties: false
14427
15735
  },
14428
- annotations: { title: "Pick a colour", ...READ8 },
15736
+ annotations: { title: "Pick a colour", ...READ9 },
14429
15737
  coordinates: { in: [""] },
14430
15738
  handler: ({ x, y, layerId }) => c.pickColor(x, y, { layerId })
14431
15739
  });
@@ -14433,8 +15741,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14433
15741
  name: "get_mixer_brush",
14434
15742
  title: "Get the Mixer Brush load",
14435
15743
  description: "Reads what the brush carries between strokes: reservoir #rrggbb or null (the Mixer Brush's loaded paint), amount 0..1 left, pickup #rrggbb or null (paint picked up from the canvas, kept by dirty brushes and a Mixer Brush that does not clean each stroke). Call before painting with mixer-brush when its loadEachStroke is false, or with a dirty brush.",
14436
- inputSchema: EMPTY3,
14437
- annotations: { title: "Get the Mixer Brush load", ...READ8 },
15744
+ inputSchema: EMPTY4,
15745
+ annotations: { title: "Get the Mixer Brush load", ...READ9 },
14438
15746
  handler: () => loadResult(c.getState().mixerLoad)
14439
15747
  });
14440
15748
  add({
@@ -14442,7 +15750,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14442
15750
  title: "Load the Mixer Brush",
14443
15751
  description: "Fills the Mixer Brush reservoir with a colour (amount 1), keeping what it picked up. Editor state, not an undo step. Returns the load.",
14444
15752
  inputSchema: { type: "object", properties: { color: { ...COLOR3, description: "Colour #rrggbb (default the paint colour)." } }, additionalProperties: false },
14445
- annotations: { title: "Load the Mixer Brush", ...SET5 },
15753
+ annotations: { title: "Load the Mixer Brush", ...SET6 },
14446
15754
  handler: ({ color }) => loadResult(c.loadMixerBrush(color))
14447
15755
  });
14448
15756
  add({
@@ -14450,7 +15758,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14450
15758
  title: "Clean the Mixer Brush",
14451
15759
  description: "Cleans the brush: empties the picked-up paint a dirty brush or the Mixer Brush carries (and with emptyReservoir the Mixer Brush paint too). Editor state, not an undo step. Returns the load.",
14452
15760
  inputSchema: { type: "object", properties: { emptyReservoir: { type: "boolean", description: "Also drop the loaded paint (default false)." } }, additionalProperties: false },
14453
- annotations: { title: "Clean the Mixer Brush", ...SET5 },
15761
+ annotations: { title: "Clean the Mixer Brush", ...SET6 },
14454
15762
  handler: ({ emptyReservoir }) => loadResult(c.cleanMixerBrush(emptyReservoir === true))
14455
15763
  });
14456
15764
  add({
@@ -14461,7 +15769,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14461
15769
  type: "object",
14462
15770
  properties: {
14463
15771
  points: POINTS2,
14464
- layerId: LAYER_ID4,
15772
+ layerId: LAYER_ID5,
14465
15773
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
14466
15774
  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 },
14467
15775
  color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
@@ -14472,7 +15780,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14472
15780
  required: ["points"],
14473
15781
  additionalProperties: false
14474
15782
  },
14475
- annotations: { title: "Paint a brush stroke", ...WRITE7 },
15783
+ annotations: { title: "Paint a brush stroke", ...WRITE8 },
14476
15784
  coordinates: { in: ["points[]"], out: ["bounds"] },
14477
15785
  handler: async (a) => {
14478
15786
  await c.wetIdle();
@@ -14499,7 +15807,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14499
15807
  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." },
14500
15808
  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." },
14501
15809
  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)." },
14502
- layerId: LAYER_ID4,
15810
+ layerId: LAYER_ID5,
14503
15811
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
14504
15812
  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 },
14505
15813
  color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
@@ -14509,7 +15817,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14509
15817
  required: ["path"],
14510
15818
  additionalProperties: false
14511
15819
  },
14512
- annotations: { title: "Brush along a path", ...WRITE7 },
15820
+ annotations: { title: "Brush along a path", ...WRITE8 },
14513
15821
  coordinates: { out: ["bounds"] },
14514
15822
  handler: async (a) => {
14515
15823
  await c.wetIdle();
@@ -14563,7 +15871,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14563
15871
  properties: { limit: { type: "integer", minimum: 1, maximum: 5e3, description: "Most recent samples to return (default 500)." } },
14564
15872
  additionalProperties: false
14565
15873
  },
14566
- annotations: { title: "Get the stroke being painted", ...READ8 },
15874
+ annotations: { title: "Get the stroke being painted", ...READ9 },
14567
15875
  handler: (args) => {
14568
15876
  const live = c.getLiveStroke();
14569
15877
  if (!live) return { live: false };
@@ -14585,7 +15893,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14585
15893
  },
14586
15894
  additionalProperties: false
14587
15895
  },
14588
- annotations: { title: "List recorded strokes", ...READ8 },
15896
+ annotations: { title: "List recorded strokes", ...READ9 },
14589
15897
  coordinates: { out: ["strokes[].bounds", "strokes[].points[]"] },
14590
15898
  handler: ({ ids, last, live, layerId }) => {
14591
15899
  if (layerId !== void 0 && !c.getLayer(layerId)) throw new Error(`Unknown layer "${layerId}". Call list_layers for the ids.`);
@@ -14614,7 +15922,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14614
15922
  required: ["x", "y"],
14615
15923
  additionalProperties: false
14616
15924
  },
14617
- annotations: { title: "Find the stroke at a point", ...READ8 },
15925
+ annotations: { title: "Find the stroke at a point", ...READ9 },
14618
15926
  coordinates: { in: [""], out: ["stroke.bounds"] },
14619
15927
  handler: ({ layerId, x, y }) => {
14620
15928
  const s = c.liveStrokeAt(layerId, x, y);
@@ -14638,7 +15946,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14638
15946
  required: ["id"],
14639
15947
  additionalProperties: false
14640
15948
  },
14641
- annotations: { title: "Change a stroke", ...WRITE7 },
15949
+ annotations: { title: "Change a stroke", ...WRITE8 },
14642
15950
  coordinates: { out: ["bounds", "stroke.bounds"] },
14643
15951
  handler: ({ id, ...update }) => {
14644
15952
  if (!Object.keys(update).length) throw new Error("Pass at least one change: brushId, brush, color, paint or seed.");
@@ -14677,7 +15985,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14677
15985
  required: ["id"],
14678
15986
  additionalProperties: false
14679
15987
  },
14680
- annotations: { title: "Move or reshape a stroke", ...WRITE7 },
15988
+ annotations: { title: "Move or reshape a stroke", ...WRITE8 },
14681
15989
  coordinates: { in: ["points[]", "transform.origin"], out: ["bounds", "stroke.bounds", "stroke.points[]"] },
14682
15990
  handler: async ({ id, points, transform }) => {
14683
15991
  await c.prepareStrokeEdit(id, { points, transform });
@@ -14695,7 +16003,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14695
16003
  required: ["id", "index"],
14696
16004
  additionalProperties: false
14697
16005
  },
14698
- annotations: { title: "Move a stroke up or down", ...WRITE7 },
16006
+ annotations: { title: "Move a stroke up or down", ...WRITE8 },
14699
16007
  handler: ({ id, index }) => ({ order: c.reorderStroke(id, index) })
14700
16008
  });
14701
16009
  add({
@@ -14717,7 +16025,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14717
16025
  title: "Make a layer a stroke layer",
14718
16026
  description: 'Makes an empty pixel layer a stroke layer, so strokes painted on it stay editable. Refuses a layer with paint (add_layer {type: "strokes"} instead). One undo step. Returns the layer.',
14719
16027
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers (default the active layer)." } }, additionalProperties: false },
14720
- annotations: { title: "Make a layer a stroke layer", ...WRITE7 },
16028
+ annotations: { title: "Make a layer a stroke layer", ...WRITE8 },
14721
16029
  coordinates: { out: ["bounds"] },
14722
16030
  handler: ({ layerId }) => c.convertToLiveStrokes(layerId)
14723
16031
  });
@@ -14759,7 +16067,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14759
16067
  required: ["strokeIds"],
14760
16068
  additionalProperties: false
14761
16069
  },
14762
- annotations: { title: "Replay strokes, varied", ...WRITE7 },
16070
+ annotations: { title: "Replay strokes, varied", ...WRITE8 },
14763
16071
  coordinates: { in: ["origin"], out: ["bounds"] },
14764
16072
  handler: async (a) => {
14765
16073
  const replay = a;
@@ -14781,7 +16089,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14781
16089
  },
14782
16090
  additionalProperties: false
14783
16091
  },
14784
- annotations: { title: "Rotate, mirror or zoom the view", ...SET5 },
16092
+ annotations: { title: "Rotate, mirror or zoom the view", ...SET6 },
14785
16093
  handler: ({ reset, ...v }) => {
14786
16094
  if (reset) c.resetView();
14787
16095
  return c.setView(v);
@@ -14792,15 +16100,15 @@ function createPaintEditorToolProvider(controller, options = {}) {
14792
16100
  title: "Set the drawing hand",
14793
16101
  description: "Says which hand holds the pen; palm rejection ignores touches on that side of the pen. Editor state, not an undo step. Returns the hand.",
14794
16102
  inputSchema: { type: "object", properties: { hand: { type: "string", enum: ["right", "left"], description: "right or left." } }, required: ["hand"], additionalProperties: false },
14795
- annotations: { title: "Set the drawing hand", ...SET5 },
16103
+ annotations: { title: "Set the drawing hand", ...SET6 },
14796
16104
  handler: ({ hand }) => ({ hand: c.setHand(hand) })
14797
16105
  });
14798
16106
  add({
14799
16107
  name: "undo_paint",
14800
16108
  title: "Undo",
14801
16109
  description: "Undoes the last step (a stroke, a fill, a layer or paper change). Returns the label undone (null when there was nothing) and what can be undone and redone next.",
14802
- inputSchema: EMPTY3,
14803
- annotations: { title: "Undo", ...WRITE7 },
16110
+ inputSchema: EMPTY4,
16111
+ annotations: { title: "Undo", ...WRITE8 },
14804
16112
  handler: () => {
14805
16113
  const undone = c.undo();
14806
16114
  const s = c.getState();
@@ -14811,8 +16119,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14811
16119
  name: "redo_paint",
14812
16120
  title: "Redo",
14813
16121
  description: "Redoes the last undone step. Returns the label redone (null when there was nothing) and what can be undone and redone next.",
14814
- inputSchema: EMPTY3,
14815
- annotations: { title: "Redo", ...WRITE7 },
16122
+ inputSchema: EMPTY4,
16123
+ annotations: { title: "Redo", ...WRITE8 },
14816
16124
  handler: () => {
14817
16125
  const redone = c.redo();
14818
16126
  const s = c.getState();
@@ -14832,7 +16140,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14832
16140
  },
14833
16141
  additionalProperties: false
14834
16142
  },
14835
- annotations: { title: "Render a preview", ...READ8 },
16143
+ annotations: { title: "Render a preview", ...READ9 },
14836
16144
  handler: ({ maxSide, layerIds, paper }) => {
14837
16145
  if (layerIds) {
14838
16146
  for (const id of layerIds) if (!c.getLayer(id)) throw new Error(`Unknown layer "${id}". Call list_layers for the ids.`);
@@ -14856,7 +16164,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14856
16164
  },
14857
16165
  additionalProperties: false
14858
16166
  },
14859
- annotations: { title: "Export the painting", ...READ8 },
16167
+ annotations: { title: "Export the painting", ...READ9 },
14860
16168
  handler: async ({ format, quality, scale, paper, time }) => {
14861
16169
  const run = () => c.exportImage({ format, quality, scale, paper });
14862
16170
  const bytes = time === void 0 ? await run() : await c.withEffectTime(time, run);
@@ -14868,7 +16176,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14868
16176
  title: "Export as PSD",
14869
16177
  description: 'Writes the painting as an 8-bit RGB PSD (PSB above 30000 px) through the shared PSD writer: every layer and group with its opacity, blend, mask and locks, the paper as a locked "Paper" pixel layer at the bottom, and the composite as the merged image. Changes nothing. Returns the file as base64, its size, whether it is a PSB, and warnings (pigment and painterly blend modes have no PSD equivalent: they are written as the nearest standard mode, see list_blend_modes).',
14870
16178
  inputSchema: { type: "object", properties: { time: TIME }, additionalProperties: false },
14871
- annotations: { title: "Export as PSD", ...READ8 },
16179
+ annotations: { title: "Export as PSD", ...READ9 },
14872
16180
  handler: async ({ time }) => {
14873
16181
  const r = time === void 0 ? c.exportPsd() : await c.withEffectTime(time, () => c.exportPsd());
14874
16182
  return { data: bytesToBase64(r.bytes), bytes: r.bytes.length, psb: r.psb, warnings: r.warnings };
@@ -14877,6 +16185,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14877
16185
  tools.push(...createPaintGuideTools(c));
14878
16186
  tools.push(...createPaintWetTools(c));
14879
16187
  tools.push(...createPaintThickTools(c));
16188
+ tools.push(...createFerroTools(c));
14880
16189
  tools.push(...createPaintReplayTools(c));
14881
16190
  tools.push(...createPaintEvolutionTools(c));
14882
16191
  tools.push(...createPaintStickerTools(c));
@@ -15435,6 +16744,18 @@ var WET_TILT_SLIDERS = [
15435
16744
  { path: "direction", label: "Downhill", min: 0, max: 359, step: 1 }
15436
16745
  ];
15437
16746
  var WET_SPEED_SLIDER = { path: "speed", label: "Speed", min: 15, max: 480, step: 15 };
16747
+ var LIVE_PATTERN_LABELS = {
16748
+ spots: "Spots",
16749
+ stripes: "Stripes",
16750
+ coral: "Coral",
16751
+ maze: "Maze",
16752
+ worms: "Worms"
16753
+ };
16754
+ var LIVE_PATTERN_SLIDERS = [
16755
+ { path: "scale", label: "Scale", min: 0.25, max: 4, step: 0.05 },
16756
+ { path: "contrast", label: "Contrast", min: 0, max: 2, step: 0.05 },
16757
+ { path: "relief", label: "Relief", min: 0, max: 1, step: 0.01 }
16758
+ ];
15438
16759
  var WET_GPU_OPTIONS = [
15439
16760
  { value: "auto", label: "GPU for large washes" },
15440
16761
  { value: "off", label: "CPU only" }
@@ -15588,6 +16909,10 @@ var EVOLUTION_SLIDERS = [
15588
16909
  { path: "brush", label: "Brush", min: 0, max: 1, step: 0.05 },
15589
16910
  { path: "seed", label: "Seed", min: 0, max: 1, step: 0.05 }
15590
16911
  ];
16912
+ var MAGNET_SLIDERS = [
16913
+ { path: "strength", label: "Strength", min: -1, max: 1, step: 0.05 },
16914
+ { path: "radius", label: "Radius", min: 4, max: 512, step: 1 }
16915
+ ];
15591
16916
  function quickStencil(kind, canvas) {
15592
16917
  const { width: w, height: h } = canvas;
15593
16918
  switch (kind) {
@@ -15873,8 +17198,8 @@ function createPaintEditorDocument(controller, options = {}) {
15873
17198
  }
15874
17199
 
15875
17200
  // src/paint-editor/collaboration-tools.ts
15876
- var READ9 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
15877
- var WRITE8 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
17201
+ var READ10 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
17202
+ var WRITE9 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
15878
17203
  var proposalIdArg = {
15879
17204
  type: "object",
15880
17205
  properties: { proposalId: { type: "string", minLength: 1, description: "Id of a pending paint proposal, from propose_paint or list_paint_proposals." } },
@@ -15952,7 +17277,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
15952
17277
  required: ["title", "ops"],
15953
17278
  additionalProperties: false
15954
17279
  },
15955
- annotations: { title: "Propose paint", ...WRITE8 },
17280
+ annotations: { title: "Propose paint", ...WRITE9 },
15956
17281
  handler: (args, context) => {
15957
17282
  const origin = context.actor?.id ? `agent:${context.actor.id}` : options.fallbackOrigin;
15958
17283
  const proposal = proposals.propose({ ...args, ...origin ? { origin } : {} });
@@ -15965,7 +17290,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
15965
17290
  title: "List paint proposals",
15966
17291
  description: "Lists the pending paint proposals (ghost paint not yet on the canvas): id, title, rationale, who proposed it, its op kinds, where it goes, where the ghost sits, the strokes it echoes and any of those since removed (missing: it can no longer be accepted), and whether accepting is allowed. Call before accept_paint_proposal or reject_paint_proposal.",
15967
17292
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
15968
- annotations: { title: "List paint proposals", ...READ9 },
17293
+ annotations: { title: "List paint proposals", ...READ10 },
15969
17294
  handler: () => ({ proposals: proposals.getState().proposals.map(describe), readOnly: readOnlyReason() })
15970
17295
  };
15971
17296
  const acceptTool = {
@@ -15973,7 +17298,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
15973
17298
  title: "Accept a paint proposal",
15974
17299
  description: "Paints a pending proposal on a new layer as one undo step, recorded as made by whoever proposed it, and drops it from the pending list; the user's active layer stays theirs. Refuses, changing nothing, when the painting is read-only or a stroke it echoes was removed. Returns the new layer id, the stroke ids painted and the undo step name.",
15975
17300
  inputSchema: proposalIdArg,
15976
- annotations: { title: "Accept a paint proposal", ...WRITE8 },
17301
+ annotations: { title: "Accept a paint proposal", ...WRITE9 },
15977
17302
  handler: (args) => {
15978
17303
  const reason = readOnlyReason();
15979
17304
  if (reason) throw new Error(reason);
@@ -15985,7 +17310,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
15985
17310
  title: "Reject a paint proposal",
15986
17311
  description: "Dismisses a pending paint proposal: its ghost disappears and the painting and its history are unchanged. Use it to withdraw a suggestion that no longer fits. Returns the dismissed proposal id and title.",
15987
17312
  inputSchema: proposalIdArg,
15988
- annotations: { title: "Reject a paint proposal", ...WRITE8 },
17313
+ annotations: { title: "Reject a paint proposal", ...WRITE9 },
15989
17314
  handler: (args) => {
15990
17315
  const p = proposals.reject(args.proposalId);
15991
17316
  return { proposalId: p.id, title: p.title, rejected: true };
@@ -16058,6 +17383,33 @@ var PAINT_DOCUMENT_JSON_SCHEMA = {
16058
17383
  required: ["id", "layerId", "points", "brush"]
16059
17384
  }
16060
17385
  },
17386
+ ferro: { type: "object", additionalProperties: { type: "string" }, description: "Layer id \u2192 asset id of its liquid ferrofluid map." },
17387
+ ferroSettings: {
17388
+ type: "object",
17389
+ additionalProperties: {
17390
+ type: "object",
17391
+ properties: {
17392
+ wavelength: { type: "number", description: "Rosensweig spike spacing in pixels." },
17393
+ threshold: { type: "number", description: "Critical magnetic field threshold." }
17394
+ }
17395
+ },
17396
+ description: "Layer id \u2192 ferrofluid spike settings."
17397
+ },
17398
+ magnets: {
17399
+ type: "array",
17400
+ description: "Magnets on the canvas that pull liquid ferrofluid into spikes.",
17401
+ items: {
17402
+ type: "object",
17403
+ properties: {
17404
+ id: { type: "string", description: "Magnet id." },
17405
+ x: { type: "number", description: "Document x in pixels." },
17406
+ y: { type: "number", description: "Document y in pixels." },
17407
+ strength: { type: "number", minimum: -1, maximum: 1, description: "Magnet dipole strength (-1..1)." },
17408
+ radius: { type: "number", minimum: 4, maximum: 512, description: "Magnet field falloff radius in pixels." }
17409
+ },
17410
+ required: ["id", "x", "y", "strength", "radius"]
17411
+ }
17412
+ },
16061
17413
  fluor: {
16062
17414
  type: "object",
16063
17415
  description: "Per-layer UV-fluorescent ink channels (layerId \u2192 bounds + PNG data URL).",
@@ -16108,13 +17460,14 @@ function uiTables() {
16108
17460
  brushOptions: BRUSH_OPTION_CONTROLS,
16109
17461
  blendMenu: BLEND_MENU_GROUPS,
16110
17462
  layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
16111
- sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, uvLight: UV_LIGHT_SLIDERS, viewTilt: VIEW_TILT_SLIDERS, ghost: GHOST_SLIDERS, mutation: MUTATION_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
17463
+ sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, uvLight: UV_LIGHT_SLIDERS, viewTilt: VIEW_TILT_SLIDERS, ghost: GHOST_SLIDERS, mutation: MUTATION_SLIDERS, livePatterns: LIVE_PATTERN_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS, magnet: MAGNET_SLIDERS },
16112
17464
  pathStrokeTapers: PATH_STROKE_TAPERS,
16113
17465
  padSampleSizes: PAD_SAMPLE_SIZES,
16114
17466
  wetGpu: WET_GPU_OPTIONS,
16115
17467
  labels: {
16116
17468
  wetTools: WET_TOOL_LABELS,
16117
17469
  wetMedia: WET_MEDIUM_LABELS,
17470
+ livePatterns: LIVE_PATTERN_LABELS,
16118
17471
  impastoTools: IMPASTO_TOOL_LABELS,
16119
17472
  particleKinds: PARTICLE_KIND_LABELS,
16120
17473
  particleRenders: PARTICLE_RENDER_LABELS,
@@ -16184,6 +17537,7 @@ export {
16184
17537
  DEFAULT_BRUSH_ID,
16185
17538
  DEFAULT_DPI,
16186
17539
  DEFAULT_DYNAMICS,
17540
+ DEFAULT_FERRO,
16187
17541
  DEFAULT_FLOW,
16188
17542
  DEFAULT_FLUORESCENCE,
16189
17543
  DEFAULT_GHOST,
@@ -16191,6 +17545,9 @@ export {
16191
17545
  DEFAULT_IRIDESCENCE,
16192
17546
  DEFAULT_LIGHT,
16193
17547
  DEFAULT_LIGHTING,
17548
+ DEFAULT_LIVE_PATTERNS,
17549
+ DEFAULT_MAGNET_RADIUS,
17550
+ DEFAULT_MAGNET_STRENGTH,
16194
17551
  DEFAULT_MULTI_COLOR,
16195
17552
  DEFAULT_MUTATION,
16196
17553
  DEFAULT_MUTATION_RATES,
@@ -16218,6 +17575,10 @@ export {
16218
17575
  EVOLUTION_BRUSH_FACETS,
16219
17576
  EVOLUTION_MUTATION_SCHEMA,
16220
17577
  EVOLUTION_SLIDERS,
17578
+ FERRO_CARRIER_RGB,
17579
+ FERRO_DEFAULT_GLOSS,
17580
+ FERRO_DEFAULT_METALLIC,
17581
+ FERRO_DEFAULT_RIM,
16221
17582
  FilmBuffer,
16222
17583
  GHOST_SLIDERS,
16223
17584
  GHOST_TRIGGERS,
@@ -16235,8 +17596,14 @@ export {
16235
17596
  IMPASTO_TOOL_LABELS,
16236
17597
  IRIDESCENCE_SLIDERS,
16237
17598
  LIGHT_SLIDERS,
17599
+ LIVE_PATTERN_KINDS,
17600
+ LIVE_PATTERN_LABELS,
17601
+ LIVE_PATTERN_PRESETS,
17602
+ LIVE_PATTERN_SCHEMA,
17603
+ LIVE_PATTERN_SLIDERS,
16238
17604
  LIVE_STROKE_ENGINES,
16239
17605
  LitCache,
17606
+ MAGNET_SLIDERS,
16240
17607
  MAX_BRUSH_SIZE,
16241
17608
  MAX_CANVAS_SIDE,
16242
17609
  MAX_COORDINATE,
@@ -16349,9 +17716,11 @@ export {
16349
17716
  addPaintGradientStop,
16350
17717
  addPolygonArea,
16351
17718
  advanceHeatField,
17719
+ advectFerro,
16352
17720
  affineCopy,
16353
17721
  applyAffine,
16354
17722
  backlightOf,
17723
+ bakeFerroIntoThick,
16355
17724
  brushEngineControls,
16356
17725
  brushLoadColor,
16357
17726
  brushLoadForStroke,
@@ -16368,6 +17737,7 @@ export {
16368
17737
  computeUvSpill,
16369
17738
  contactSheet,
16370
17739
  createEvolutionTools,
17740
+ createFerroTools,
16371
17741
  createHeatField,
16372
17742
  createMutationTools,
16373
17743
  createPaintAgentStrokes,
@@ -16412,11 +17782,20 @@ export {
16412
17782
  effectPaintKey,
16413
17783
  effectPaintSize,
16414
17784
  emptiedLayers,
17785
+ emptyFerro,
16415
17786
  emptyFluor,
16416
17787
  emptyThermo,
16417
17788
  emptyThickChannels,
16418
17789
  engineBrushSourceIds,
16419
17790
  exportProposalContactSheetPng,
17791
+ ferroCacheKey,
17792
+ ferroFieldAt,
17793
+ ferroFromJSON,
17794
+ ferroInfo,
17795
+ ferroSpikeHeightAt,
17796
+ ferroToJSON,
17797
+ ferroToShadow,
17798
+ ferroTwinBrush,
16420
17799
  fillRegionMask,
16421
17800
  finalizeThermoStroke,
16422
17801
  findUnfilledRegionsMask,
@@ -16453,7 +17832,9 @@ export {
16453
17832
  lightingKey,
16454
17833
  liveStrokeBounds,
16455
17834
  liveStrokeRefusal,
17835
+ magnetsFromJSON,
16456
17836
  mapPenPoint,
17837
+ mergeFerro,
16457
17838
  mergeFourChannel,
16458
17839
  mix32,
16459
17840
  mixColorOklch,
@@ -16475,6 +17856,8 @@ export {
16475
17856
  normalizeBrushStroke,
16476
17857
  normalizeCanvas,
16477
17858
  normalizeDynamics,
17859
+ normalizeFerro,
17860
+ normalizeFerro as normalizeFerroSettings,
16478
17861
  normalizeFlow,
16479
17862
  normalizeFluorescence,
16480
17863
  normalizeGhost,
@@ -16482,6 +17865,8 @@ export {
16482
17865
  normalizeImpasto,
16483
17866
  normalizeLight,
16484
17867
  normalizeLighting,
17868
+ normalizeLivePatterns,
17869
+ normalizeMagnet,
16485
17870
  normalizeMultiColor,
16486
17871
  normalizeMutation,
16487
17872
  normalizeMutationRates,
@@ -16563,6 +17948,7 @@ export {
16563
17948
  sampleHeatAt,
16564
17949
  samplePath,
16565
17950
  samplePressure,
17951
+ scaleFerro,
16566
17952
  scaleFluor,
16567
17953
  scalePaintGradient,
16568
17954
  scaleStencil,
@@ -16571,6 +17957,7 @@ export {
16571
17957
  seededStrokeVariation,
16572
17958
  serializePaintDocument,
16573
17959
  serializePaintDocumentExternal,
17960
+ shadowToFerro,
16574
17961
  shiftHeatField,
16575
17962
  solidPaint,
16576
17963
  squaredDistanceTransform,