@pixerate/editor 0.276.0 → 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 (61) hide show
  1. package/dist/animation-editor/index.cjs +59 -59
  2. package/dist/animation-editor/index.js +3 -3
  3. package/dist/brushes/index.cjs +69 -67
  4. package/dist/brushes/index.js +6 -4
  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 +2 -0
  8. package/dist/brushes/profiles.d.ts +2 -0
  9. package/dist/brushes/schema.d.cts +1 -0
  10. package/dist/brushes/schema.d.ts +1 -0
  11. package/dist/brushes/types.d.cts +10 -0
  12. package/dist/brushes/types.d.ts +10 -0
  13. package/dist/{brushstrokeRender-I6MYFCOU.cjs → brushstrokeRender-7HOMKZNO.cjs} +15 -15
  14. package/dist/{brushstrokeRender-FK3ASUUJ.js → brushstrokeRender-EIK3625T.js} +3 -3
  15. package/dist/canvas/index.cjs +13 -13
  16. package/dist/canvas/index.js +1 -1
  17. package/dist/{chunk-U26LUXCJ.cjs → chunk-4NZTK7LM.cjs} +35 -35
  18. package/dist/{chunk-CVBTJVRN.js → chunk-DWPFGWW5.js} +1 -1
  19. package/dist/{chunk-XMSH66VU.js → chunk-FNIWHSLI.js} +1 -1
  20. package/dist/{chunk-QNNZA3V5.cjs → chunk-H3EME3S5.cjs} +2 -2
  21. package/dist/{chunk-4EM5NGCL.js → chunk-HHX7ST6Z.js} +1 -1
  22. package/dist/{chunk-Q33V5PEF.cjs → chunk-HRUK5TFW.cjs} +73 -12
  23. package/dist/{chunk-5HR5CSLC.cjs → chunk-JVFGK3DK.cjs} +69 -58
  24. package/dist/{chunk-JNMNM3SL.js → chunk-LZE2QZCT.js} +1 -1
  25. package/dist/{chunk-OS6OG7DN.js → chunk-N4MCWQZ7.js} +1 -1
  26. package/dist/{chunk-IJ6NSWKM.js → chunk-NJLPW4MM.js} +17 -6
  27. package/dist/{chunk-OY24XRYF.cjs → chunk-PEQWARCP.cjs} +12 -12
  28. package/dist/{chunk-XVPQNX7O.js → chunk-PPDUCZZV.js} +66 -5
  29. package/dist/{chunk-X7XBEX3O.cjs → chunk-QBZEC2JJ.cjs} +86 -86
  30. package/dist/{chunk-GHYJAMMS.js → chunk-ROURHJYT.js} +1 -1
  31. package/dist/{chunk-ZIE6EJGR.cjs → chunk-VNF7A2JA.cjs} +23 -23
  32. package/dist/{chunk-ISCAJBPQ.cjs → chunk-WFSYZO5Z.cjs} +1 -1
  33. package/dist/image-editor/index.cjs +788 -788
  34. package/dist/image-editor/index.js +4 -4
  35. package/dist/paint-editor/brush-model.d.cts +15 -1
  36. package/dist/paint-editor/brush-model.d.ts +15 -1
  37. package/dist/paint-editor/controller.d.cts +50 -1
  38. package/dist/paint-editor/controller.d.ts +50 -1
  39. package/dist/paint-editor/document.d.cts +8 -1
  40. package/dist/paint-editor/document.d.ts +8 -1
  41. package/dist/paint-editor/ferro-tools.d.cts +10 -0
  42. package/dist/paint-editor/ferro-tools.d.ts +10 -0
  43. package/dist/paint-editor/ferro.d.cts +134 -0
  44. package/dist/paint-editor/ferro.d.ts +134 -0
  45. package/dist/paint-editor/index.cjs +1878 -831
  46. package/dist/paint-editor/index.d.cts +2 -0
  47. package/dist/paint-editor/index.d.ts +2 -0
  48. package/dist/paint-editor/index.js +1184 -137
  49. package/dist/paint-editor/stroke.d.cts +14 -0
  50. package/dist/paint-editor/stroke.d.ts +14 -0
  51. package/dist/paint-editor/thick.d.cts +15 -2
  52. package/dist/paint-editor/thick.d.ts +15 -2
  53. package/dist/paint-editor/ui.d.cts +2 -0
  54. package/dist/paint-editor/ui.d.ts +2 -0
  55. package/dist/vector/index.cjs +998 -998
  56. package/dist/vector/index.js +4 -4
  57. package/package.json +1 -1
  58. package/skills/pixerate-brushes/SKILL.md +1 -0
  59. package/skills/pixerate-paint-editor/SKILL.md +11 -29
  60. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  61. 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-IJ6NSWKM.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-GHYJAMMS.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-XMSH66VU.js";
45
+ } from "../chunk-FNIWHSLI.js";
46
46
  import "../chunk-ADMRNWR7.js";
47
47
  import "../chunk-WJ4WQJCC.js";
48
48
  import {
@@ -125,7 +125,7 @@ import {
125
125
  wetPaperInput,
126
126
  wetPigmentOf,
127
127
  withStrokePath
128
- } from "../chunk-4EM5NGCL.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-XVPQNX7O.js";
203
+ } from "../chunk-PPDUCZZV.js";
202
204
  import {
203
205
  STAMP_KINDS,
204
206
  StickersSlot,
@@ -412,6 +414,442 @@ function createPaintDocument(options = {}) {
412
414
  return { canvas, paper: normalizePaper(options.paper ?? {}), layers: [layer], activeLayerId: layer.id, strokes: [] };
413
415
  }
414
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
+
415
853
  // src/paint-editor/thick.ts
416
854
  var DEFAULT_LIGHTING = { azimuth: 315, elevation: 40, relief: 0.7 };
417
855
  function normalizeLighting(raw = {}, base = DEFAULT_LIGHTING) {
@@ -551,8 +989,14 @@ function lightTile(color, height, heightAround, material, light, film = null, vi
551
989
  }
552
990
  var LitCache = class {
553
991
  tiles = /* @__PURE__ */ new Map();
554
- /** The layer's pixels lit (tiles without relief or film nearby are the colour tiles themselves). */
555
- 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);
556
1000
  const hasRelief = ch.height.tiles.size > 0 && light.relief > 0;
557
1001
  const hasFilm = ch.film.tiles.size > 0;
558
1002
  if (!hasRelief && !hasFilm) return color;
@@ -601,6 +1045,101 @@ var LitCache = class {
601
1045
  for (const key of [...this.tiles.keys()]) if (!keys.has(key)) this.tiles.delete(key);
602
1046
  return new TiledSurface(4, out, color.defaultValue);
603
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
+ }
604
1143
  };
605
1144
  function expand(t, channels) {
606
1145
  if (t.data.length === TILE * TILE * channels) return t.data;
@@ -924,27 +1463,27 @@ var ThickStrokeDriver = class {
924
1463
  const u = ((dx + 0.5 - d.x) * nxs + (dy + 0.5 - d.y) * nys) / r;
925
1464
  const h0 = this.getH(lx, ly);
926
1465
  let h = h0;
927
- const full2 = 255 * thick * k;
1466
+ const full3 = 255 * thick * k;
928
1467
  switch (im.tool) {
929
1468
  case "brush": {
930
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);
931
- const target = full2 * c * ridge;
1470
+ const target = full3 * c * ridge;
932
1471
  h = Math.max(h0 * (1 - 0.25 * c * im.drag), Math.min(255, h0 * 0.35 + target));
933
1472
  break;
934
1473
  }
935
1474
  case "tube": {
936
1475
  const dome = Math.sqrt(Math.max(0, c));
937
- h = Math.max(h0, Math.min(255, full2 * dome * 1.1));
1476
+ h = Math.max(h0, Math.min(255, full3 * dome * 1.1));
938
1477
  break;
939
1478
  }
940
1479
  case "gloop": {
941
1480
  const dome = Math.sqrt(Math.max(0, 1 - Math.min(1, Math.abs(u)) ** 2));
942
- 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)));
943
1482
  break;
944
1483
  }
945
1484
  case "roller": {
946
1485
  const stipple = 0.75 + 0.5 * hash2(lx >> 1, ly >> 1, seed);
947
- 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);
948
1487
  break;
949
1488
  }
950
1489
  }
@@ -1068,7 +1607,7 @@ function normalizeLightSpill(raw, base = DEFAULT_LIGHT_SPILL) {
1068
1607
  function emptyLight() {
1069
1608
  return new TiledSurface(1, /* @__PURE__ */ new Map(), 0);
1070
1609
  }
1071
- var full = (t, channels) => {
1610
+ var full2 = (t, channels) => {
1072
1611
  if (t.data.length === TILE * TILE * channels) return t.data;
1073
1612
  const out = new Uint8ClampedArray(TILE * TILE * channels);
1074
1613
  const u = t.uniform ?? 0;
@@ -1082,7 +1621,7 @@ var full = (t, channels) => {
1082
1621
  function lightToShadow(light) {
1083
1622
  const tiles = /* @__PURE__ */ new Map();
1084
1623
  for (const [key, tile] of light.tiles) {
1085
- const l = full(tile, 1);
1624
+ const l = full2(tile, 1);
1086
1625
  const d = new Uint8ClampedArray(TILE * TILE * 4);
1087
1626
  for (let i = 0, j = 0; i < l.length; i++, j += 4) {
1088
1627
  if (!l[i]) continue;
@@ -1097,7 +1636,7 @@ function lightToShadow(light) {
1097
1636
  function shadowToLight(shadow) {
1098
1637
  const tiles = /* @__PURE__ */ new Map();
1099
1638
  for (const [key, tile] of shadow.tiles) {
1100
- const s = full(tile, 4);
1639
+ const s = full2(tile, 4);
1101
1640
  const d = new Uint8ClampedArray(TILE * TILE);
1102
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);
1103
1642
  const t = finishTile(d, 1, 0);
@@ -1132,7 +1671,7 @@ function scaleLight(light, k) {
1132
1671
  const tiles = /* @__PURE__ */ new Map();
1133
1672
  if (k > 0) {
1134
1673
  for (const [key, tile] of light.tiles) {
1135
- 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));
1136
1675
  const t = finishTile(d, 1, 0);
1137
1676
  if (t) tiles.set(key, t);
1138
1677
  }
@@ -1147,9 +1686,9 @@ function mergeLight(lower, upper, upperColor) {
1147
1686
  const lo = lower?.tiles.get(key);
1148
1687
  const up = upper.tiles.get(key);
1149
1688
  const col = upperColor.tiles.get(key);
1150
- const l = lo ? full(lo, 1) : null;
1151
- const u = up ? full(up, 1) : null;
1152
- 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;
1153
1692
  const d = new Uint8ClampedArray(TILE * TILE);
1154
1693
  for (let i = 0; i < d.length; i++) {
1155
1694
  const a = c ? c[i * 4 + 3] / 255 : 0;
@@ -1186,10 +1725,10 @@ var LightCache = class {
1186
1725
  out.set(key, cached.tile);
1187
1726
  continue;
1188
1727
  }
1189
- const c = full(tile, 4);
1190
- const ld = l ? full(l, 1) : null;
1191
- const md = m ? full(m, 4) : null;
1192
- 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;
1193
1732
  const d = new Uint8ClampedArray(c);
1194
1733
  for (let y = 0, i = 0; y < TILE; y++) {
1195
1734
  for (let x = 0; x < TILE; x++, i++) {
@@ -2916,6 +3455,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2916
3455
  this.invisibleDaylight = brush.fluorescence.amount > 0 && brush.fluorescence.invisible && brush.engine !== "eraser" && brush.engine !== "blender" && brush.engine !== "smudge";
2917
3456
  if (init.wet) this.wetDriver = init.wet.driver;
2918
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);
2919
3459
  if (init.fluor && !init.wet && !init.sticker) this.fluorTwin = this.makeFluorTwin(init.fluor.surface);
2920
3460
  if (init.thermo && !init.wet && !init.sticker) this.thermoTwin = this.makeThermoTwin(init.thermo.surface);
2921
3461
  this.apply = {
@@ -2948,6 +3488,9 @@ var PaintStrokeSession = class _PaintStrokeSession {
2948
3488
  /** Light paint: the stroke's twin over the light channel, and what it left there. */
2949
3489
  lightTwin = null;
2950
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;
2951
3494
  /** UV-fluorescent ink: the stroke's twin over the fluorescence channel, and what it left there. */
2952
3495
  fluorTwin = null;
2953
3496
  fluorResult = null;
@@ -3046,6 +3589,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
3046
3589
  paint,
3047
3590
  effect,
3048
3591
  light: null,
3592
+ ferro: null,
3049
3593
  fluor: null,
3050
3594
  thermo: null
3051
3595
  });
@@ -3069,7 +3613,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
3069
3613
  inputFilter: void 0,
3070
3614
  light: null,
3071
3615
  fluor: null,
3072
- thermo: null
3616
+ thermo: null,
3617
+ ferro: null
3073
3618
  });
3074
3619
  }
3075
3620
  /** The stroke's twin over the 4-channel thermochromic surface (see `heat.ts`). */
@@ -3104,7 +3649,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
3104
3649
  effect: null,
3105
3650
  light: null,
3106
3651
  fluor: null,
3107
- thermo: null
3652
+ thermo: null,
3653
+ ferro: null
3108
3654
  });
3109
3655
  }
3110
3656
  /** Light paint: the layer's light channel after the stroke, or `null` when the stroke has no twin or left it unchanged. */
@@ -3119,6 +3665,44 @@ var PaintStrokeSession = class _PaintStrokeSession {
3119
3665
  thermoChannel() {
3120
3666
  return this.thermoResult;
3121
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
+ }
3122
3706
  tileTarget() {
3123
3707
  return {
3124
3708
  offset: this.init.offset,
@@ -3149,6 +3733,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
3149
3733
  this.lastRaw = points[points.length - 1];
3150
3734
  this.host.sampled?.(this, points);
3151
3735
  this.lightTwin?.push(points);
3736
+ this.ferroTwin?.push(points);
3152
3737
  this.fluorTwin?.push(points);
3153
3738
  this.thermoTwin?.push(points);
3154
3739
  if (this.wetDriver) {
@@ -3242,6 +3827,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
3242
3827
  this.update(this.dry ? this.dry.finish() : this.thickDriver ? this.thickDriver.finish() : this.stickerSpray ? this.stickerSpray.finish() : this.wet.finish());
3243
3828
  this.closed = true;
3244
3829
  if (this.lightTwin?.open) this.lightTwin.end();
3830
+ if (this.ferroTwin?.open) this.ferroTwin.end();
3245
3831
  if (this.fluorTwin?.open) this.fluorTwin.end();
3246
3832
  if (this.thermoTwin?.open) this.thermoTwin.end();
3247
3833
  const ch = this.base.channels;
@@ -3276,6 +3862,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
3276
3862
  if (this.fluorTwin?.open) this.fluorTwin.cancel();
3277
3863
  if (this.thermoTwin?.open) this.thermoTwin.cancel();
3278
3864
  this.lightResult = null;
3865
+ if (this.ferroTwin?.open) this.ferroTwin.cancel();
3866
+ this.ferroResult = null;
3279
3867
  this.fluorResult = null;
3280
3868
  this.thermoResult = null;
3281
3869
  this.host.commit(this, null, true);
@@ -3392,8 +3980,8 @@ function nearestOnStencil(s, p) {
3392
3980
  if (s.kind === "arc") {
3393
3981
  const c = s.center;
3394
3982
  const ang = Math.atan2(p.y - c.y, p.x - c.x);
3395
- const full2 = Math.abs(s.endAngle - s.startAngle) >= 360;
3396
- if (full2) {
3983
+ const full3 = Math.abs(s.endAngle - s.startAngle) >= 360;
3984
+ if (full3) {
3397
3985
  const q = { x: c.x + Math.cos(ang) * s.radius, y: c.y + Math.sin(ang) * s.radius };
3398
3986
  return { point: q, distance: Math.hypot(p.x - q.x, p.y - q.y) };
3399
3987
  }
@@ -3836,7 +4424,7 @@ function deserializePaintDocument(raw) {
3836
4424
  const wetEvents = Array.isArray(r.wetEvents) ? r.wetEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.clock) && typeof e.kind === "string") : [];
3837
4425
  const state = { canvas, paper, layers, activeLayerId, strokes: readStrokes(r.strokes, problems) };
3838
4426
  if (wetEvents.length) state.wetEvents = wetEvents;
3839
- 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)) : [];
3840
4428
  if (paintEvents.length) state.paintEvents = paintEvents;
3841
4429
  const o = r.origin;
3842
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) };
@@ -4768,6 +5356,7 @@ function liveStrokeRefusal(brush) {
4768
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).`;
4769
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).`;
4770
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).`;
4771
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.`;
4772
5361
  return null;
4773
5362
  }
@@ -5036,6 +5625,12 @@ async function replayPainting(source, target, options = {}) {
5036
5625
  if (strokes >= maxStrokes && item.kind === "stroke") break;
5037
5626
  if (item.kind === "paint") {
5038
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
+ }
5039
5634
  if (!target.getLayer(e.layerId)) continue;
5040
5635
  if (e.kind === "fill") target.fillLayer({ layerId: e.layerId, ...e.data });
5041
5636
  else if (e.kind === "fill-region") {
@@ -5050,6 +5645,7 @@ async function replayPainting(source, target, options = {}) {
5050
5645
  ...d.expand !== void 0 ? { expand: Math.max(-16, Math.min(16, d.expand * s)) } : {}
5051
5646
  });
5052
5647
  } else if (e.kind === "clear") target.clearLayer(e.layerId);
5648
+ else if (e.kind === "cure-ferrofluid") target.cureFerrofluid(e.layerId);
5053
5649
  else if (e.kind === "live-strokes") {
5054
5650
  const d = e.data;
5055
5651
  if (d?.rasterized) target.replaceLiveStrokes(e.layerId, null);
@@ -6363,6 +6959,9 @@ var PaintEditorController = class _PaintEditorController {
6363
6959
  litCaches = /* @__PURE__ */ new Map();
6364
6960
  /** The live impasto stroke's channels. */
6365
6961
  liveThick = null;
6962
+ /** The live ferrofluid stroke's channel. */
6963
+ liveFerro = null;
6964
+ nextMagnetId = 1;
6366
6965
  /** The UV / blacklight setting (saved with the document, not undo steps). */
6367
6966
  uvLight = { ...DEFAULT_UV_LIGHT };
6368
6967
  uvCaches = /* @__PURE__ */ new Map();
@@ -6892,6 +7491,7 @@ var PaintEditorController = class _PaintEditorController {
6892
7491
  paper: options.paper === void 0 ? { ...this.doc.paper, offset: void 0 } : this.resolvePaper(options.paper)
6893
7492
  });
6894
7493
  this.extensions = {};
7494
+ this.nextMagnetId = 1;
6895
7495
  this.guides = { ...this.guides, symmetry: { ...this.guides.symmetry, center: { x: next.canvas.width / 2, y: next.canvas.height / 2 } }, tracing: null };
6896
7496
  this.tracingVersion++;
6897
7497
  if (this.wet.world || this.wet.patterns.size) {
@@ -7049,7 +7649,7 @@ var PaintEditorController = class _PaintEditorController {
7049
7649
  describeLayer(l, parentId = findLayer(this.doc.layers, l.id)?.parent?.id ?? null, depth = findLayer(this.doc.layers, l.id)?.path.length ?? 0) {
7050
7650
  let bounds = null;
7051
7651
  if (l.type === "pixel") {
7052
- 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);
7053
7653
  bounds = b ? { ...b, x: b.x + l.offset.x, y: b.y + l.offset.y } : null;
7054
7654
  } else for (const c of flattenLayers(l.children)) if (c.type === "pixel") bounds = unionRect(bounds, this.describeLayer(c, l.id, depth + 1).bounds);
7055
7655
  return {
@@ -7250,7 +7850,8 @@ var PaintEditorController = class _PaintEditorController {
7250
7850
  ...this.liveStrokesWithout(removedIds),
7251
7851
  ...this.lightWithout(removedIds),
7252
7852
  ...this.fluorWithout(removedIds),
7253
- ...this.thermoWithout(removedIds)
7853
+ ...this.thermoWithout(removedIds),
7854
+ ...this.ferroWithout(removedIds)
7254
7855
  });
7255
7856
  return { removed: id, activeLayerId: active };
7256
7857
  }
@@ -7266,12 +7867,16 @@ var PaintEditorController = class _PaintEditorController {
7266
7867
  const stickers = { ...this.doc.stickers ?? {} };
7267
7868
  const liveStrokes = { ...this.doc.liveStrokes ?? {} };
7268
7869
  const light = { ...this.doc.light ?? {} };
7870
+ const ferro = { ...this.doc.ferro ?? {} };
7871
+ const ferroSettings = { ...this.doc.ferroSettings ?? {} };
7269
7872
  const fluor = { ...this.doc.fluor ?? {} };
7270
7873
  const thermo = { ...this.doc.thermo ?? {} };
7271
7874
  const copy = (l) => {
7272
7875
  const id2 = newPaintLayerId();
7273
7876
  if (light[l.id]) light[id2] = light[l.id];
7274
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] };
7275
7880
  if (fluor[l.id]) fluor[id2] = fluor[l.id];
7276
7881
  if (thermo[l.id]) thermo[id2] = thermo[l.id];
7277
7882
  if (thick[l.id]) thick[id2] = thick[l.id];
@@ -7292,7 +7897,9 @@ var PaintEditorController = class _PaintEditorController {
7292
7897
  ...this.doc.liveStrokes ? { liveStrokes } : {},
7293
7898
  ...this.doc.light ? { light } : {},
7294
7899
  ...this.doc.fluor ? { fluor } : {},
7295
- ...this.doc.thermo ? { thermo } : {}
7900
+ ...this.doc.thermo ? { thermo } : {},
7901
+ ...this.doc.ferro ? { ferro } : {},
7902
+ ...this.doc.ferroSettings ? { ferroSettings } : {}
7296
7903
  })
7297
7904
  );
7298
7905
  return this.describeLayer(dup);
@@ -7345,6 +7952,13 @@ var PaintEditorController = class _PaintEditorController {
7345
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;
7346
7953
  const lightAfter = this.lightWithout([upper.id, below.id]).light ?? (this.doc.light ? { ...this.doc.light } : void 0);
7347
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;
7348
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;
7349
7963
  const fluorAfter = this.fluorWithout([upper.id, below.id]).fluor ?? (this.doc.fluor ? { ...this.doc.fluor } : void 0);
7350
7964
  const fluor = mergedFluor ? { ...fluorAfter ?? {}, [below.id]: mergedFluor } : fluorAfter;
@@ -7362,7 +7976,9 @@ var PaintEditorController = class _PaintEditorController {
7362
7976
  ...this.liveStrokesWithout([upper.id, below.id]),
7363
7977
  ...light ? { light } : {},
7364
7978
  ...fluor ? { fluor } : {},
7365
- ...thermo ? { thermo } : {}
7979
+ ...thermo ? { thermo } : {},
7980
+ ...ferro !== void 0 ? { ferro } : {},
7981
+ ...ferroSettings !== void 0 ? { ferroSettings } : {}
7366
7982
  })
7367
7983
  );
7368
7984
  return this.describeLayer(merged);
@@ -7387,7 +8003,8 @@ var PaintEditorController = class _PaintEditorController {
7387
8003
  ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {},
7388
8004
  ...this.lightWithout([l.id]),
7389
8005
  ...this.fluorWithout([l.id]),
7390
- ...this.thermoWithout([l.id])
8006
+ ...this.thermoWithout([l.id]),
8007
+ ...this.ferroWithout([l.id])
7391
8008
  },
7392
8009
  { kind: "clear", layerId: l.id }
7393
8010
  )
@@ -7437,6 +8054,9 @@ var PaintEditorController = class _PaintEditorController {
7437
8054
  const surface = this.applyCoverage(l, coverage, rect, apply);
7438
8055
  const lit = this.doc.light?.[l.id];
7439
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;
7440
8060
  const fl = this.doc.fluor?.[l.id];
7441
8061
  const fluor = fl ? withLayerLight(this.doc.fluor, l.id, scaleFluor(fl, 1 - opacity)) : void 0;
7442
8062
  const th = this.doc.thermo?.[l.id];
@@ -7449,7 +8069,8 @@ var PaintEditorController = class _PaintEditorController {
7449
8069
  layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })),
7450
8070
  ...light ? { light } : {},
7451
8071
  ...fluor ? { fluor } : {},
7452
- ...thermo ? { thermo } : {}
8072
+ ...thermo ? { thermo } : {},
8073
+ ...fer ? { ferro, ferroSettings } : {}
7453
8074
  }),
7454
8075
  { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }
7455
8076
  )
@@ -8625,6 +9246,10 @@ var PaintEditorController = class _PaintEditorController {
8625
9246
  if (brush.thermochromic.hotColor !== null && brush.engine === "sticker") {
8626
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).`);
8627
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
+ }
8628
9253
  if (doc.liveStrokes?.[layer.id]) {
8629
9254
  const why = liveStrokeRefusal(brush);
8630
9255
  if (why) throw new PaintEditorError(`Layer "${layer.name}" is a stroke layer. ${why}`);
@@ -8754,6 +9379,8 @@ var PaintEditorController = class _PaintEditorController {
8754
9379
  thick: needThick ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
8755
9380
  // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
8756
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,
8757
9384
  fluor: brush.engine !== "sticker" && (brush.fluorescence.amount > 0 || doc.fluor?.[layer.id]) ? { surface: doc.fluor?.[layer.id] ?? emptyFluor() } : null,
8758
9385
  thermo: brush.engine !== "sticker" && (brush.thermochromic.hotColor !== null || doc.thermo?.[layer.id]) ? { surface: doc.thermo?.[layer.id] ?? emptyThermo() } : null,
8759
9386
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc), art: this.stickerArt.get, variant: (ref2) => this.stickerArt.variantFor(ref2, this.colorScheme === "dark") } : void 0,
@@ -8804,6 +9431,9 @@ var PaintEditorController = class _PaintEditorController {
8804
9431
  this.live = { layerId, surface };
8805
9432
  const ch = this.session?.thickChannels();
8806
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;
8807
9437
  const fl = this.session?.fluorChannel();
8808
9438
  this.liveFluor = fl ? { layerId, surface: fl } : null;
8809
9439
  const th = this.session?.thermoChannel();
@@ -8826,6 +9456,7 @@ var PaintEditorController = class _PaintEditorController {
8826
9456
  this.live = null;
8827
9457
  this.liveLayers = null;
8828
9458
  this.liveThick = null;
9459
+ this.liveFerro = null;
8829
9460
  this.liveFluor = null;
8830
9461
  this.liveThermo = null;
8831
9462
  const wetStroke = Boolean(s.init.wet);
@@ -8874,6 +9505,9 @@ var PaintEditorController = class _PaintEditorController {
8874
9505
  const stickers = sprayed ? { ...this.doc.stickers ?? {}, [l.id]: [...this.doc.stickers?.[l.id] ?? [], ...sprayed] } : this.doc.stickers;
8875
9506
  const wetLight = wetStroke && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null;
8876
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;
8877
9511
  const fluor = withLayerLight(this.doc.fluor, l.id, s.fluorChannel());
8878
9512
  const thermo = withLayerLight(this.doc.thermo, l.id, s.thermoChannel());
8879
9513
  this.commit(
@@ -8888,7 +9522,9 @@ var PaintEditorController = class _PaintEditorController {
8888
9522
  ...stickers ? { stickers } : {},
8889
9523
  ...light ? { light } : {},
8890
9524
  ...fluor ? { fluor } : {},
8891
- ...thermo ? { thermo } : {}
9525
+ ...thermo ? { thermo } : {},
9526
+ ...ferro ? { ferro } : { ferro: void 0 },
9527
+ ...ferroSettings ? { ferroSettings } : { ferroSettings: void 0 }
8892
9528
  },
8893
9529
  stroke
8894
9530
  )
@@ -8973,6 +9609,7 @@ var PaintEditorController = class _PaintEditorController {
8973
9609
  this.live = null;
8974
9610
  this.liveLayers = null;
8975
9611
  this.liveThick = null;
9612
+ this.liveFerro = null;
8976
9613
  this.liveFluor = null;
8977
9614
  this.liveThermo = null;
8978
9615
  const masks = s.init.selection ? (s.maskStencils ?? this.guides.stencils).filter((x) => x.enabled && x.mode === "mask") : [];
@@ -8993,6 +9630,7 @@ var PaintEditorController = class _PaintEditorController {
8993
9630
  this.live = null;
8994
9631
  this.liveLayers = null;
8995
9632
  this.liveThick = null;
9633
+ this.liveFerro = null;
8996
9634
  this.liveFluor = null;
8997
9635
  this.liveThermo = null;
8998
9636
  this.emit();
@@ -9037,6 +9675,14 @@ var PaintEditorController = class _PaintEditorController {
9037
9675
  const thick = withLayerThick(out.thick, l.id, ch);
9038
9676
  const sprayed = s.stickers();
9039
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;
9040
9686
  const fl = s.fluorChannel();
9041
9687
  const th = s.thermoChannel();
9042
9688
  out = withLiveStroke({
@@ -9046,6 +9692,7 @@ var PaintEditorController = class _PaintEditorController {
9046
9692
  ...thick ? { thick } : {},
9047
9693
  ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {},
9048
9694
  ...lit ? { light: withLayerLight(out.light, l.id, lit) } : {},
9695
+ ...fc ? { ferro: nextFerro, ferroSettings: nextFerroSettings } : {},
9049
9696
  ...fl ? { fluor: withLayerLight(out.fluor, l.id, fl) } : {},
9050
9697
  ...th ? { thermo: withLayerLight(out.thermo, l.id, th) } : {}
9051
9698
  }, stroke);
@@ -9440,6 +10087,178 @@ var PaintEditorController = class _PaintEditorController {
9440
10087
  this.commit("Flatten relief", this.withPaintEvent({ ...this.doc, ...t }, { kind: "flatten", layerId: l.id }));
9441
10088
  return { layerId: l.id, flattened: true };
9442
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
+ }
9443
10262
  // --- Replay: time-lapse and high-resolution export ------------------------------
9444
10263
  /**
9445
10264
  * A fresh, headless editor for replaying this painting at `scale` (canvas, dpi and paper grain
@@ -9942,20 +10761,33 @@ var PaintEditorController = class _PaintEditorController {
9942
10761
  };
9943
10762
  return visit(layers);
9944
10763
  }
9945
- /** 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). */
9946
10765
  litLayers(layers) {
9947
10766
  const thick = this.doc.thick;
9948
10767
  const live = this.liveThick;
9949
- if (!thick && !live) return layers;
10768
+ const ferro = this.doc.ferro;
10769
+ const liveFerro = this.liveFerro;
10770
+ if (!thick && !live && !ferro && !liveFerro) return layers;
9950
10771
  let out = layers;
9951
- 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 ?? [];
9952
10779
  for (const id of ids) {
9953
- const ch = live?.layerId === id ? live.channels : thick?.[id];
9954
- const l = ch && findLayer(out, id)?.layer;
9955
- 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;
9956
10788
  let cache = this.litCaches.get(id);
9957
10789
  if (!cache) this.litCaches.set(id, cache = new LitCache());
9958
- 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);
9959
10791
  if (lit !== l.surface) out = mapLayer(out, id, () => ({ ...l, surface: lit }));
9960
10792
  }
9961
10793
  return out;
@@ -10044,13 +10876,13 @@ var PaintEditorController = class _PaintEditorController {
10044
10876
  }
10045
10877
  /** The canvas (or some layers), scaled so the longest side is at most `maxSide`. */
10046
10878
  renderPreview(options = {}) {
10047
- const full2 = this.compositePixels({ ...options, wet: options.wet ?? "display" });
10879
+ const full3 = this.compositePixels({ ...options, wet: options.wet ?? "display" });
10048
10880
  const side = Math.max(1, Math.round(options.maxSide ?? 512));
10049
- const k = Math.min(1, side / Math.max(full2.width, full2.height));
10050
- if (k >= 1) return full2;
10051
- const w = Math.max(1, Math.round(full2.width * k));
10052
- const h = Math.max(1, Math.round(full2.height * k));
10053
- 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) };
10054
10886
  }
10055
10887
  // --- Save, load and export -------------------------------------------------------
10056
10888
  /** The document as JSON with inline PNG assets. */
@@ -10166,6 +10998,16 @@ var PaintEditorController = class _PaintEditorController {
10166
10998
  const spills = Object.fromEntries(Object.entries(this.lightSpills).filter(([id]) => findLayer(this.doc.layers, id)));
10167
10999
  if (Object.keys(spills).length) ext.lightSpill = spills;
10168
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;
10169
11011
  const effectLayers = effectLayersToJSON(this.doc.effectLayers ?? {}, (id) => isPixelLayer(findLayer(this.doc.layers, id)?.layer));
10170
11012
  if (effectLayers) ext.effectLayers = effectLayers;
10171
11013
  else delete ext.effectLayers;
@@ -10303,6 +11145,25 @@ var PaintEditorController = class _PaintEditorController {
10303
11145
  if (heat) state = { ...state, heat };
10304
11146
  else problems.push("The heat field could not be read; the canvas loads cool.");
10305
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
+ }
10306
11167
  const raw = ext.layerChannels;
10307
11168
  if (!raw || typeof raw !== "object") return state;
10308
11169
  const thick = {};
@@ -10337,6 +11198,11 @@ var PaintEditorController = class _PaintEditorController {
10337
11198
  this.redoStack = [];
10338
11199
  this.resetMutations();
10339
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
+ }
10340
11206
  for (const list of Object.values(this.doc.liveStrokes ?? {})) {
10341
11207
  for (const s of list) {
10342
11208
  const n = Number(/^s(\d+)$/.exec(s.id)?.[1] ?? 0);
@@ -10347,6 +11213,8 @@ var PaintEditorController = class _PaintEditorController {
10347
11213
  delete this.extensions.layerChannels;
10348
11214
  delete this.extensions.light;
10349
11215
  delete this.extensions.lightSpill;
11216
+ delete this.extensions.ferro;
11217
+ delete this.extensions.magnets;
10350
11218
  delete this.extensions.lighting;
10351
11219
  delete this.extensions.stickers;
10352
11220
  delete this.extensions.liveStrokes;
@@ -10387,13 +11255,13 @@ var PaintEditorController = class _PaintEditorController {
10387
11255
  return encodePng(img.width, img.height, img.data);
10388
11256
  }
10389
11257
  scaled(options) {
10390
- const full2 = this.compositePixels(options);
11258
+ const full3 = this.compositePixels(options);
10391
11259
  const s = options.scale ?? 1;
10392
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).`);
10393
- if (s === 1) return full2;
10394
- const w = Math.max(1, Math.round(full2.width * s));
10395
- const h = Math.max(1, Math.round(full2.height * s));
10396
- 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) };
10397
11265
  }
10398
11266
  /**
10399
11267
  * The canvas as PNG, JPEG or WebP bytes. JPEG has no alpha, so it always includes the paper.
@@ -11596,11 +12464,11 @@ var PaintEditorController = class _PaintEditorController {
11596
12464
  width,
11597
12465
  height
11598
12466
  ) ?? { x: 0, y: 0, width, height };
11599
- const full2 = this.compositePixels({ wet: "display" });
12467
+ const full3 = this.compositePixels({ wet: "display" });
11600
12468
  const cropData = new Uint8ClampedArray(cropRect.width * cropRect.height * 4);
11601
12469
  for (let y = 0; y < cropRect.height; y++) {
11602
12470
  const srcRow = ((cropRect.y + y) * width + cropRect.x) * 4;
11603
- 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);
11604
12472
  }
11605
12473
  const cropPngBase64 = bytesToBase64(encodePng(cropRect.width, cropRect.height, cropData));
11606
12474
  const preview = this.renderPreview({ wet: "display", maxSide: options.maxSide ?? 512 });
@@ -12231,6 +13099,19 @@ function withLayerThick(thick, layerId, next) {
12231
13099
  else delete out[layerId];
12232
13100
  return out;
12233
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
+ }
12234
13115
  var BACKLIGHT_LAYER_ID = "__backlight";
12235
13116
  function insertAfter(layers, id, node) {
12236
13117
  const i = layers.findIndex((l) => l.id === id);
@@ -12272,6 +13153,7 @@ function shiftDocument(doc, dx, dy) {
12272
13153
  for (const l of flattenLayers(doc.layers)) if (l.type === "pixel") layers = mapLayer(layers, l.id, () => ({ ...l, offset: movePoint(l.offset, dx, dy) }));
12273
13154
  const m = [1, 0, 0, 1, dx, dy];
12274
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 }));
12275
13157
  const out = {
12276
13158
  ...doc,
12277
13159
  layers,
@@ -12287,11 +13169,14 @@ function shiftDocument(doc, dx, dy) {
12287
13169
  return s.stencils ? { ...moved, stencils: s.stencils.map((x) => translateStencil(x, dx, dy)) } : moved;
12288
13170
  };
12289
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);
12290
13173
  if (doc.liveStrokes) out.liveStrokes = Object.fromEntries(Object.entries(doc.liveStrokes).map(([id, list]) => [id, list.map(moveStroke)]));
12291
13174
  if (doc.paintEvents) {
12292
13175
  out.paintEvents = doc.paintEvents.map((e) => {
12293
13176
  const list = e.kind === "stickers" ? e.data?.stickers : void 0;
12294
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) } };
12295
13180
  const strokes = e.kind === "live-strokes" ? e.data?.strokes : void 0;
12296
13181
  if (strokes) return { ...e, data: { ...e.data, strokes: strokes.map(moveStroke) } };
12297
13182
  if (e.kind === "fill-region" && e.data) {
@@ -12647,7 +13532,7 @@ function unionRect2(a, b) {
12647
13532
 
12648
13533
  // src/paint-editor/playback.ts
12649
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)$/;
12650
- 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)$/;
12651
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)$/;
12652
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)$/;
12653
13538
  var COLOR = /^(set_color|set_paint|color_harmony|add_swatch|remove_swatch|load_mixer_brush|clean_mixer_brush)$/;
@@ -13838,8 +14723,111 @@ function createPaintThickTools(c) {
13838
14723
  ];
13839
14724
  }
13840
14725
 
13841
- // src/paint-editor/replay-tools.ts
14726
+ // src/paint-editor/ferro-tools.ts
13842
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 };
13843
14831
  function createPaintReplayTools(c) {
13844
14832
  return [
13845
14833
  {
@@ -13858,7 +14846,7 @@ function createPaintReplayTools(c) {
13858
14846
  },
13859
14847
  additionalProperties: false
13860
14848
  },
13861
- annotations: { title: "Export a time-lapse", ...READ5 },
14849
+ annotations: { title: "Export a time-lapse", ...READ6 },
13862
14850
  handler: async (a) => {
13863
14851
  await c.wetIdle();
13864
14852
  const tl = await c.timeLapse({ frames: a.frames, fps: a.fps, maxSide: a.maxSide, wetStepsPerFrame: a.wetStepsPerFrame, holdMs: a.holdMs });
@@ -13890,7 +14878,7 @@ function createPaintReplayTools(c) {
13890
14878
  required: ["scale"],
13891
14879
  additionalProperties: false
13892
14880
  },
13893
- annotations: { title: "Export at a higher resolution", ...READ5 },
14881
+ annotations: { title: "Export at a higher resolution", ...READ6 },
13894
14882
  handler: async ({ scale, paper }) => {
13895
14883
  await c.wetIdle();
13896
14884
  const img = await c.renderHighRes({ scale, paper });
@@ -13902,9 +14890,9 @@ function createPaintReplayTools(c) {
13902
14890
  }
13903
14891
 
13904
14892
  // src/paint-editor/mutation-tools.ts
13905
- var READ6 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13906
- var WRITE5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13907
- 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 };
13908
14896
  var POINTS = {
13909
14897
  type: "array",
13910
14898
  minItems: 1,
@@ -13943,7 +14931,7 @@ function createMutationTools(c, options = {}) {
13943
14931
  },
13944
14932
  additionalProperties: false
13945
14933
  },
13946
- annotations: { title: "List pending mutations", ...READ6 },
14934
+ annotations: { title: "List pending mutations", ...READ7 },
13947
14935
  coordinates: { out: ["mutations[].bounds"] },
13948
14936
  handler: (a) => ({ mutations: c.listMutations({ includeAll: Boolean(a.includeAll) }) })
13949
14937
  },
@@ -13974,7 +14962,7 @@ function createMutationTools(c, options = {}) {
13974
14962
  required: ["id"],
13975
14963
  additionalProperties: false
13976
14964
  },
13977
- annotations: { title: "Read and claim a mutation", ...WRITE5 },
14965
+ annotations: { title: "Read and claim a mutation", ...WRITE6 },
13978
14966
  coordinates: { out: ["bounds", "strokes[].bounds", "strokes[].points[]", "cropPng.rect"] },
13979
14967
  handler: (a) => c.readMutation(a.id, {
13980
14968
  actor: options.actor?.id,
@@ -14051,7 +15039,7 @@ function createMutationTools(c, options = {}) {
14051
15039
  required: ["id", "strokes"],
14052
15040
  additionalProperties: false
14053
15041
  },
14054
- annotations: { title: "Answer a mutation with strokes", ...WRITE5 },
15042
+ annotations: { title: "Answer a mutation with strokes", ...WRITE6 },
14055
15043
  coordinates: { in: ["strokes[].points[]"], out: ["bounds"] },
14056
15044
  handler: async (a) => {
14057
15045
  await c.wetIdle();
@@ -14105,7 +15093,7 @@ function createMutationTools(c, options = {}) {
14105
15093
  required: ["id"],
14106
15094
  additionalProperties: false
14107
15095
  },
14108
- annotations: { title: "Decline a mutation", ...SET4 },
15096
+ annotations: { title: "Decline a mutation", ...SET5 },
14109
15097
  coordinates: { out: ["bounds"] },
14110
15098
  handler: (a) => c.declineMutation(a.id, a.reason)
14111
15099
  }
@@ -14113,8 +15101,8 @@ function createMutationTools(c, options = {}) {
14113
15101
  }
14114
15102
 
14115
15103
  // src/paint-editor/evolution-tools.ts
14116
- var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
14117
- 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 };
14118
15106
  var UNIT2 = (description) => ({ type: "number", minimum: 0, maximum: 1, description });
14119
15107
  var EVOLUTION_MUTATION_SCHEMA = {
14120
15108
  type: "object",
@@ -14159,7 +15147,7 @@ function createPaintEvolutionTools(c) {
14159
15147
  required: ["strokeIds"],
14160
15148
  additionalProperties: false
14161
15149
  },
14162
- annotations: { title: "Breed stroke variants", ...WRITE6 },
15150
+ annotations: { title: "Breed stroke variants", ...WRITE7 },
14163
15151
  coordinates: { out: ["proposals[].bounds"] },
14164
15152
  handler: ({ strokeIds, count, mutation, seed }, context) => {
14165
15153
  const actor = context?.actor?.id;
@@ -14202,7 +15190,7 @@ function createPaintEvolutionTools(c) {
14202
15190
  required: ["proposalIds"],
14203
15191
  additionalProperties: false
14204
15192
  },
14205
- annotations: { title: "Breed from paint proposals", ...WRITE6 },
15193
+ annotations: { title: "Breed from paint proposals", ...WRITE7 },
14206
15194
  coordinates: { out: ["proposals[].bounds"] },
14207
15195
  handler: ({ proposalIds, count, mutation, seed }, context) => {
14208
15196
  const actor = context?.actor?.id;
@@ -14230,7 +15218,7 @@ function createPaintEvolutionTools(c) {
14230
15218
  required: ["proposalId"],
14231
15219
  additionalProperties: false
14232
15220
  },
14233
- annotations: { title: "Get a proposal\u2019s stroke genome", ...READ7 },
15221
+ annotations: { title: "Get a proposal\u2019s stroke genome", ...READ8 },
14234
15222
  coordinates: { out: ["bounds", "strokes[].points[]"] },
14235
15223
  handler: ({ proposalId }) => c.getProposalGenome(proposalId)
14236
15224
  }
@@ -14249,13 +15237,13 @@ function createPaintEvolutionToolProvider(controller, options = {}) {
14249
15237
  }
14250
15238
 
14251
15239
  // src/paint-editor/tools.ts
14252
- var READ8 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
14253
- var WRITE7 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
14254
- 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 };
14255
15243
  var DESTRUCTIVE3 = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
14256
- var EMPTY3 = { type: "object", properties: {}, additionalProperties: false };
15244
+ var EMPTY4 = { type: "object", properties: {}, additionalProperties: false };
14257
15245
  var COLOR3 = { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: 'Colour #rrggbb, e.g. "#2a4b8d".' };
14258
- 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)." };
14259
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)." };
14260
15248
  var POINTS2 = {
14261
15249
  type: "array",
@@ -14298,8 +15286,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14298
15286
  name: "get_canvas_info",
14299
15287
  title: "Get the canvas",
14300
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.",
14301
- inputSchema: EMPTY3,
14302
- annotations: { title: "Get the canvas", ...READ8 },
15289
+ inputSchema: EMPTY4,
15290
+ annotations: { title: "Get the canvas", ...READ9 },
14303
15291
  coordinates: { out: ["bounds"] },
14304
15292
  handler: () => {
14305
15293
  const s = c.getState();
@@ -14360,7 +15348,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14360
15348
  },
14361
15349
  additionalProperties: false
14362
15350
  },
14363
- annotations: { title: "Resize the canvas", ...SET5 },
15351
+ annotations: { title: "Resize the canvas", ...SET6 },
14364
15352
  handler: (a) => c.setCanvas({ width: a.width, height: a.height, dpi: a.dpi })
14365
15353
  });
14366
15354
  const EXTEND_SIDE = (side) => ({ type: "integer", minimum: 0, maximum: MAX_CANVAS_SIDE - 1, description: `Pixels to add on the ${side} (default 0).` });
@@ -14373,15 +15361,15 @@ function createPaintEditorToolProvider(controller, options = {}) {
14373
15361
  properties: { left: EXTEND_SIDE("left"), top: EXTEND_SIDE("top"), right: EXTEND_SIDE("right"), bottom: EXTEND_SIDE("bottom") },
14374
15362
  additionalProperties: false
14375
15363
  },
14376
- annotations: { title: "Extend the canvas", ...WRITE7 },
15364
+ annotations: { title: "Extend the canvas", ...WRITE8 },
14377
15365
  handler: (a) => c.extendCanvas({ left: a.left, top: a.top, right: a.right, bottom: a.bottom })
14378
15366
  });
14379
15367
  add({
14380
15368
  name: "list_papers",
14381
15369
  title: "List papers",
14382
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.",
14383
- inputSchema: EMPTY3,
14384
- annotations: { title: "List papers", ...READ8 },
15371
+ inputSchema: EMPTY4,
15372
+ annotations: { title: "List papers", ...READ9 },
14385
15373
  handler: () => ({
14386
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 })),
14387
15375
  families: [...PAPER_FAMILIES],
@@ -14392,8 +15380,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14392
15380
  name: "get_paper",
14393
15381
  title: "Get the paper",
14394
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).",
14395
- inputSchema: EMPTY3,
14396
- annotations: { title: "Get the paper", ...READ8 },
15383
+ inputSchema: EMPTY4,
15384
+ annotations: { title: "Get the paper", ...READ9 },
14397
15385
  handler: () => JSON.parse(JSON.stringify(c.document.paper))
14398
15386
  });
14399
15387
  const tuning = {
@@ -14431,15 +15419,15 @@ function createPaintEditorToolProvider(controller, options = {}) {
14431
15419
  },
14432
15420
  additionalProperties: false
14433
15421
  },
14434
- annotations: { title: "Set the paper", ...SET5 },
15422
+ annotations: { title: "Set the paper", ...SET6 },
14435
15423
  handler: (a) => c.setPaper(a)
14436
15424
  });
14437
15425
  add({
14438
15426
  name: "list_layers",
14439
15427
  title: "List layers",
14440
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.",
14441
- inputSchema: EMPTY3,
14442
- annotations: { title: "List layers", ...READ8 },
15429
+ inputSchema: EMPTY4,
15430
+ annotations: { title: "List layers", ...READ9 },
14443
15431
  coordinates: { out: ["layers[].bounds"] },
14444
15432
  handler: () => ({ layers: c.listLayers(), activeLayerId: c.document.activeLayerId })
14445
15433
  });
@@ -14457,7 +15445,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14457
15445
  },
14458
15446
  additionalProperties: false
14459
15447
  },
14460
- annotations: { title: "Add a layer", ...WRITE7 },
15448
+ annotations: { title: "Add a layer", ...WRITE8 },
14461
15449
  coordinates: { out: ["bounds"] },
14462
15450
  handler: (a) => c.addLayer({ name: a.name, type: a.type, aboveId: a.aboveId, parentId: a.parentId })
14463
15451
  });
@@ -14493,7 +15481,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14493
15481
  required: ["layerId"],
14494
15482
  additionalProperties: false
14495
15483
  },
14496
- annotations: { title: "Change a layer", ...SET5 },
15484
+ annotations: { title: "Change a layer", ...SET6 },
14497
15485
  coordinates: { out: ["bounds"] },
14498
15486
  handler: ({ layerId, ...u }) => c.updateLayer(layerId, u)
14499
15487
  });
@@ -14501,8 +15489,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14501
15489
  name: "list_blend_modes",
14502
15490
  title: "List blend modes",
14503
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.",
14504
- inputSchema: EMPTY3,
14505
- annotations: { title: "List blend modes", ...READ8 },
15492
+ inputSchema: EMPTY4,
15493
+ annotations: { title: "List blend modes", ...READ9 },
14506
15494
  handler: () => ({
14507
15495
  modes: BLEND_MODES.map((m) => ({
14508
15496
  id: m.id,
@@ -14518,7 +15506,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14518
15506
  title: "Choose the active layer",
14519
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.",
14520
15508
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers." } }, required: ["layerId"], additionalProperties: false },
14521
- annotations: { title: "Choose the active layer", ...SET5 },
15509
+ annotations: { title: "Choose the active layer", ...SET6 },
14522
15510
  coordinates: { out: ["bounds"] },
14523
15511
  handler: ({ layerId }) => {
14524
15512
  c.setActiveLayer(layerId);
@@ -14538,7 +15526,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14538
15526
  title: "Duplicate a layer",
14539
15527
  description: "Copies a layer (a group with its children) directly above it; the copy becomes active. One undo step. Returns the copy.",
14540
15528
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers." } }, required: ["layerId"], additionalProperties: false },
14541
- annotations: { title: "Duplicate a layer", ...WRITE7 },
15529
+ annotations: { title: "Duplicate a layer", ...WRITE8 },
14542
15530
  coordinates: { out: ["bounds"] },
14543
15531
  handler: ({ layerId }) => c.duplicateLayer(layerId)
14544
15532
  });
@@ -14556,7 +15544,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14556
15544
  required: ["layerId", "index"],
14557
15545
  additionalProperties: false
14558
15546
  },
14559
- annotations: { title: "Reorder a layer", ...WRITE7 },
15547
+ annotations: { title: "Reorder a layer", ...WRITE8 },
14560
15548
  coordinates: { out: ["bounds"] },
14561
15549
  handler: ({ layerId, index, parentId }) => c.moveLayer(layerId, index, parentId)
14562
15550
  });
@@ -14576,7 +15564,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14576
15564
  inputSchema: {
14577
15565
  type: "object",
14578
15566
  properties: {
14579
- layerId: LAYER_ID4,
15567
+ layerId: LAYER_ID5,
14580
15568
  color: { ...COLOR3, description: "Fill colour #rrggbb (default the paint colour)." },
14581
15569
  opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0..1 (default 1)." },
14582
15570
  grain: { type: "number", minimum: 0, maximum: 1, description: "Paper grain response 0..1 (default 0: flat)." },
@@ -14585,7 +15573,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14585
15573
  },
14586
15574
  additionalProperties: false
14587
15575
  },
14588
- annotations: { title: "Fill a layer", ...WRITE7 },
15576
+ annotations: { title: "Fill a layer", ...WRITE8 },
14589
15577
  coordinates: { out: ["bounds"] },
14590
15578
  handler: (a) => act({ kind: "fill", fill: a }, () => c.fillLayer(a))
14591
15579
  });
@@ -14613,7 +15601,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14613
15601
  additionalProperties: false
14614
15602
  }
14615
15603
  },
14616
- layerId: LAYER_ID4,
15604
+ layerId: LAYER_ID5,
14617
15605
  reference: {
14618
15606
  type: "string",
14619
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).'
@@ -14632,7 +15620,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14632
15620
  },
14633
15621
  additionalProperties: false
14634
15622
  },
14635
- annotations: { title: "Fill a region (bucket / flats)", ...WRITE7 },
15623
+ annotations: { title: "Fill a region (bucket / flats)", ...WRITE8 },
14636
15624
  coordinates: { in: ["", "points[]"], out: ["bounds", "regions[].point", "regions[].bounds"] },
14637
15625
  handler: (a) => act({ kind: "fill-region", fill: a }, () => c.fillRegion(a))
14638
15626
  });
@@ -14656,7 +15644,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14656
15644
  },
14657
15645
  additionalProperties: false
14658
15646
  },
14659
- annotations: { title: "Find enclosed unfilled regions", ...READ8 },
15647
+ annotations: { title: "Find enclosed unfilled regions", ...READ9 },
14660
15648
  coordinates: { out: ["regions[].point", "regions[].centroid", "regions[].bounds"] },
14661
15649
  handler: (a) => c.findUnfilledRegions(a)
14662
15650
  });
@@ -14664,7 +15652,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14664
15652
  name: "clear_layer",
14665
15653
  title: "Clear a layer",
14666
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).",
14667
- inputSchema: { type: "object", properties: { layerId: LAYER_ID4 }, additionalProperties: false },
15655
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID5 }, additionalProperties: false },
14668
15656
  annotations: { title: "Clear a layer", ...DESTRUCTIVE3 },
14669
15657
  coordinates: { out: ["bounds"] },
14670
15658
  handler: ({ layerId }) => c.clearLayer(layerId)
@@ -14674,7 +15662,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14674
15662
  title: "Get a brush",
14675
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.",
14676
15664
  inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." } }, additionalProperties: false },
14677
- annotations: { title: "Get a brush", ...READ8 },
15665
+ annotations: { title: "Get a brush", ...READ9 },
14678
15666
  handler: ({ id }) => {
14679
15667
  const st = c.getState();
14680
15668
  const b = id === void 0 ? st.brush : c.getBrush(id);
@@ -14688,7 +15676,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14688
15676
  title: "Set the brush",
14689
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).",
14690
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 },
14691
- annotations: { title: "Set the brush", ...SET5 },
15679
+ annotations: { title: "Set the brush", ...SET6 },
14692
15680
  handler: async ({ id, unset, ...patch }) => {
14693
15681
  const stamps = patch.tip?.stamps;
14694
15682
  if (stamps) await c.prepareStickers(stamps);
@@ -14709,7 +15697,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14709
15697
  },
14710
15698
  additionalProperties: false
14711
15699
  },
14712
- annotations: { title: "Set the paint colour", ...SET5 },
15700
+ annotations: { title: "Set the paint colour", ...SET6 },
14713
15701
  handler: ({ color, secondaryColor, locked }) => {
14714
15702
  if (color === void 0 && secondaryColor === void 0 && locked === void 0) throw new Error("Pass color, secondaryColor or locked.");
14715
15703
  if (color !== void 0) c.setColor(color);
@@ -14724,7 +15712,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14724
15712
  title: "Set the paint (colour or gradient)",
14725
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.',
14726
15714
  inputSchema: { type: "object", properties: { paint: PAINT_SOURCE_JSON_SCHEMA }, required: ["paint"], additionalProperties: false },
14727
- annotations: { title: "Set the paint (colour or gradient)", ...SET5 },
15715
+ annotations: { title: "Set the paint (colour or gradient)", ...SET6 },
14728
15716
  handler: ({ paint }) => {
14729
15717
  const p = c.setPaint(paint);
14730
15718
  const s = c.getState();
@@ -14745,7 +15733,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14745
15733
  required: ["x", "y"],
14746
15734
  additionalProperties: false
14747
15735
  },
14748
- annotations: { title: "Pick a colour", ...READ8 },
15736
+ annotations: { title: "Pick a colour", ...READ9 },
14749
15737
  coordinates: { in: [""] },
14750
15738
  handler: ({ x, y, layerId }) => c.pickColor(x, y, { layerId })
14751
15739
  });
@@ -14753,8 +15741,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
14753
15741
  name: "get_mixer_brush",
14754
15742
  title: "Get the Mixer Brush load",
14755
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.",
14756
- inputSchema: EMPTY3,
14757
- annotations: { title: "Get the Mixer Brush load", ...READ8 },
15744
+ inputSchema: EMPTY4,
15745
+ annotations: { title: "Get the Mixer Brush load", ...READ9 },
14758
15746
  handler: () => loadResult(c.getState().mixerLoad)
14759
15747
  });
14760
15748
  add({
@@ -14762,7 +15750,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14762
15750
  title: "Load the Mixer Brush",
14763
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.",
14764
15752
  inputSchema: { type: "object", properties: { color: { ...COLOR3, description: "Colour #rrggbb (default the paint colour)." } }, additionalProperties: false },
14765
- annotations: { title: "Load the Mixer Brush", ...SET5 },
15753
+ annotations: { title: "Load the Mixer Brush", ...SET6 },
14766
15754
  handler: ({ color }) => loadResult(c.loadMixerBrush(color))
14767
15755
  });
14768
15756
  add({
@@ -14770,7 +15758,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14770
15758
  title: "Clean the Mixer Brush",
14771
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.",
14772
15760
  inputSchema: { type: "object", properties: { emptyReservoir: { type: "boolean", description: "Also drop the loaded paint (default false)." } }, additionalProperties: false },
14773
- annotations: { title: "Clean the Mixer Brush", ...SET5 },
15761
+ annotations: { title: "Clean the Mixer Brush", ...SET6 },
14774
15762
  handler: ({ emptyReservoir }) => loadResult(c.cleanMixerBrush(emptyReservoir === true))
14775
15763
  });
14776
15764
  add({
@@ -14781,7 +15769,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14781
15769
  type: "object",
14782
15770
  properties: {
14783
15771
  points: POINTS2,
14784
- layerId: LAYER_ID4,
15772
+ layerId: LAYER_ID5,
14785
15773
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
14786
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 },
14787
15775
  color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
@@ -14792,7 +15780,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14792
15780
  required: ["points"],
14793
15781
  additionalProperties: false
14794
15782
  },
14795
- annotations: { title: "Paint a brush stroke", ...WRITE7 },
15783
+ annotations: { title: "Paint a brush stroke", ...WRITE8 },
14796
15784
  coordinates: { in: ["points[]"], out: ["bounds"] },
14797
15785
  handler: async (a) => {
14798
15786
  await c.wetIdle();
@@ -14819,7 +15807,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14819
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." },
14820
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." },
14821
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)." },
14822
- layerId: LAYER_ID4,
15810
+ layerId: LAYER_ID5,
14823
15811
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
14824
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 },
14825
15813
  color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
@@ -14829,7 +15817,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14829
15817
  required: ["path"],
14830
15818
  additionalProperties: false
14831
15819
  },
14832
- annotations: { title: "Brush along a path", ...WRITE7 },
15820
+ annotations: { title: "Brush along a path", ...WRITE8 },
14833
15821
  coordinates: { out: ["bounds"] },
14834
15822
  handler: async (a) => {
14835
15823
  await c.wetIdle();
@@ -14883,7 +15871,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14883
15871
  properties: { limit: { type: "integer", minimum: 1, maximum: 5e3, description: "Most recent samples to return (default 500)." } },
14884
15872
  additionalProperties: false
14885
15873
  },
14886
- annotations: { title: "Get the stroke being painted", ...READ8 },
15874
+ annotations: { title: "Get the stroke being painted", ...READ9 },
14887
15875
  handler: (args) => {
14888
15876
  const live = c.getLiveStroke();
14889
15877
  if (!live) return { live: false };
@@ -14905,7 +15893,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14905
15893
  },
14906
15894
  additionalProperties: false
14907
15895
  },
14908
- annotations: { title: "List recorded strokes", ...READ8 },
15896
+ annotations: { title: "List recorded strokes", ...READ9 },
14909
15897
  coordinates: { out: ["strokes[].bounds", "strokes[].points[]"] },
14910
15898
  handler: ({ ids, last, live, layerId }) => {
14911
15899
  if (layerId !== void 0 && !c.getLayer(layerId)) throw new Error(`Unknown layer "${layerId}". Call list_layers for the ids.`);
@@ -14934,7 +15922,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14934
15922
  required: ["x", "y"],
14935
15923
  additionalProperties: false
14936
15924
  },
14937
- annotations: { title: "Find the stroke at a point", ...READ8 },
15925
+ annotations: { title: "Find the stroke at a point", ...READ9 },
14938
15926
  coordinates: { in: [""], out: ["stroke.bounds"] },
14939
15927
  handler: ({ layerId, x, y }) => {
14940
15928
  const s = c.liveStrokeAt(layerId, x, y);
@@ -14958,7 +15946,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14958
15946
  required: ["id"],
14959
15947
  additionalProperties: false
14960
15948
  },
14961
- annotations: { title: "Change a stroke", ...WRITE7 },
15949
+ annotations: { title: "Change a stroke", ...WRITE8 },
14962
15950
  coordinates: { out: ["bounds", "stroke.bounds"] },
14963
15951
  handler: ({ id, ...update }) => {
14964
15952
  if (!Object.keys(update).length) throw new Error("Pass at least one change: brushId, brush, color, paint or seed.");
@@ -14997,7 +15985,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
14997
15985
  required: ["id"],
14998
15986
  additionalProperties: false
14999
15987
  },
15000
- annotations: { title: "Move or reshape a stroke", ...WRITE7 },
15988
+ annotations: { title: "Move or reshape a stroke", ...WRITE8 },
15001
15989
  coordinates: { in: ["points[]", "transform.origin"], out: ["bounds", "stroke.bounds", "stroke.points[]"] },
15002
15990
  handler: async ({ id, points, transform }) => {
15003
15991
  await c.prepareStrokeEdit(id, { points, transform });
@@ -15015,7 +16003,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15015
16003
  required: ["id", "index"],
15016
16004
  additionalProperties: false
15017
16005
  },
15018
- annotations: { title: "Move a stroke up or down", ...WRITE7 },
16006
+ annotations: { title: "Move a stroke up or down", ...WRITE8 },
15019
16007
  handler: ({ id, index }) => ({ order: c.reorderStroke(id, index) })
15020
16008
  });
15021
16009
  add({
@@ -15037,7 +16025,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15037
16025
  title: "Make a layer a stroke layer",
15038
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.',
15039
16027
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers (default the active layer)." } }, additionalProperties: false },
15040
- annotations: { title: "Make a layer a stroke layer", ...WRITE7 },
16028
+ annotations: { title: "Make a layer a stroke layer", ...WRITE8 },
15041
16029
  coordinates: { out: ["bounds"] },
15042
16030
  handler: ({ layerId }) => c.convertToLiveStrokes(layerId)
15043
16031
  });
@@ -15079,7 +16067,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15079
16067
  required: ["strokeIds"],
15080
16068
  additionalProperties: false
15081
16069
  },
15082
- annotations: { title: "Replay strokes, varied", ...WRITE7 },
16070
+ annotations: { title: "Replay strokes, varied", ...WRITE8 },
15083
16071
  coordinates: { in: ["origin"], out: ["bounds"] },
15084
16072
  handler: async (a) => {
15085
16073
  const replay = a;
@@ -15101,7 +16089,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15101
16089
  },
15102
16090
  additionalProperties: false
15103
16091
  },
15104
- annotations: { title: "Rotate, mirror or zoom the view", ...SET5 },
16092
+ annotations: { title: "Rotate, mirror or zoom the view", ...SET6 },
15105
16093
  handler: ({ reset, ...v }) => {
15106
16094
  if (reset) c.resetView();
15107
16095
  return c.setView(v);
@@ -15112,15 +16100,15 @@ function createPaintEditorToolProvider(controller, options = {}) {
15112
16100
  title: "Set the drawing hand",
15113
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.",
15114
16102
  inputSchema: { type: "object", properties: { hand: { type: "string", enum: ["right", "left"], description: "right or left." } }, required: ["hand"], additionalProperties: false },
15115
- annotations: { title: "Set the drawing hand", ...SET5 },
16103
+ annotations: { title: "Set the drawing hand", ...SET6 },
15116
16104
  handler: ({ hand }) => ({ hand: c.setHand(hand) })
15117
16105
  });
15118
16106
  add({
15119
16107
  name: "undo_paint",
15120
16108
  title: "Undo",
15121
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.",
15122
- inputSchema: EMPTY3,
15123
- annotations: { title: "Undo", ...WRITE7 },
16110
+ inputSchema: EMPTY4,
16111
+ annotations: { title: "Undo", ...WRITE8 },
15124
16112
  handler: () => {
15125
16113
  const undone = c.undo();
15126
16114
  const s = c.getState();
@@ -15131,8 +16119,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
15131
16119
  name: "redo_paint",
15132
16120
  title: "Redo",
15133
16121
  description: "Redoes the last undone step. Returns the label redone (null when there was nothing) and what can be undone and redone next.",
15134
- inputSchema: EMPTY3,
15135
- annotations: { title: "Redo", ...WRITE7 },
16122
+ inputSchema: EMPTY4,
16123
+ annotations: { title: "Redo", ...WRITE8 },
15136
16124
  handler: () => {
15137
16125
  const redone = c.redo();
15138
16126
  const s = c.getState();
@@ -15152,7 +16140,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15152
16140
  },
15153
16141
  additionalProperties: false
15154
16142
  },
15155
- annotations: { title: "Render a preview", ...READ8 },
16143
+ annotations: { title: "Render a preview", ...READ9 },
15156
16144
  handler: ({ maxSide, layerIds, paper }) => {
15157
16145
  if (layerIds) {
15158
16146
  for (const id of layerIds) if (!c.getLayer(id)) throw new Error(`Unknown layer "${id}". Call list_layers for the ids.`);
@@ -15176,7 +16164,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15176
16164
  },
15177
16165
  additionalProperties: false
15178
16166
  },
15179
- annotations: { title: "Export the painting", ...READ8 },
16167
+ annotations: { title: "Export the painting", ...READ9 },
15180
16168
  handler: async ({ format, quality, scale, paper, time }) => {
15181
16169
  const run = () => c.exportImage({ format, quality, scale, paper });
15182
16170
  const bytes = time === void 0 ? await run() : await c.withEffectTime(time, run);
@@ -15188,7 +16176,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15188
16176
  title: "Export as PSD",
15189
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).',
15190
16178
  inputSchema: { type: "object", properties: { time: TIME }, additionalProperties: false },
15191
- annotations: { title: "Export as PSD", ...READ8 },
16179
+ annotations: { title: "Export as PSD", ...READ9 },
15192
16180
  handler: async ({ time }) => {
15193
16181
  const r = time === void 0 ? c.exportPsd() : await c.withEffectTime(time, () => c.exportPsd());
15194
16182
  return { data: bytesToBase64(r.bytes), bytes: r.bytes.length, psb: r.psb, warnings: r.warnings };
@@ -15197,6 +16185,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
15197
16185
  tools.push(...createPaintGuideTools(c));
15198
16186
  tools.push(...createPaintWetTools(c));
15199
16187
  tools.push(...createPaintThickTools(c));
16188
+ tools.push(...createFerroTools(c));
15200
16189
  tools.push(...createPaintReplayTools(c));
15201
16190
  tools.push(...createPaintEvolutionTools(c));
15202
16191
  tools.push(...createPaintStickerTools(c));
@@ -15920,6 +16909,10 @@ var EVOLUTION_SLIDERS = [
15920
16909
  { path: "brush", label: "Brush", min: 0, max: 1, step: 0.05 },
15921
16910
  { path: "seed", label: "Seed", min: 0, max: 1, step: 0.05 }
15922
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
+ ];
15923
16916
  function quickStencil(kind, canvas) {
15924
16917
  const { width: w, height: h } = canvas;
15925
16918
  switch (kind) {
@@ -16205,8 +17198,8 @@ function createPaintEditorDocument(controller, options = {}) {
16205
17198
  }
16206
17199
 
16207
17200
  // src/paint-editor/collaboration-tools.ts
16208
- var READ9 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
16209
- 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 };
16210
17203
  var proposalIdArg = {
16211
17204
  type: "object",
16212
17205
  properties: { proposalId: { type: "string", minLength: 1, description: "Id of a pending paint proposal, from propose_paint or list_paint_proposals." } },
@@ -16284,7 +17277,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
16284
17277
  required: ["title", "ops"],
16285
17278
  additionalProperties: false
16286
17279
  },
16287
- annotations: { title: "Propose paint", ...WRITE8 },
17280
+ annotations: { title: "Propose paint", ...WRITE9 },
16288
17281
  handler: (args, context) => {
16289
17282
  const origin = context.actor?.id ? `agent:${context.actor.id}` : options.fallbackOrigin;
16290
17283
  const proposal = proposals.propose({ ...args, ...origin ? { origin } : {} });
@@ -16297,7 +17290,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
16297
17290
  title: "List paint proposals",
16298
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.",
16299
17292
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
16300
- annotations: { title: "List paint proposals", ...READ9 },
17293
+ annotations: { title: "List paint proposals", ...READ10 },
16301
17294
  handler: () => ({ proposals: proposals.getState().proposals.map(describe), readOnly: readOnlyReason() })
16302
17295
  };
16303
17296
  const acceptTool = {
@@ -16305,7 +17298,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
16305
17298
  title: "Accept a paint proposal",
16306
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.",
16307
17300
  inputSchema: proposalIdArg,
16308
- annotations: { title: "Accept a paint proposal", ...WRITE8 },
17301
+ annotations: { title: "Accept a paint proposal", ...WRITE9 },
16309
17302
  handler: (args) => {
16310
17303
  const reason = readOnlyReason();
16311
17304
  if (reason) throw new Error(reason);
@@ -16317,7 +17310,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
16317
17310
  title: "Reject a paint proposal",
16318
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.",
16319
17312
  inputSchema: proposalIdArg,
16320
- annotations: { title: "Reject a paint proposal", ...WRITE8 },
17313
+ annotations: { title: "Reject a paint proposal", ...WRITE9 },
16321
17314
  handler: (args) => {
16322
17315
  const p = proposals.reject(args.proposalId);
16323
17316
  return { proposalId: p.id, title: p.title, rejected: true };
@@ -16390,6 +17383,33 @@ var PAINT_DOCUMENT_JSON_SCHEMA = {
16390
17383
  required: ["id", "layerId", "points", "brush"]
16391
17384
  }
16392
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
+ },
16393
17413
  fluor: {
16394
17414
  type: "object",
16395
17415
  description: "Per-layer UV-fluorescent ink channels (layerId \u2192 bounds + PNG data URL).",
@@ -16440,7 +17460,7 @@ function uiTables() {
16440
17460
  brushOptions: BRUSH_OPTION_CONTROLS,
16441
17461
  blendMenu: BLEND_MENU_GROUPS,
16442
17462
  layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
16443
- 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 },
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 },
16444
17464
  pathStrokeTapers: PATH_STROKE_TAPERS,
16445
17465
  padSampleSizes: PAD_SAMPLE_SIZES,
16446
17466
  wetGpu: WET_GPU_OPTIONS,
@@ -16517,6 +17537,7 @@ export {
16517
17537
  DEFAULT_BRUSH_ID,
16518
17538
  DEFAULT_DPI,
16519
17539
  DEFAULT_DYNAMICS,
17540
+ DEFAULT_FERRO,
16520
17541
  DEFAULT_FLOW,
16521
17542
  DEFAULT_FLUORESCENCE,
16522
17543
  DEFAULT_GHOST,
@@ -16525,6 +17546,8 @@ export {
16525
17546
  DEFAULT_LIGHT,
16526
17547
  DEFAULT_LIGHTING,
16527
17548
  DEFAULT_LIVE_PATTERNS,
17549
+ DEFAULT_MAGNET_RADIUS,
17550
+ DEFAULT_MAGNET_STRENGTH,
16528
17551
  DEFAULT_MULTI_COLOR,
16529
17552
  DEFAULT_MUTATION,
16530
17553
  DEFAULT_MUTATION_RATES,
@@ -16552,6 +17575,10 @@ export {
16552
17575
  EVOLUTION_BRUSH_FACETS,
16553
17576
  EVOLUTION_MUTATION_SCHEMA,
16554
17577
  EVOLUTION_SLIDERS,
17578
+ FERRO_CARRIER_RGB,
17579
+ FERRO_DEFAULT_GLOSS,
17580
+ FERRO_DEFAULT_METALLIC,
17581
+ FERRO_DEFAULT_RIM,
16555
17582
  FilmBuffer,
16556
17583
  GHOST_SLIDERS,
16557
17584
  GHOST_TRIGGERS,
@@ -16576,6 +17603,7 @@ export {
16576
17603
  LIVE_PATTERN_SLIDERS,
16577
17604
  LIVE_STROKE_ENGINES,
16578
17605
  LitCache,
17606
+ MAGNET_SLIDERS,
16579
17607
  MAX_BRUSH_SIZE,
16580
17608
  MAX_CANVAS_SIDE,
16581
17609
  MAX_COORDINATE,
@@ -16688,9 +17716,11 @@ export {
16688
17716
  addPaintGradientStop,
16689
17717
  addPolygonArea,
16690
17718
  advanceHeatField,
17719
+ advectFerro,
16691
17720
  affineCopy,
16692
17721
  applyAffine,
16693
17722
  backlightOf,
17723
+ bakeFerroIntoThick,
16694
17724
  brushEngineControls,
16695
17725
  brushLoadColor,
16696
17726
  brushLoadForStroke,
@@ -16707,6 +17737,7 @@ export {
16707
17737
  computeUvSpill,
16708
17738
  contactSheet,
16709
17739
  createEvolutionTools,
17740
+ createFerroTools,
16710
17741
  createHeatField,
16711
17742
  createMutationTools,
16712
17743
  createPaintAgentStrokes,
@@ -16751,11 +17782,20 @@ export {
16751
17782
  effectPaintKey,
16752
17783
  effectPaintSize,
16753
17784
  emptiedLayers,
17785
+ emptyFerro,
16754
17786
  emptyFluor,
16755
17787
  emptyThermo,
16756
17788
  emptyThickChannels,
16757
17789
  engineBrushSourceIds,
16758
17790
  exportProposalContactSheetPng,
17791
+ ferroCacheKey,
17792
+ ferroFieldAt,
17793
+ ferroFromJSON,
17794
+ ferroInfo,
17795
+ ferroSpikeHeightAt,
17796
+ ferroToJSON,
17797
+ ferroToShadow,
17798
+ ferroTwinBrush,
16759
17799
  fillRegionMask,
16760
17800
  finalizeThermoStroke,
16761
17801
  findUnfilledRegionsMask,
@@ -16792,7 +17832,9 @@ export {
16792
17832
  lightingKey,
16793
17833
  liveStrokeBounds,
16794
17834
  liveStrokeRefusal,
17835
+ magnetsFromJSON,
16795
17836
  mapPenPoint,
17837
+ mergeFerro,
16796
17838
  mergeFourChannel,
16797
17839
  mix32,
16798
17840
  mixColorOklch,
@@ -16814,6 +17856,8 @@ export {
16814
17856
  normalizeBrushStroke,
16815
17857
  normalizeCanvas,
16816
17858
  normalizeDynamics,
17859
+ normalizeFerro,
17860
+ normalizeFerro as normalizeFerroSettings,
16817
17861
  normalizeFlow,
16818
17862
  normalizeFluorescence,
16819
17863
  normalizeGhost,
@@ -16822,6 +17866,7 @@ export {
16822
17866
  normalizeLight,
16823
17867
  normalizeLighting,
16824
17868
  normalizeLivePatterns,
17869
+ normalizeMagnet,
16825
17870
  normalizeMultiColor,
16826
17871
  normalizeMutation,
16827
17872
  normalizeMutationRates,
@@ -16903,6 +17948,7 @@ export {
16903
17948
  sampleHeatAt,
16904
17949
  samplePath,
16905
17950
  samplePressure,
17951
+ scaleFerro,
16906
17952
  scaleFluor,
16907
17953
  scalePaintGradient,
16908
17954
  scaleStencil,
@@ -16911,6 +17957,7 @@ export {
16911
17957
  seededStrokeVariation,
16912
17958
  serializePaintDocument,
16913
17959
  serializePaintDocumentExternal,
17960
+ shadowToFerro,
16914
17961
  shiftHeatField,
16915
17962
  solidPaint,
16916
17963
  squaredDistanceTransform,