@pixerate/editor 0.274.0 → 0.275.1

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 (86) hide show
  1. package/dist/animation-editor/index.cjs +60 -60
  2. package/dist/animation-editor/index.js +4 -4
  3. package/dist/brushes/index.cjs +102 -74
  4. package/dist/brushes/index.js +34 -6
  5. package/dist/brushes/patch.d.cts +1 -1
  6. package/dist/brushes/patch.d.ts +1 -1
  7. package/dist/brushes/preview.d.cts +3 -0
  8. package/dist/brushes/preview.d.ts +3 -0
  9. package/dist/brushes/profiles.d.cts +2 -0
  10. package/dist/brushes/profiles.d.ts +2 -0
  11. package/dist/brushes/schema.d.cts +4 -0
  12. package/dist/brushes/schema.d.ts +4 -0
  13. package/dist/brushes/types.d.cts +47 -0
  14. package/dist/brushes/types.d.ts +47 -0
  15. package/dist/{brushstrokeRender-5YBCLFDQ.cjs → brushstrokeRender-2RQL46WB.cjs} +15 -15
  16. package/dist/{brushstrokeRender-DZGFU3J4.js → brushstrokeRender-WGY4DZHF.js} +3 -3
  17. package/dist/canvas/index.cjs +13 -13
  18. package/dist/canvas/index.js +1 -1
  19. package/dist/chart/contract.d.cts +3 -1
  20. package/dist/chart/contract.d.ts +3 -1
  21. package/dist/{chunk-2XDNF73E.js → chunk-43XJC3PP.js} +1 -1
  22. package/dist/{chunk-GHGBIAWQ.cjs → chunk-6QNDGMK7.cjs} +263 -45
  23. package/dist/{chunk-2F3YDXCN.js → chunk-75SYKUOT.js} +1 -1
  24. package/dist/{chunk-M3RZOF3G.cjs → chunk-C6VB7CPY.cjs} +1 -1
  25. package/dist/{chunk-N5IMDAHM.cjs → chunk-DHC4E2DU.cjs} +89 -37
  26. package/dist/{chunk-NAHJV3O6.js → chunk-G5TCMKX5.js} +28 -11
  27. package/dist/{chunk-DZVRH63Y.js → chunk-IEEY5RJV.js} +1 -1
  28. package/dist/{chunk-7W2EQQBP.cjs → chunk-JDXWLCXF.cjs} +6 -6
  29. package/dist/{chunk-N3SKPQEH.js → chunk-LMG57L5A.js} +62 -10
  30. package/dist/{chunk-5HP2VSAS.js → chunk-MSWLJI4G.js} +1 -1
  31. package/dist/{chunk-SNAFFCAX.cjs → chunk-OLZXXAER.cjs} +221 -196
  32. package/dist/{chunk-YJANF2BL.cjs → chunk-RAPNRK4R.cjs} +86 -86
  33. package/dist/{chunk-CLB2I2FU.js → chunk-RJGW3RQS.js} +2 -2
  34. package/dist/{chunk-FXVCB6YG.cjs → chunk-T6KNJVBP.cjs} +35 -35
  35. package/dist/{chunk-ZEI7ZEA4.js → chunk-Y2ISSVPH.js} +34 -9
  36. package/dist/{chunk-3JKCREEU.cjs → chunk-Y354ETAE.cjs} +76 -59
  37. package/dist/{chunk-TNLKFW7D.cjs → chunk-YYZC2EQO.cjs} +23 -23
  38. package/dist/{chunk-UXYCY3HP.js → chunk-ZM7IKQ4X.js} +239 -21
  39. package/dist/image-editor/index.cjs +789 -789
  40. package/dist/image-editor/index.js +5 -5
  41. package/dist/paint/index.cjs +2 -2
  42. package/dist/paint/index.js +1 -1
  43. package/dist/paint/wet/world.d.cts +15 -2
  44. package/dist/paint/wet/world.d.ts +15 -2
  45. package/dist/paint-editor/brush-controls.d.cts +7 -0
  46. package/dist/paint-editor/brush-controls.d.ts +7 -0
  47. package/dist/paint-editor/brush-model.d.cts +59 -1
  48. package/dist/paint-editor/brush-model.d.ts +59 -1
  49. package/dist/paint-editor/controller.d.cts +168 -1
  50. package/dist/paint-editor/controller.d.ts +168 -1
  51. package/dist/paint-editor/dab-stroke.d.cts +3 -0
  52. package/dist/paint-editor/dab-stroke.d.ts +3 -0
  53. package/dist/paint-editor/document.d.cts +13 -3
  54. package/dist/paint-editor/document.d.ts +13 -3
  55. package/dist/paint-editor/engine.d.cts +3 -0
  56. package/dist/paint-editor/engine.d.ts +3 -0
  57. package/dist/paint-editor/heat.d.cts +156 -0
  58. package/dist/paint-editor/heat.d.ts +156 -0
  59. package/dist/paint-editor/index.cjs +3386 -855
  60. package/dist/paint-editor/index.d.cts +3 -0
  61. package/dist/paint-editor/index.d.ts +3 -0
  62. package/dist/paint-editor/index.js +2731 -200
  63. package/dist/paint-editor/mutation-tools.d.cts +13 -0
  64. package/dist/paint-editor/mutation-tools.d.ts +13 -0
  65. package/dist/paint-editor/mutation.d.cts +67 -0
  66. package/dist/paint-editor/mutation.d.ts +67 -0
  67. package/dist/paint-editor/presence.d.cts +7 -1
  68. package/dist/paint-editor/presence.d.ts +7 -1
  69. package/dist/paint-editor/procedural.d.cts +3 -0
  70. package/dist/paint-editor/procedural.d.ts +3 -0
  71. package/dist/paint-editor/stroke.d.cts +32 -2
  72. package/dist/paint-editor/stroke.d.ts +32 -2
  73. package/dist/paint-editor/thick.d.cts +48 -7
  74. package/dist/paint-editor/thick.d.ts +48 -7
  75. package/dist/paint-editor/ui.d.cts +34 -1
  76. package/dist/paint-editor/ui.d.ts +34 -1
  77. package/dist/paint-editor/wet.d.cts +8 -1
  78. package/dist/paint-editor/wet.d.ts +8 -1
  79. package/dist/vector/index.cjs +999 -999
  80. package/dist/vector/index.js +5 -5
  81. package/package.json +1 -1
  82. package/skills/pixerate-brushes/SKILL.md +1 -0
  83. package/skills/pixerate-paint-editor/SKILL.md +7 -1
  84. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  85. package/skills/pixerate-paint-editor/references/live-patterns.md +41 -0
  86. package/skills/pixerate-paint-watercolour/SKILL.md +8 -0
@@ -7,12 +7,14 @@ import {
7
7
  BRUSH_SLIDERS,
8
8
  BRUSH_UNSET_PROPERTY,
9
9
  GHOST_SLIDERS,
10
+ IRIDESCENCE_SLIDERS,
11
+ MUTATION_SLIDERS,
10
12
  brushEngineControls,
11
13
  createBrushLibraryToolProvider,
12
14
  mergeBrushPatch,
13
15
  multiColorSliders,
14
16
  patchBrushValue
15
- } from "../chunk-NAHJV3O6.js";
17
+ } from "../chunk-G5TCMKX5.js";
16
18
  import {
17
19
  createStickerTools,
18
20
  describeStickerRef
@@ -20,7 +22,7 @@ import {
20
22
  import {
21
23
  BRUSH_PREVIEW_DARK_PAPER,
22
24
  brushPreviewsOnDark
23
- } from "../chunk-CLB2I2FU.js";
25
+ } from "../chunk-RJGW3RQS.js";
24
26
  import {
25
27
  toVideoFfmpeg
26
28
  } from "../chunk-S2XPXA47.js";
@@ -40,7 +42,7 @@ import {
40
42
  resolveExternalAssets,
41
43
  serializeLayers,
42
44
  serializeLayersExternal
43
- } from "../chunk-5HP2VSAS.js";
45
+ } from "../chunk-MSWLJI4G.js";
44
46
  import "../chunk-ADMRNWR7.js";
45
47
  import "../chunk-WJ4WQJCC.js";
46
48
  import {
@@ -123,7 +125,7 @@ import {
123
125
  wetPaperInput,
124
126
  wetPigmentOf,
125
127
  withStrokePath
126
- } from "../chunk-ZEI7ZEA4.js";
128
+ } from "../chunk-Y2ISSVPH.js";
127
129
  import {
128
130
  BRUSH_ID,
129
131
  BRUSH_PRESETS,
@@ -135,17 +137,22 @@ import {
135
137
  DEFAULT_BRISTLE,
136
138
  DEFAULT_BRUSH_ID,
137
139
  DEFAULT_DYNAMICS,
140
+ DEFAULT_FLUORESCENCE,
138
141
  DEFAULT_GHOST,
139
142
  DEFAULT_IMPASTO,
143
+ DEFAULT_IRIDESCENCE,
140
144
  DEFAULT_LIGHT,
141
145
  DEFAULT_MULTI_COLOR,
146
+ DEFAULT_MUTATION,
142
147
  DEFAULT_PARTICLE,
143
148
  DEFAULT_SPRAY,
144
149
  DEFAULT_STICKER,
150
+ DEFAULT_THERMOCHROMIC,
145
151
  DEFAULT_TIP_GRAIN,
146
152
  DEFAULT_WET_BRUSH,
147
153
  DOT_ROTATIONS,
148
154
  DOT_TEXTURE_ORDERS,
155
+ GHOST_TRIGGERS,
149
156
  IMPASTO_TOOLS,
150
157
  MAX_DOT_TEXTURES,
151
158
  MAX_STICKER_STAMPS,
@@ -175,20 +182,23 @@ import {
175
182
  normalizeBristle,
176
183
  normalizeBrushSpec,
177
184
  normalizeDynamics,
185
+ normalizeFluorescence,
178
186
  normalizeGhost,
179
187
  normalizeImpasto,
180
188
  normalizeLight,
181
189
  normalizeMultiColor,
190
+ normalizeMutation,
182
191
  normalizePaintBrush,
183
192
  normalizeParticle,
184
193
  normalizeStickerSettings,
194
+ normalizeThermochromic,
185
195
  normalizeWetBrush,
186
196
  paintToMultiColor,
187
197
  parseBrushLibrary,
188
198
  readBrushSource,
189
199
  resolveBrush,
190
200
  usesPaintExchange
191
- } from "../chunk-UXYCY3HP.js";
201
+ } from "../chunk-ZM7IKQ4X.js";
192
202
  import {
193
203
  STAMP_KINDS,
194
204
  StickersSlot,
@@ -238,7 +248,7 @@ import {
238
248
  normalizeWetParams,
239
249
  pigmentOf,
240
250
  wetSupport
241
- } from "../chunk-N3SKPQEH.js";
251
+ } from "../chunk-LMG57L5A.js";
242
252
  import "../chunk-W6JDUXP3.js";
243
253
  import {
244
254
  CoverageMap,
@@ -408,8 +418,22 @@ function normalizeLighting(raw = {}, base = DEFAULT_LIGHTING) {
408
418
  relief: Math.min(1, Math.max(0, num(raw.relief, base.relief)))
409
419
  };
410
420
  }
421
+ var DEFAULT_VIEW_TILT = { x: 0, y: 0 };
422
+ function normalizeViewTilt(raw = {}, base = DEFAULT_VIEW_TILT) {
423
+ const num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
424
+ return {
425
+ x: Math.min(1, Math.max(-1, num(raw.x, base.x))),
426
+ y: Math.min(1, Math.max(-1, num(raw.y, base.y)))
427
+ };
428
+ }
429
+ function viewTiltKey(v) {
430
+ return `${v.x.toFixed(3)}|${v.y.toFixed(3)}`;
431
+ }
411
432
  function emptyThickChannels() {
412
- return { height: new TiledSurface(1, /* @__PURE__ */ new Map(), 0), material: new TiledSurface(4, /* @__PURE__ */ new Map(), 0) };
433
+ return { height: new TiledSurface(1, /* @__PURE__ */ new Map(), 0), material: new TiledSurface(4, /* @__PURE__ */ new Map(), 0), film: new TiledSurface(4, /* @__PURE__ */ new Map(), 0) };
434
+ }
435
+ function hasThickChannels(ch) {
436
+ return Boolean(ch && (ch.height.tiles.size > 0 || ch.material.tiles.size > 0 || ch.film.tiles.size > 0));
413
437
  }
414
438
  var RELIEF_PX = 7;
415
439
  var AMBIENT = 0.38;
@@ -418,14 +442,31 @@ function lightVector(l) {
418
442
  const el = l.elevation * Math.PI / 180;
419
443
  return [Math.cos(el) * Math.sin(az), -Math.cos(el) * Math.cos(az), Math.sin(el)];
420
444
  }
445
+ function viewVector(v) {
446
+ const vx = v.x * 1.25;
447
+ const vy = v.y * 1.25;
448
+ const vz = 1;
449
+ const vl = Math.hypot(vx, vy, vz);
450
+ return [vx / vl, vy / vl, vz / vl];
451
+ }
452
+ function thinFilmRgb(opd) {
453
+ const twoPi = Math.PI * 2;
454
+ return [
455
+ 0.5 - 0.5 * Math.cos(twoPi * opd / 630),
456
+ 0.5 - 0.5 * Math.cos(twoPi * opd / 532),
457
+ 0.5 - 0.5 * Math.cos(twoPi * opd / 465)
458
+ ];
459
+ }
421
460
  function lightingKey(l) {
422
461
  return `${l.azimuth.toFixed(2)}|${l.elevation.toFixed(2)}|${l.relief.toFixed(3)}`;
423
462
  }
424
- function lightTile(color, height, heightAround, material, light) {
463
+ function lightTile(color, height, heightAround, material, light, film = null, viewTilt = DEFAULT_VIEW_TILT) {
425
464
  const out = new Uint8ClampedArray(color);
426
- if (!height || light.relief <= 0) return out;
465
+ const hasRelief = Boolean(height && light.relief > 0);
466
+ if (!hasRelief && !film) return out;
427
467
  const [lx, ly, lz] = lightVector(light);
428
- const hv = [lx, ly, lz + 1];
468
+ const [vx, vy, vz] = viewVector(viewTilt);
469
+ const hv = [lx + vx, ly + vy, lz + vz];
429
470
  const hl = Math.hypot(hv[0], hv[1], hv[2]);
430
471
  const hx = hv[0] / hl;
431
472
  const hy = hv[1] / hl;
@@ -433,6 +474,7 @@ function lightTile(color, height, heightAround, material, light) {
433
474
  const k = RELIEF_PX / 255 * light.relief * 2;
434
475
  const flatDiffuse = AMBIENT + (1 - AMBIENT) * lz;
435
476
  const read = (x, y) => {
477
+ if (!height) return 0;
436
478
  if (x >= 0 && y >= 0 && x < TILE && y < TILE) return height[y * TILE + x];
437
479
  const dx = x < 0 ? -1 : x >= TILE ? 1 : 0;
438
480
  const dy = y < 0 ? -1 : y >= TILE ? 1 : 0;
@@ -445,28 +487,59 @@ function lightTile(color, height, heightAround, material, light) {
445
487
  const p = y * TILE + x;
446
488
  const i = p * 4;
447
489
  if (color[i + 3] === 0) continue;
448
- const h = height[p];
449
- const gx = (read(x + 1, y) - read(x - 1, y)) * 0.5;
450
- const gy = (read(x, y + 1) - read(x, y - 1)) * 0.5;
451
- if (h === 0 && gx === 0 && gy === 0) continue;
452
- let nx = -gx * k;
453
- let ny = -gy * k;
454
- const nl = Math.hypot(nx, ny, 1);
455
- nx /= nl;
456
- ny /= nl;
457
- const nz = 1 / nl;
458
- const diffuse = Math.max(0, nx * lx + ny * ly + nz * lz);
459
- const shade = (AMBIENT + (1 - AMBIENT) * diffuse) / flatDiffuse;
460
- const gloss = material ? material[i] / 255 : 0.3;
461
- const metal = material ? material[i + 1] / 255 : 0;
462
- const shininess = 6 + 90 * gloss;
463
- const nh = Math.max(0, nx * hx + ny * hy + nz * hz);
464
- const spec = Math.max(0, Math.pow(nh, shininess) - Math.pow(hz, shininess)) * (0.25 + 0.75 * gloss) * light.relief;
490
+ const fa = film ? film[i + 3] / 255 : 0;
491
+ const h = hasRelief ? height[p] : 0;
492
+ const gx = hasRelief ? (read(x + 1, y) - read(x - 1, y)) * 0.5 : 0;
493
+ const gy = hasRelief ? (read(x, y + 1) - read(x, y - 1)) * 0.5 : 0;
494
+ const hasSlope = hasRelief && (h !== 0 || gx !== 0 || gy !== 0);
495
+ if (!hasSlope && fa <= 0) continue;
496
+ let nx = 0;
497
+ let ny = 0;
498
+ let nz = 1;
499
+ let shade = 1;
500
+ let gloss = 0.3;
501
+ let metal = 0;
502
+ let spec = 0;
503
+ if (hasSlope) {
504
+ nx = -gx * k;
505
+ ny = -gy * k;
506
+ const nl = Math.hypot(nx, ny, 1);
507
+ nx /= nl;
508
+ ny /= nl;
509
+ nz = 1 / nl;
510
+ const diffuse = Math.max(0, nx * lx + ny * ly + nz * lz);
511
+ shade = (AMBIENT + (1 - AMBIENT) * diffuse) / flatDiffuse;
512
+ gloss = material ? material[i] / 255 : 0.3;
513
+ metal = material ? material[i + 1] / 255 : 0;
514
+ const shininess = 6 + 90 * gloss;
515
+ const nh = Math.max(0, nx * hx + ny * hy + nz * hz);
516
+ spec = Math.max(0, Math.pow(nh, shininess) - Math.pow(hz, shininess)) * (0.25 + 0.75 * gloss) * light.relief;
517
+ }
518
+ let filmRgb = null;
519
+ if (fa > 0 && film) {
520
+ const d = 100 + film[i] / 255 * 900;
521
+ const ior = 1.2 + film[i + 1] / 255 * 0.8;
522
+ const nh = Math.max(0, nx * hx + ny * hy + nz * hz);
523
+ const nv = Math.max(0, nx * vx + ny * vy + nz * vz);
524
+ const cosThetaI = Math.max(0, Math.min(1, nh * nv));
525
+ const sin2ThetaT = (1 - cosThetaI * cosThetaI) / (ior * ior);
526
+ const cosThetaT = Math.sqrt(Math.max(0, 1 - sin2ThetaT));
527
+ const opd = 2 * ior * d * cosThetaT;
528
+ filmRgb = thinFilmRgb(opd);
529
+ }
465
530
  for (let c = 0; c < 3; c++) {
466
531
  const a = SRGB_TO_LINEAR[color[i + c]];
467
- const tint = metal * a + (1 - metal);
468
- const v = a * shade * (1 - 0.35 * metal) + spec * tint * (1 + metal);
469
- out[i + c] = linearToSrgb8(v);
532
+ const fc = filmRgb ? filmRgb[c] : a;
533
+ if (hasSlope) {
534
+ const tint = metal * a + (1 - metal);
535
+ const diff = fa > 0 ? a * (1 - fa * 0.75) + fc * fa * (0.45 + 0.55 * a) : a;
536
+ const specTint = fa > 0 ? tint * (1 - fa) + fc * fa : tint;
537
+ const v = diff * shade * (1 - 0.35 * metal) + spec * specTint * (1 + metal);
538
+ out[i + c] = linearToSrgb8(v);
539
+ } else {
540
+ const v = a * (1 - fa * 0.75) + fc * fa * (0.45 + 0.55 * a);
541
+ out[i + c] = linearToSrgb8(v);
542
+ }
470
543
  }
471
544
  }
472
545
  }
@@ -474,10 +547,12 @@ function lightTile(color, height, heightAround, material, light) {
474
547
  }
475
548
  var LitCache = class {
476
549
  tiles = /* @__PURE__ */ new Map();
477
- /** The layer's pixels lit (tiles without relief nearby are the colour tiles themselves). */
478
- lit(color, ch, light) {
479
- if (!ch.height.tiles.size || light.relief <= 0) return color;
480
- const lk = lightingKey(light);
550
+ /** The layer's pixels lit (tiles without relief or film nearby are the colour tiles themselves). */
551
+ lit(color, ch, light, viewTilt = DEFAULT_VIEW_TILT) {
552
+ const hasRelief = ch.height.tiles.size > 0 && light.relief > 0;
553
+ const hasFilm = ch.film.tiles.size > 0;
554
+ if (!hasRelief && !hasFilm) return color;
555
+ const lk = `${lightingKey(light)}|${viewTiltKey(viewTilt)}`;
481
556
  const out = new Map(color.tiles);
482
557
  const keys = /* @__PURE__ */ new Set();
483
558
  for (const key of color.tiles.keys()) keys.add(key);
@@ -486,12 +561,15 @@ var LitCache = class {
486
561
  const ty = Math.floor(key / 65536) - 32768;
487
562
  const around = (dx, dy) => ch.height.tiles.get(tileKey(tx + dx, ty + dy))?.data ?? null;
488
563
  let any = false;
489
- for (let dy = -1; dy <= 1 && !any; dy++) for (let dx = -1; dx <= 1 && !any; dx++) if (around(dx, dy)) any = true;
490
- if (!any) continue;
491
- const h = ch.height.tiles.get(key) ?? null;
492
- const m = ch.material.tiles.get(key) ?? null;
493
- const inputs = [tile, lk, m];
494
- for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) inputs.push(ch.height.tiles.get(tileKey(tx + dx, ty + dy)) ?? null);
564
+ if (hasRelief) {
565
+ for (let dy = -1; dy <= 1 && !any; dy++) for (let dx = -1; dx <= 1 && !any; dx++) if (around(dx, dy)) any = true;
566
+ }
567
+ const f = ch.film.tiles.get(key) ?? null;
568
+ if (!any && !f) continue;
569
+ const h = hasRelief ? ch.height.tiles.get(key) ?? null : null;
570
+ const m = hasRelief ? ch.material.tiles.get(key) ?? null : null;
571
+ const inputs = [tile, lk, m, f];
572
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) inputs.push(hasRelief ? ch.height.tiles.get(tileKey(tx + dx, ty + dy)) ?? null : null);
495
573
  const cached = this.tiles.get(key);
496
574
  if (cached && cached.inputs.length === inputs.length && cached.inputs.every((v, i) => v === inputs[i])) {
497
575
  out.set(key, cached.tile);
@@ -499,10 +577,19 @@ var LitCache = class {
499
577
  }
500
578
  const hd = h ? expand(h, 1) : null;
501
579
  const md = m ? expand(m, 4) : null;
502
- const data = lightTile(expand(tile, 4), hd, (dx, dy) => {
503
- const t = ch.height.tiles.get(tileKey(tx + dx, ty + dy));
504
- return t ? expand(t, 1) : null;
505
- }, md, light);
580
+ const fd = f ? expand(f, 4) : null;
581
+ const data = lightTile(
582
+ expand(tile, 4),
583
+ hd,
584
+ (dx, dy) => {
585
+ const t = ch.height.tiles.get(tileKey(tx + dx, ty + dy));
586
+ return t ? expand(t, 1) : null;
587
+ },
588
+ md,
589
+ light,
590
+ fd,
591
+ viewTilt
592
+ );
506
593
  const lit = { data, uniform: null };
507
594
  this.tiles.set(key, { inputs, tile: lit });
508
595
  out.set(key, lit);
@@ -522,6 +609,122 @@ function expand(t, channels) {
522
609
  }
523
610
  return out;
524
611
  }
612
+ var FilmBuffer = class {
613
+ constructor(base, offset = { x: 0, y: 0 }, mask = null) {
614
+ this.base = base;
615
+ this.offset = offset;
616
+ this.mask = mask;
617
+ }
618
+ base;
619
+ offset;
620
+ mask;
621
+ tiles = /* @__PURE__ */ new Map();
622
+ maskAt(wx, wy) {
623
+ const sel = this.mask;
624
+ if (!sel) return 1;
625
+ return wx >= 0 && wy >= 0 && wx < sel.width && wy < sel.height ? sel.data[wy * sel.width + wx] / 255 : 0;
626
+ }
627
+ tile(tx, ty) {
628
+ const key = tileKey(tx, ty);
629
+ let t = this.tiles.get(key);
630
+ if (!t) {
631
+ const b = this.base.tiles.get(key);
632
+ t = b ? new Uint8ClampedArray(expand(b, 4)) : new Uint8ClampedArray(TILE * TILE * 4);
633
+ this.tiles.set(key, t);
634
+ }
635
+ return t;
636
+ }
637
+ /** Deposits thin-film iridescence over `rect` with per-pixel `coverage` (0..1) scaled by `strength` (0..1). */
638
+ deposit(rect, coverage, strength, ir, pressure = 0.5) {
639
+ if (strength <= 0) return;
640
+ const nm = Math.max(100, Math.min(1e3, ir.thickness * (1 + ir.pressureToThickness * (pressure - 0.5) * 2)));
641
+ const rByte = Math.round((nm - 100) / 900 * 255);
642
+ const gByte = Math.round((ir.ior - 1.2) / 0.8 * 255);
643
+ const ox = this.offset.x;
644
+ const oy = this.offset.y;
645
+ for (let yy = 0, p = 0; yy < rect.height; yy++) {
646
+ for (let xx = 0; xx < rect.width; xx++, p++) {
647
+ const wx = rect.x + xx;
648
+ const wy = rect.y + yy;
649
+ const m = this.maskAt(wx, wy);
650
+ const c = Math.min(1, coverage[p] * strength * m);
651
+ if (c <= 0) continue;
652
+ const lx = wx - ox;
653
+ const ly = wy - oy;
654
+ const t = this.tile(Math.floor(lx / TILE), Math.floor(ly / TILE));
655
+ const i = ((ly & 255) * TILE + (lx & 255)) * 4;
656
+ const prevA = t[i + 3] / 255;
657
+ const nextA = prevA + c * (1 - prevA);
658
+ const w = nextA > 0 ? c / nextA : 1;
659
+ t[i] = Math.round(t[i] + (rByte - t[i]) * w);
660
+ t[i + 1] = Math.round(t[i + 1] + (gByte - t[i + 1]) * w);
661
+ t[i + 2] = 0;
662
+ t[i + 3] = Math.round(nextA * 255);
663
+ }
664
+ }
665
+ }
666
+ /** Stamps a soft disc of thin-film iridescence at `(cx, cy)` (document pixels) with `radius` and `alpha` (0..1). */
667
+ stampDisc(cx, cy, radius, alpha, ir, pressure = 0.5) {
668
+ if (alpha <= 0 || radius <= 0) return;
669
+ const r = Math.max(0.5, radius);
670
+ const x0 = Math.floor(cx - r - 1);
671
+ const y0 = Math.floor(cy - r - 1);
672
+ const x1 = Math.ceil(cx + r + 1);
673
+ const y1 = Math.ceil(cy + r + 1);
674
+ const w = x1 - x0;
675
+ const h = y1 - y0;
676
+ if (w <= 0 || h <= 0) return;
677
+ const cov = new Float32Array(w * h);
678
+ for (let yy = 0, p = 0; yy < h; yy++) {
679
+ for (let xx = 0; xx < w; xx++, p++) {
680
+ const d = Math.hypot(x0 + xx + 0.5 - cx, y0 + yy + 0.5 - cy);
681
+ if (d < r + 0.5) cov[p] = d <= r - 0.5 ? 1 : Math.max(0, r + 0.5 - d);
682
+ }
683
+ }
684
+ this.deposit({ x: x0, y: y0, width: w, height: h }, cov, Math.min(1, alpha), ir, pressure);
685
+ }
686
+ /** Erases thin-film coverage over `rect` by `coverage[p] * strength`. */
687
+ erase(rect, coverage, strength) {
688
+ if (strength <= 0) return;
689
+ const ox = this.offset.x;
690
+ const oy = this.offset.y;
691
+ for (let yy = 0, p = 0; yy < rect.height; yy++) {
692
+ for (let xx = 0; xx < rect.width; xx++, p++) {
693
+ const wx = rect.x + xx;
694
+ const wy = rect.y + yy;
695
+ const m = this.maskAt(wx, wy);
696
+ const c = Math.min(1, coverage[p] * strength * m);
697
+ if (c <= 0) continue;
698
+ const lx = wx - ox;
699
+ const ly = wy - oy;
700
+ const tx = Math.floor(lx / TILE);
701
+ const ty = Math.floor(ly / TILE);
702
+ const key = tileKey(tx, ty);
703
+ if (!this.tiles.has(key) && !this.base.tiles.has(key)) continue;
704
+ const t = this.tile(tx, ty);
705
+ const i = ((ly & 255) * TILE + (lx & 255)) * 4;
706
+ const a = Math.round(t[i + 3] * (1 - c));
707
+ if (a <= 0) {
708
+ t[i] = 0;
709
+ t[i + 1] = 0;
710
+ t[i + 2] = 0;
711
+ t[i + 3] = 0;
712
+ } else {
713
+ t[i + 3] = a;
714
+ }
715
+ }
716
+ }
717
+ }
718
+ surface() {
719
+ const out = new Map(this.base.tiles);
720
+ for (const [k, d] of this.tiles) {
721
+ const t = finishTile(new Uint8ClampedArray(d), 4, 0);
722
+ if (t) out.set(k, t);
723
+ else out.delete(k);
724
+ }
725
+ return new TiledSurface(4, out, 0);
726
+ }
727
+ };
525
728
  var ThickStrokeDriver = class {
526
729
  constructor(init) {
527
730
  this.init = init;
@@ -538,6 +741,7 @@ var ThickStrokeDriver = class {
538
741
  amount: 1
539
742
  }));
540
743
  this.medium = new TileMedium(init.target, {});
744
+ this.film = b.iridescence ? new FilmBuffer(init.channels.film, init.target.offset) : null;
541
745
  this.sampler = init.paint ? new PaintGradientSampler(init.paint.map.by === "across-tip" ? { ...init.paint, map: { by: "distance", length: 300, repeat: "mirror" } } : init.paint, init.seed) : null;
542
746
  }
543
747
  init;
@@ -547,6 +751,7 @@ var ThickStrokeDriver = class {
547
751
  medium;
548
752
  height = /* @__PURE__ */ new Map();
549
753
  material = /* @__PURE__ */ new Map();
754
+ film;
550
755
  paint = [0, 0, 0];
551
756
  centre = [0, 0, 0];
552
757
  tip = new Float32Array(0);
@@ -575,7 +780,11 @@ var ThickStrokeDriver = class {
575
780
  else m.delete(k);
576
781
  }
577
782
  void this.version;
578
- return { height: new TiledSurface(1, h, 0), material: new TiledSurface(4, m, 0) };
783
+ return {
784
+ height: new TiledSurface(1, h, 0),
785
+ material: new TiledSurface(4, m, 0),
786
+ film: this.film ? this.film.surface() : this.init.channels.film
787
+ };
579
788
  }
580
789
  heightTile(tx, ty, write) {
581
790
  const key = tileKey(tx, ty);
@@ -671,6 +880,7 @@ var ThickStrokeDriver = class {
671
880
  st.smudge.dab(this.medium, { x: d.x, y: d.y, radius: d.radius, opacity: k }, rect, tip);
672
881
  this.knifeHeight(st, d, rect, tip, k, im.drag);
673
882
  this.paintMaterial(rect, tip, k * 0.6, im.gloss, im.metallic, im.rim, im.clarity);
883
+ if (this.film && b.iridescence) this.film.deposit(rect, tip, k * 0.6, b.iridescence, d.pressure);
674
884
  return;
675
885
  }
676
886
  const ex = st.exchange;
@@ -738,6 +948,7 @@ var ThickStrokeDriver = class {
738
948
  }
739
949
  }
740
950
  this.paintMaterial(rect, tip, k, im.gloss * (1 - 0.3 * im.thinner), im.metallic, im.rim, im.clarity);
951
+ if (this.film && b.iridescence) this.film.deposit(rect, tip, k, b.iridescence, d.pressure);
741
952
  }
742
953
  /** The knife: flattens the relief under it and carries some of it along (a patch, like a smudge). */
743
954
  knifeHeight(st, d, rect, tip, k, drag) {
@@ -790,18 +1001,33 @@ var ThickStrokeDriver = class {
790
1001
  }
791
1002
  }
792
1003
  };
1004
+ function unionRects(a, b) {
1005
+ if (!a) return b ? { ...b } : null;
1006
+ if (!b) return { ...a };
1007
+ const x = Math.min(a.x, b.x);
1008
+ const y = Math.min(a.y, yOf(b));
1009
+ return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
1010
+ }
1011
+ var yOf = (r) => r.y;
793
1012
  function reliefInfo(ch) {
794
1013
  let max = 0;
795
1014
  for (const t of ch.height.tiles.values()) {
796
1015
  if (t.uniform !== null) max = Math.max(max, t.uniform & 255);
797
1016
  else for (let i = 0; i < t.data.length; i++) if (t.data[i] > max) max = t.data[i];
798
1017
  }
799
- return { bounds: ch.height.bounds, maxHeight: max, tiles: ch.height.tiles.size };
1018
+ const bounds = unionRects(ch.height.bounds, ch.film.bounds);
1019
+ return { bounds, maxHeight: max, tiles: ch.height.tiles.size || ch.film.tiles.size };
800
1020
  }
801
1021
  function thickToJSON(ch) {
802
- const b = ch.height.bounds;
1022
+ const b = unionRects(ch.height.bounds, ch.film.bounds);
803
1023
  if (!b) return null;
804
- return { rect: { ...b }, height: pngDataUrl(b.width, b.height, readRegion(ch.height, b), 1), material: pngDataUrl(b.width, b.height, readRegion(ch.material, b), 4) };
1024
+ const out = {
1025
+ rect: { ...b },
1026
+ height: pngDataUrl(b.width, b.height, readRegion(ch.height, b), 1),
1027
+ material: pngDataUrl(b.width, b.height, readRegion(ch.material, b), 4)
1028
+ };
1029
+ if (ch.film.bounds) out.film = pngDataUrl(b.width, b.height, readRegion(ch.film, b), 4);
1030
+ return out;
805
1031
  }
806
1032
  function thickFromJSON(raw) {
807
1033
  const r = raw;
@@ -814,9 +1040,14 @@ function thickFromJSON(raw) {
814
1040
  const h = decode(r.height, true);
815
1041
  const m = decode(r.material, false);
816
1042
  const hd = h.channels === 1 ? h.data : Uint8ClampedArray.from({ length: h.width * h.height }, (_, i) => h.data[i * 4]);
1043
+ const film = typeof r.film === "string" ? (() => {
1044
+ const f = decode(r.film, false);
1045
+ return surfaceFromPixels({ width: f.width, height: f.height, data: f.data }, 4, r.rect.x, r.rect.y);
1046
+ })() : new TiledSurface(4, /* @__PURE__ */ new Map(), 0);
817
1047
  return {
818
1048
  height: surfaceFromPixels({ width: h.width, height: h.height, data: hd }, 1, r.rect.x, r.rect.y),
819
- material: surfaceFromPixels({ width: m.width, height: m.height, data: m.data }, 4, r.rect.x, r.rect.y)
1049
+ material: surfaceFromPixels({ width: m.width, height: m.height, data: m.data }, 4, r.rect.x, r.rect.y),
1050
+ film
820
1051
  };
821
1052
  }
822
1053
 
@@ -1075,6 +1306,567 @@ function lightFromJSON(raw) {
1075
1306
  return surfaceFromPixels({ width: img.width, height: img.height, data }, 1, r.rect.x, r.rect.y);
1076
1307
  }
1077
1308
 
1309
+ // src/paint-editor/heat.ts
1310
+ var DEFAULT_UV_LIGHT = { on: false, strength: 1, ambient: 0.15 };
1311
+ function normalizeUvLight(raw = {}, base = DEFAULT_UV_LIGHT) {
1312
+ const num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
1313
+ return {
1314
+ on: typeof raw.on === "boolean" ? raw.on : base.on,
1315
+ strength: Math.min(2, Math.max(0, num(raw.strength, base.strength))),
1316
+ ambient: Math.min(1, Math.max(0, num(raw.ambient, base.ambient)))
1317
+ };
1318
+ }
1319
+ function uvLightKey(uv) {
1320
+ return `${uv.on ? 1 : 0}|${uv.strength.toFixed(3)}|${uv.ambient.toFixed(3)}`;
1321
+ }
1322
+ var uvPapers = /* @__PURE__ */ new WeakMap();
1323
+ function uvPaper(paper, uv) {
1324
+ if (!uv.on || uv.ambient >= 1) return paper;
1325
+ const cached = uvPapers.get(paper);
1326
+ if (cached && cached.ambient === uv.ambient) return cached.paper;
1327
+ const [r, g, b] = hexToRgb8(paper.color);
1328
+ const toHex = (v) => Math.round(Math.min(255, Math.max(0, v * uv.ambient))).toString(16).padStart(2, "0");
1329
+ const next = { ...paper, color: `#${toHex(r)}${toHex(g)}${toHex(b)}` };
1330
+ uvPapers.set(paper, { ambient: uv.ambient, paper: next });
1331
+ return next;
1332
+ }
1333
+ function emptyFluor() {
1334
+ return new TiledSurface(4, /* @__PURE__ */ new Map(), 0);
1335
+ }
1336
+ function emptyThermo() {
1337
+ return new TiledSurface(4, /* @__PURE__ */ new Map(), 0);
1338
+ }
1339
+ function expand4(t) {
1340
+ if (t.data.length === TILE * TILE * 4) return t.data;
1341
+ const out = new Uint8ClampedArray(TILE * TILE * 4);
1342
+ const u = t.uniform ?? 0;
1343
+ const bytes = [u >>> 24 & 255, u >>> 16 & 255, u >>> 8 & 255, u & 255];
1344
+ for (let i = 0; i < out.length; i += 4) out.set(bytes, i);
1345
+ return out;
1346
+ }
1347
+ function fluorTwinBrush(brush) {
1348
+ const f = brush.fluorescence.amount;
1349
+ const base = { ...brush, light: { amount: 0, pressure: false }, fluorescence: { amount: 0, invisible: false }, thermochromic: { hotColor: null, threshold: 0.5 }, tip: { ...brush.tip, blend: "normal" } };
1350
+ if (brush.engine === "eraser" || brush.engine === "smudge" || brush.engine === "blender") return base;
1351
+ const move = brush.engine === "impasto" ? brush.impasto.tool === "knife" ? "smudge" : "dab" : brush.engine;
1352
+ if (f > 0) {
1353
+ return normalizePaintBrush(
1354
+ {
1355
+ ...base,
1356
+ engine: move,
1357
+ tip: { ...base.tip, opacity: brush.tip.opacity * f, blend: "normal" }
1358
+ },
1359
+ base
1360
+ );
1361
+ }
1362
+ if (brush.engine === "mixer" || brush.behavior.pickup > 0 || brush.engine === "impasto" && brush.impasto.tool === "knife") {
1363
+ return normalizePaintBrush({ ...base, engine: move }, base);
1364
+ }
1365
+ return normalizePaintBrush(
1366
+ {
1367
+ ...base,
1368
+ engine: "eraser",
1369
+ tip: { ...base.tip, opacity: brush.tip.opacity },
1370
+ paint: null
1371
+ },
1372
+ base
1373
+ );
1374
+ }
1375
+ function thermoTwinBrush(brush) {
1376
+ const active = brush.thermochromic.hotColor !== null;
1377
+ const base = {
1378
+ ...brush,
1379
+ light: { amount: 0, pressure: false },
1380
+ fluorescence: { amount: 0, invisible: false },
1381
+ thermochromic: { hotColor: null, threshold: 0.5 },
1382
+ paint: null,
1383
+ tip: { ...brush.tip, blend: "normal" }
1384
+ };
1385
+ if (brush.engine === "eraser" || brush.engine === "smudge" || brush.engine === "blender") return base;
1386
+ const move = brush.engine === "impasto" ? brush.impasto.tool === "knife" ? "smudge" : "dab" : brush.engine;
1387
+ if (active) {
1388
+ return normalizePaintBrush({ ...base, engine: move, behavior: { ...base.behavior, pickup: 0 } }, base);
1389
+ }
1390
+ if (brush.engine === "mixer" || brush.behavior.pickup > 0 || brush.engine === "impasto" && brush.impasto.tool === "knife") {
1391
+ return normalizePaintBrush({ ...base, engine: move }, base);
1392
+ }
1393
+ return normalizePaintBrush({ ...base, engine: "eraser", tip: { ...base.tip, opacity: brush.tip.opacity } }, base);
1394
+ }
1395
+ function finalizeThermoStroke(twinOut, before, hotRgb, threshold) {
1396
+ const thByte = Math.max(1, Math.min(255, Math.round(threshold * 255)));
1397
+ const out = new Map(before.tiles);
1398
+ const keys = /* @__PURE__ */ new Set([...before.tiles.keys(), ...twinOut.tiles.keys()]);
1399
+ for (const key of keys) {
1400
+ const afterTile = twinOut.tiles.get(key);
1401
+ const beforeTile = before.tiles.get(key);
1402
+ if (afterTile === beforeTile) continue;
1403
+ if (!afterTile) {
1404
+ out.delete(key);
1405
+ continue;
1406
+ }
1407
+ const aData = expand4(afterTile);
1408
+ const bData = beforeTile ? expand4(beforeTile) : null;
1409
+ const dst = new Uint8ClampedArray(TILE * TILE * 4);
1410
+ for (let i = 0; i < dst.length; i += 4) {
1411
+ const cov = aData[i + 3] / 255;
1412
+ const prevTh = bData ? bData[i + 3] : 0;
1413
+ if (cov <= 0 && prevTh === 0) continue;
1414
+ if (prevTh === 0) {
1415
+ dst[i] = hotRgb[0];
1416
+ dst[i + 1] = hotRgb[1];
1417
+ dst[i + 2] = hotRgb[2];
1418
+ dst[i + 3] = thByte;
1419
+ } else if (cov <= 0) {
1420
+ dst[i] = bData[i];
1421
+ dst[i + 1] = bData[i + 1];
1422
+ dst[i + 2] = bData[i + 2];
1423
+ dst[i + 3] = prevTh;
1424
+ } else {
1425
+ dst[i] = Math.round(bData[i] * (1 - cov) + hotRgb[0] * cov);
1426
+ dst[i + 1] = Math.round(bData[i + 1] * (1 - cov) + hotRgb[1] * cov);
1427
+ dst[i + 2] = Math.round(bData[i + 2] * (1 - cov) + hotRgb[2] * cov);
1428
+ dst[i + 3] = Math.max(1, Math.round(prevTh * (1 - cov) + thByte * cov));
1429
+ }
1430
+ }
1431
+ const ft = finishTile(dst, 4, 0);
1432
+ if (ft) out.set(key, ft);
1433
+ else out.delete(key);
1434
+ }
1435
+ return new TiledSurface(4, out, 0);
1436
+ }
1437
+ function scaleFluor(surface, k) {
1438
+ if (k <= 0 || !surface.tiles.size) return emptyFluor();
1439
+ if (k >= 1) return new TiledSurface(4, new Map(surface.tiles), 0);
1440
+ const tiles = /* @__PURE__ */ new Map();
1441
+ for (const [key, tile] of surface.tiles) {
1442
+ const src = expand4(tile);
1443
+ const dst = new Uint8ClampedArray(src);
1444
+ for (let i = 3; i < dst.length; i += 4) dst[i] = Math.round(dst[i] * k);
1445
+ const ft = finishTile(dst, 4, 0);
1446
+ if (ft) tiles.set(key, ft);
1447
+ }
1448
+ return new TiledSurface(4, tiles, 0);
1449
+ }
1450
+ function mergeFourChannel(below, above) {
1451
+ if (!below?.tiles.size && !above?.tiles.size) return null;
1452
+ if (!below?.tiles.size) return new TiledSurface(4, new Map(above.tiles), 0);
1453
+ if (!above?.tiles.size) return new TiledSurface(4, new Map(below.tiles), 0);
1454
+ const keys = /* @__PURE__ */ new Set([...below.tiles.keys(), ...above.tiles.keys()]);
1455
+ const tiles = /* @__PURE__ */ new Map();
1456
+ for (const key of keys) {
1457
+ const b = below.tiles.get(key);
1458
+ const a = above.tiles.get(key);
1459
+ if (!a) {
1460
+ tiles.set(key, b);
1461
+ continue;
1462
+ }
1463
+ if (!b) {
1464
+ tiles.set(key, a);
1465
+ continue;
1466
+ }
1467
+ const bd = expand4(b);
1468
+ const ad = expand4(a);
1469
+ const dst = new Uint8ClampedArray(TILE * TILE * 4);
1470
+ for (let i = 0; i < dst.length; i += 4) {
1471
+ const sa = ad[i + 3] / 255;
1472
+ const da = bd[i + 3] / 255;
1473
+ const oa = sa + da * (1 - sa);
1474
+ if (oa <= 0) continue;
1475
+ for (let c = 0; c < 3; c++) dst[i + c] = Math.round((ad[i + c] * sa + bd[i + c] * da * (1 - sa)) / oa);
1476
+ dst[i + 3] = Math.round(oa * 255);
1477
+ }
1478
+ const ft = finishTile(dst, 4, 0);
1479
+ if (ft) tiles.set(key, ft);
1480
+ }
1481
+ return tiles.size ? new TiledSurface(4, tiles, 0) : null;
1482
+ }
1483
+ function fluorIntensitySurface(fluor) {
1484
+ const tiles = /* @__PURE__ */ new Map();
1485
+ for (const [key, tile] of fluor.tiles) {
1486
+ const src = expand4(tile);
1487
+ const dst = new Uint8ClampedArray(TILE * TILE);
1488
+ for (let p = 0; p < TILE * TILE; p++) dst[p] = src[p * 4 + 3];
1489
+ const ft = finishTile(dst, 1, 0);
1490
+ if (ft) tiles.set(key, ft);
1491
+ }
1492
+ return new TiledSurface(1, tiles, 0);
1493
+ }
1494
+ function computeUvSpill(fluor, strength) {
1495
+ if (!fluor.tiles.size || strength <= 0) return null;
1496
+ const intensity = fluorIntensitySurface(fluor);
1497
+ if (!intensity.tiles.size) return null;
1498
+ return computeSpill(fluor, intensity, { radius: 20, strength: Math.min(2, 0.9 * strength) });
1499
+ }
1500
+ var UvCache = class {
1501
+ tiles = /* @__PURE__ */ new Map();
1502
+ spillCache = null;
1503
+ render(color, fluor, uv) {
1504
+ if (!uv.on) return color;
1505
+ const uk = uvLightKey(uv);
1506
+ const keys = /* @__PURE__ */ new Set([...color.tiles.keys(), ...fluor ? fluor.tiles.keys() : []]);
1507
+ const out = /* @__PURE__ */ new Map();
1508
+ for (const key of keys) {
1509
+ const cTile = color.tiles.get(key) ?? null;
1510
+ const fTile = fluor?.tiles.get(key) ?? null;
1511
+ if (!cTile && (!fTile || uv.strength <= 0)) continue;
1512
+ const inputs = [cTile, fTile, uk];
1513
+ const cached = this.tiles.get(key);
1514
+ if (cached && cached.inputs.length === inputs.length && cached.inputs.every((v, i) => v === inputs[i])) {
1515
+ out.set(key, cached.tile);
1516
+ continue;
1517
+ }
1518
+ const cd = cTile ? expand4(cTile) : null;
1519
+ const fd = fTile && uv.strength > 0 ? expand4(fTile) : null;
1520
+ const dst = new Uint8ClampedArray(TILE * TILE * 4);
1521
+ const amb = uv.ambient;
1522
+ const str = uv.strength;
1523
+ for (let i = 0; i < dst.length; i += 4) {
1524
+ const ca = cd ? cd[i + 3] : 0;
1525
+ const fa = fd ? fd[i + 3] : 0;
1526
+ if (ca === 0 && fa === 0) continue;
1527
+ const fGain = fa / 255 * str;
1528
+ const fCov = Math.min(1, fGain);
1529
+ for (let c = 0; c < 3; c++) {
1530
+ const baseLin = ca > 0 ? SRGB_TO_LINEAR[cd[i + c]] * amb : 0;
1531
+ const emLin = fa > 0 ? SRGB_TO_LINEAR[fd[i + c]] * (1 + 1.15 * fGain) : 0;
1532
+ dst[i + c] = linearToSrgb8(baseLin * (1 - fCov * 0.5) + emLin * fCov);
1533
+ }
1534
+ dst[i + 3] = Math.min(255, Math.max(ca, Math.round(fa * Math.min(1, str))));
1535
+ }
1536
+ const ft = finishTile(dst, 4, 0);
1537
+ if (ft) {
1538
+ this.tiles.set(key, { inputs, tile: ft });
1539
+ out.set(key, ft);
1540
+ }
1541
+ }
1542
+ for (const key of [...this.tiles.keys()]) if (!keys.has(key)) this.tiles.delete(key);
1543
+ return new TiledSurface(4, out, color.defaultValue);
1544
+ }
1545
+ spill(fluor, uv) {
1546
+ if (!uv.on || uv.strength <= 0 || !fluor.tiles.size) return null;
1547
+ if (this.spillCache && this.spillCache.fluor === fluor && this.spillCache.strength === uv.strength) {
1548
+ return this.spillCache.surface;
1549
+ }
1550
+ const surface = computeUvSpill(fluor, uv.strength);
1551
+ this.spillCache = { fluor, strength: uv.strength, surface };
1552
+ return surface;
1553
+ }
1554
+ };
1555
+ var HEAT_SCALE = 4;
1556
+ var HEAT_DIFFUSION = 0.18;
1557
+ var HEAT_COOLING = 0.03;
1558
+ var HEAT_DRY_THRESHOLD = 0.5;
1559
+ function createHeatField(width, height) {
1560
+ const gridWidth = Math.max(1, Math.ceil(width / HEAT_SCALE));
1561
+ const gridHeight = Math.max(1, Math.ceil(height / HEAT_SCALE));
1562
+ return {
1563
+ width,
1564
+ height,
1565
+ gridWidth,
1566
+ gridHeight,
1567
+ cells: new Float64Array(gridWidth * gridHeight),
1568
+ max: 0,
1569
+ revision: 0
1570
+ };
1571
+ }
1572
+ function cloneHeatField(field) {
1573
+ return {
1574
+ width: field.width,
1575
+ height: field.height,
1576
+ gridWidth: field.gridWidth,
1577
+ gridHeight: field.gridHeight,
1578
+ cells: new Float64Array(field.cells),
1579
+ max: field.max,
1580
+ revision: field.revision
1581
+ };
1582
+ }
1583
+ function distToSegment(px, py, ax, ay, bx, by) {
1584
+ const dx = bx - ax;
1585
+ const dy = by - ay;
1586
+ const len2 = dx * dx + dy * dy;
1587
+ if (len2 <= 1e-9) return Math.hypot(px - ax, py - ay);
1588
+ const t = Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / len2));
1589
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1590
+ }
1591
+ var round3 = (n) => Math.round(n * 1e3) / 1e3;
1592
+ function heatStats(field) {
1593
+ if (!field) return { max: 0, mean: 0, hotCells: 0, bounds: null };
1594
+ let max = 0;
1595
+ let sum = 0;
1596
+ let hot = 0;
1597
+ const n = field.cells.length;
1598
+ for (let i = 0; i < n; i++) {
1599
+ const v = field.cells[i];
1600
+ if (v > max) max = v;
1601
+ sum += v;
1602
+ if (v >= 0.05) hot++;
1603
+ }
1604
+ field.max = round3(max);
1605
+ return {
1606
+ max: field.max,
1607
+ mean: n > 0 ? round3(sum / n) : 0,
1608
+ hotCells: hot,
1609
+ bounds: hot > 0 ? hotCellsBounds(field, 0.05) : null
1610
+ };
1611
+ }
1612
+ function hotCellsBounds(field, threshold = HEAT_DRY_THRESHOLD) {
1613
+ let x0 = field.gridWidth;
1614
+ let y0 = field.gridHeight;
1615
+ let x1 = -1;
1616
+ let y1 = -1;
1617
+ for (let cy = 0; cy < field.gridHeight; cy++) {
1618
+ for (let cx = 0; cx < field.gridWidth; cx++) {
1619
+ if (field.cells[cy * field.gridWidth + cx] >= threshold) {
1620
+ if (cx < x0) x0 = cx;
1621
+ if (cy < y0) y0 = cy;
1622
+ if (cx > x1) x1 = cx;
1623
+ if (cy > y1) y1 = cy;
1624
+ }
1625
+ }
1626
+ }
1627
+ if (x1 < x0) return null;
1628
+ const rx = x0 * HEAT_SCALE;
1629
+ const ry = y0 * HEAT_SCALE;
1630
+ const rw = Math.min(field.width - rx, (x1 - x0 + 1) * HEAT_SCALE);
1631
+ const rh = Math.min(field.height - ry, (y1 - y0 + 1) * HEAT_SCALE);
1632
+ return rw > 0 && rh > 0 ? { x: rx, y: ry, width: rw, height: rh } : null;
1633
+ }
1634
+ function paintHeatField(field, points, radius = 32, temperature = 0.85) {
1635
+ const r = Math.min(512, Math.max(1, radius));
1636
+ const temp = Math.min(1, Math.max(0, temperature));
1637
+ const gw = field.gridWidth;
1638
+ const gh = field.gridHeight;
1639
+ let minCx = gw;
1640
+ let minCy = gh;
1641
+ let maxCx = -1;
1642
+ let maxCy = -1;
1643
+ const segCount = Math.max(1, points.length - 1);
1644
+ for (let s = 0; s < segCount; s++) {
1645
+ const a = points[s];
1646
+ const b = points[Math.min(points.length - 1, s + 1)];
1647
+ const x0 = Math.max(0, Math.floor((Math.min(a.x, b.x) - r) / HEAT_SCALE));
1648
+ const y0 = Math.max(0, Math.floor((Math.min(a.y, b.y) - r) / HEAT_SCALE));
1649
+ const x1 = Math.min(gw - 1, Math.ceil((Math.max(a.x, b.x) + r) / HEAT_SCALE));
1650
+ const y1 = Math.min(gh - 1, Math.ceil((Math.max(a.y, b.y) + r) / HEAT_SCALE));
1651
+ for (let cy = y0; cy <= y1; cy++) {
1652
+ const gy = (cy + 0.5) * HEAT_SCALE;
1653
+ for (let cx = x0; cx <= x1; cx++) {
1654
+ const gx = (cx + 0.5) * HEAT_SCALE;
1655
+ const d = distToSegment(gx, gy, a.x, a.y, b.x, b.y);
1656
+ if (d >= r) continue;
1657
+ const u = 1 - d / r;
1658
+ const w = u * u * (3 - 2 * u);
1659
+ const idx = cy * gw + cx;
1660
+ const cur = field.cells[idx];
1661
+ const next = cur + (temp - cur) * w;
1662
+ field.cells[idx] = next < 1e-4 ? 0 : Math.min(1, Math.max(0, next));
1663
+ if (cx < minCx) minCx = cx;
1664
+ if (cy < minCy) minCy = cy;
1665
+ if (cx > maxCx) maxCx = cx;
1666
+ if (cy > maxCy) maxCy = cy;
1667
+ }
1668
+ }
1669
+ }
1670
+ field.revision++;
1671
+ const bounds = maxCx >= minCx ? {
1672
+ x: minCx * HEAT_SCALE,
1673
+ y: minCy * HEAT_SCALE,
1674
+ width: Math.min(field.width - minCx * HEAT_SCALE, (maxCx - minCx + 1) * HEAT_SCALE),
1675
+ height: Math.min(field.height - minCy * HEAT_SCALE, (maxCy - minCy + 1) * HEAT_SCALE)
1676
+ } : { x: 0, y: 0, width: 0, height: 0 };
1677
+ return { bounds, stats: heatStats(field) };
1678
+ }
1679
+ function advanceHeatField(field, steps = 10) {
1680
+ const n = Math.min(1e3, Math.max(1, Math.round(steps)));
1681
+ const gw = field.gridWidth;
1682
+ const gh = field.gridHeight;
1683
+ let src = field.cells;
1684
+ let dst = new Float64Array(src.length);
1685
+ const decay = 1 - HEAT_COOLING;
1686
+ for (let s = 0; s < n; s++) {
1687
+ for (let y = 0; y < gh; y++) {
1688
+ const yUp = Math.max(0, y - 1) * gw;
1689
+ const yCur = y * gw;
1690
+ const yDn = Math.min(gh - 1, y + 1) * gw;
1691
+ for (let x = 0; x < gw; x++) {
1692
+ const xL = Math.max(0, x - 1);
1693
+ const xR = Math.min(gw - 1, x + 1);
1694
+ const c = src[yCur + x];
1695
+ const avg = (src[yCur + xL] + src[yCur + xR] + src[yUp + x] + src[yDn + x]) * 0.25;
1696
+ const v = (c + HEAT_DIFFUSION * (avg - c)) * decay;
1697
+ dst[yCur + x] = v < 1e-4 ? 0 : v;
1698
+ }
1699
+ }
1700
+ const tmp = src;
1701
+ src = dst;
1702
+ dst = tmp;
1703
+ }
1704
+ if (src !== field.cells) field.cells.set(src);
1705
+ field.revision++;
1706
+ return heatStats(field);
1707
+ }
1708
+ function clearHeatField(field) {
1709
+ let had = false;
1710
+ for (let i = 0; i < field.cells.length; i++) {
1711
+ if (field.cells[i] > 0) {
1712
+ had = true;
1713
+ field.cells[i] = 0;
1714
+ }
1715
+ }
1716
+ field.max = 0;
1717
+ if (had) field.revision++;
1718
+ return had;
1719
+ }
1720
+ function sampleHeatAt(field, x, y) {
1721
+ const gx = x / HEAT_SCALE - 0.5;
1722
+ const gy = y / HEAT_SCALE - 0.5;
1723
+ const gw = field.gridWidth;
1724
+ const gh = field.gridHeight;
1725
+ const x0 = Math.max(0, Math.min(gw - 1, Math.floor(gx)));
1726
+ const y0 = Math.max(0, Math.min(gh - 1, Math.floor(gy)));
1727
+ const x1 = Math.max(0, Math.min(gw - 1, x0 + 1));
1728
+ const y1 = Math.max(0, Math.min(gh - 1, y0 + 1));
1729
+ const fx = Math.min(1, Math.max(0, gx - Math.floor(gx)));
1730
+ const fy = Math.min(1, Math.max(0, gy - Math.floor(gy)));
1731
+ const c00 = field.cells[y0 * gw + x0];
1732
+ const c10 = field.cells[y0 * gw + x1];
1733
+ const c01 = field.cells[y1 * gw + x0];
1734
+ const c11 = field.cells[y1 * gw + x1];
1735
+ const top = c00 * (1 - fx) + c10 * fx;
1736
+ const bot = c01 * (1 - fx) + c11 * fx;
1737
+ return top * (1 - fy) + bot * fy;
1738
+ }
1739
+ function shiftHeatField(field, dx, dy, newWidth, newHeight) {
1740
+ const next = createHeatField(newWidth, newHeight);
1741
+ for (let cy = 0; cy < next.gridHeight; cy++) {
1742
+ const docY = (cy + 0.5) * HEAT_SCALE - dy;
1743
+ if (docY < 0 || docY >= field.height) continue;
1744
+ for (let cx = 0; cx < next.gridWidth; cx++) {
1745
+ const docX = (cx + 0.5) * HEAT_SCALE - dx;
1746
+ if (docX < 0 || docX >= field.width) continue;
1747
+ next.cells[cy * next.gridWidth + cx] = sampleHeatAt(field, docX, docY);
1748
+ }
1749
+ }
1750
+ next.revision = field.revision + 1;
1751
+ heatStats(next);
1752
+ return next;
1753
+ }
1754
+ var ThermoCache = class {
1755
+ tiles = /* @__PURE__ */ new Map();
1756
+ render(color, thermo, offset, heat) {
1757
+ if (!thermo?.tiles.size) return color;
1758
+ let anyHeat = false;
1759
+ for (let i = 0; i < heat.cells.length; i++) {
1760
+ if (heat.cells[i] > 0) {
1761
+ anyHeat = true;
1762
+ break;
1763
+ }
1764
+ }
1765
+ if (!anyHeat) return color;
1766
+ const out = new Map(color.tiles);
1767
+ const keys = /* @__PURE__ */ new Set();
1768
+ for (const key of color.tiles.keys()) keys.add(key);
1769
+ for (const [key, cTile] of color.tiles) {
1770
+ const tTile = thermo.tiles.get(key);
1771
+ if (!tTile) continue;
1772
+ const inputs = [cTile, tTile, offset.x, offset.y, heat.revision];
1773
+ const cached = this.tiles.get(key);
1774
+ if (cached && cached.inputs.length === inputs.length && cached.inputs.every((v, i) => v === inputs[i])) {
1775
+ out.set(key, cached.tile);
1776
+ continue;
1777
+ }
1778
+ const tx = key % 65536 - 32768;
1779
+ const ty = Math.floor(key / 65536) - 32768;
1780
+ const cd = expand4(cTile);
1781
+ const td = expand4(tTile);
1782
+ const dst = new Uint8ClampedArray(cd);
1783
+ const band = 0.12;
1784
+ for (let y = 0; y < TILE; y++) {
1785
+ const docY = ty * TILE + y + offset.y + 0.5;
1786
+ for (let x = 0; x < TILE; x++) {
1787
+ const i = (y * TILE + x) * 4;
1788
+ const ca = cd[i + 3];
1789
+ const thByte = td[i + 3];
1790
+ if (ca === 0 || thByte === 0) continue;
1791
+ const docX = tx * TILE + x + offset.x + 0.5;
1792
+ const temp = sampleHeatAt(heat, docX, docY);
1793
+ if (temp <= 0) continue;
1794
+ const threshold = thByte / 255;
1795
+ const u = Math.min(1, Math.max(0, (temp - (threshold - band)) / (2 * band)));
1796
+ const t = u * u * (3 - 2 * u);
1797
+ if (t <= 0) continue;
1798
+ dst[i] = Math.round(cd[i] * (1 - t) + td[i] * t);
1799
+ dst[i + 1] = Math.round(cd[i + 1] * (1 - t) + td[i + 1] * t);
1800
+ dst[i + 2] = Math.round(cd[i + 2] * (1 - t) + td[i + 2] * t);
1801
+ }
1802
+ }
1803
+ const shadedTile = { data: dst, uniform: null };
1804
+ this.tiles.set(key, { inputs, tile: shadedTile });
1805
+ out.set(key, shadedTile);
1806
+ }
1807
+ for (const key of [...this.tiles.keys()]) if (!keys.has(key)) this.tiles.delete(key);
1808
+ return new TiledSurface(4, out, color.defaultValue);
1809
+ }
1810
+ };
1811
+ function fluorToJSON(fluor) {
1812
+ const b = fluor?.bounds;
1813
+ if (!fluor || !b) return null;
1814
+ return { rect: { ...b }, fluor: pngDataUrl(b.width, b.height, readRegion(fluor, b), 4) };
1815
+ }
1816
+ function fluorFromJSON(raw) {
1817
+ const r = raw;
1818
+ if (!r || typeof r !== "object" || !r.rect || typeof r.fluor !== "string") throw new Error("not a saved fluorescence channel");
1819
+ const parsed = parseDataUrl(r.fluor);
1820
+ if (!parsed) throw new Error("the fluorescence channel is not a PNG data URL");
1821
+ const img = decodePng(parsed.bytes);
1822
+ return surfaceFromPixels({ width: img.width, height: img.height, data: img.data }, 4, r.rect.x, r.rect.y);
1823
+ }
1824
+ function thermoToJSON(thermo) {
1825
+ const b = thermo?.bounds;
1826
+ if (!thermo || !b) return null;
1827
+ return { rect: { ...b }, thermo: pngDataUrl(b.width, b.height, readRegion(thermo, b), 4) };
1828
+ }
1829
+ function thermoFromJSON(raw) {
1830
+ const r = raw;
1831
+ if (!r || typeof r !== "object" || !r.rect || typeof r.thermo !== "string") throw new Error("not a saved thermochromic channel");
1832
+ const parsed = parseDataUrl(r.thermo);
1833
+ if (!parsed) throw new Error("the thermochromic channel is not a PNG data URL");
1834
+ const img = decodePng(parsed.bytes);
1835
+ return surfaceFromPixels({ width: img.width, height: img.height, data: img.data }, 4, r.rect.x, r.rect.y);
1836
+ }
1837
+ function heatToJSON(field) {
1838
+ if (!field) return null;
1839
+ let any = false;
1840
+ for (let i = 0; i < field.cells.length; i++) {
1841
+ if (field.cells[i] > 0) {
1842
+ any = true;
1843
+ break;
1844
+ }
1845
+ }
1846
+ if (!any) return null;
1847
+ return {
1848
+ gridWidth: field.gridWidth,
1849
+ gridHeight: field.gridHeight,
1850
+ cells: Array.from(field.cells, (v) => Math.round(v * 1e4) / 1e4)
1851
+ };
1852
+ }
1853
+ function heatFromJSON(raw, width, height) {
1854
+ const r = raw;
1855
+ if (!r || typeof r !== "object" || typeof r.gridWidth !== "number" || typeof r.gridHeight !== "number" || !Array.isArray(r.cells)) {
1856
+ return null;
1857
+ }
1858
+ const field = createHeatField(width, height);
1859
+ if (r.gridWidth === field.gridWidth && r.gridHeight === field.gridHeight && r.cells.length === field.cells.length) {
1860
+ for (let i = 0; i < field.cells.length; i++) {
1861
+ const v = r.cells[i];
1862
+ field.cells[i] = typeof v === "number" && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0;
1863
+ }
1864
+ field.revision = 1;
1865
+ heatStats(field);
1866
+ }
1867
+ return field;
1868
+ }
1869
+
1078
1870
  // src/paint-editor/procedural.ts
1079
1871
  var STEP_PX = 1.5;
1080
1872
  var MarkStroke = class {
@@ -1223,6 +2015,7 @@ var MarkStroke = class {
1223
2015
  if (color && color.alpha < 1) for (let k = 0; k < n; k++) vals[k] *= color.alpha;
1224
2016
  this.coverage.accumulate(rect, vals, opacity, color ? color.rgb : null);
1225
2017
  }
2018
+ if (this.brush.iridescence && this.options.film) this.options.film.deposit(rect, vals, opacity * this.brush.tip.opacity, this.brush.iridescence, pressure);
1226
2019
  rects.push(rect);
1227
2020
  this.dabCount++;
1228
2021
  }
@@ -2116,8 +2909,11 @@ var PaintStrokeSession = class _PaintStrokeSession {
2116
2909
  this.live = new Map(this.base.tiles);
2117
2910
  this.copies = init.copies.length ? init.copies : [IDENTITY_COPY];
2118
2911
  const brush = init.brush;
2912
+ this.invisibleDaylight = brush.fluorescence.amount > 0 && brush.fluorescence.invisible && brush.engine !== "eraser" && brush.engine !== "blender" && brush.engine !== "smudge";
2119
2913
  if (init.wet) this.wetDriver = init.wet.driver;
2120
2914
  if (init.light && !init.wet && !init.sticker) this.lightTwin = this.makeLightTwin(init.light.surface);
2915
+ if (init.fluor && !init.wet && !init.sticker) this.fluorTwin = this.makeFluorTwin(init.fluor.surface);
2916
+ if (init.thermo && !init.wet && !init.sticker) this.thermoTwin = this.makeThermoTwin(init.thermo.surface);
2121
2917
  this.apply = {
2122
2918
  mode: brush.engine === "eraser" ? "erase" : "paint",
2123
2919
  color: init.color,
@@ -2143,10 +2939,18 @@ var PaintStrokeSession = class _PaintStrokeSession {
2143
2939
  wet = null;
2144
2940
  wetDriver = null;
2145
2941
  thickDriver = null;
2942
+ filmBuffer = null;
2146
2943
  stickerSpray = null;
2147
2944
  /** Light paint: the stroke's twin over the light channel, and what it left there. */
2148
2945
  lightTwin = null;
2149
2946
  lightResult = null;
2947
+ /** UV-fluorescent ink: the stroke's twin over the fluorescence channel, and what it left there. */
2948
+ fluorTwin = null;
2949
+ fluorResult = null;
2950
+ /** Thermochromic ink: the stroke's twin over the thermochromic channel, and what it left there. */
2951
+ thermoTwin = null;
2952
+ thermoResult = null;
2953
+ invisibleDaylight;
2150
2954
  /** A wet stroke that ended while the simulation was mid-batch: it commits when the driver catches up. */
2151
2955
  wetEndPending = false;
2152
2956
  apply;
@@ -2161,30 +2965,36 @@ var PaintStrokeSession = class _PaintStrokeSession {
2161
2965
  build(effect) {
2162
2966
  const init = this.init;
2163
2967
  const brush = init.brush;
2164
- this.dry = this.wet = this.thickDriver = this.stickerSpray = null;
2968
+ this.dry = this.wet = this.thickDriver = this.stickerSpray = this.filmBuffer = null;
2165
2969
  if (init.sticker) {
2166
2970
  this.stickerSpray = new StickerSpray({ brush, target: this.tileTarget(), color: init.colorHex, seed: init.seed, copies: this.copies, nextId: init.sticker.nextId, paint: init.paint ?? null, effect, art: init.sticker.art, variant: init.sticker.variant });
2167
- } else if (init.thick) {
2971
+ } else if (brush.engine === "impasto" && init.thick) {
2168
2972
  const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2169
2973
  this.thickDriver = new ThickStrokeDriver({ brush, target: this.tileTarget(), channels: init.thick.channels, color: rgb(init.color), load: init.mixerLoad, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect });
2170
- } else if (brush.engine === "mixer" || brush.engine === "blender" || brush.engine === "smudge" || usesPaintExchange(brush, Boolean(init.paint))) {
2171
- const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2172
- this.wet = new PaintDabStroke(brush, {
2173
- target: this.tileTarget(),
2174
- color: rgb(init.color),
2175
- secondary: rgb(init.secondary ?? init.background),
2176
- load: init.mixerLoad,
2177
- grain: init.grain,
2178
- paperDepth: init.paperDepth,
2179
- seed: init.seed,
2180
- copies: this.copies,
2181
- paint: init.paint ?? null,
2182
- effect,
2183
- stampArt: init.stampArt
2184
- });
2185
2974
  } else {
2186
- const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt };
2187
- this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
2975
+ if (init.thick && (brush.iridescence || brush.engine === "eraser")) {
2976
+ this.filmBuffer = new FilmBuffer(init.thick.channels.film, init.offset, init.selection ?? null);
2977
+ }
2978
+ if (brush.engine === "mixer" || brush.engine === "blender" || brush.engine === "smudge" || usesPaintExchange(brush, Boolean(init.paint))) {
2979
+ const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2980
+ this.wet = new PaintDabStroke(brush, {
2981
+ target: this.tileTarget(),
2982
+ color: rgb(init.color),
2983
+ secondary: rgb(init.secondary ?? init.background),
2984
+ load: init.mixerLoad,
2985
+ grain: init.grain,
2986
+ paperDepth: init.paperDepth,
2987
+ seed: init.seed,
2988
+ copies: this.copies,
2989
+ paint: init.paint ?? null,
2990
+ effect,
2991
+ stampArt: init.stampArt,
2992
+ film: this.filmBuffer
2993
+ });
2994
+ } else {
2995
+ const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt, film: this.filmBuffer };
2996
+ this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
2997
+ }
2188
2998
  }
2189
2999
  }
2190
3000
  /**
@@ -2228,16 +3038,83 @@ var PaintStrokeSession = class _PaintStrokeSession {
2228
3038
  secondary: [grey, grey, grey, 255],
2229
3039
  inputFilter: void 0,
2230
3040
  mixerLoad: { reservoir: init.mixerLoad.reservoir ? [g, g, g] : null, amount: init.mixerLoad.amount, pickup: null },
2231
- thick: init.thick,
3041
+ thick: void 0,
2232
3042
  paint,
2233
3043
  effect,
2234
- light: null
3044
+ light: null,
3045
+ fluor: null,
3046
+ thermo: null
3047
+ });
3048
+ }
3049
+ /** The stroke's twin over the 4-channel fluorescence surface (see `heat.ts`). */
3050
+ makeFluorTwin(fluor) {
3051
+ const init = this.init;
3052
+ const host = {
3053
+ update: (_id, surface) => {
3054
+ this.fluorResult = surface;
3055
+ },
3056
+ commit: (_s, surface) => {
3057
+ this.fluorResult = surface;
3058
+ }
3059
+ };
3060
+ return new _PaintStrokeSession(host, {
3061
+ ...init,
3062
+ surface: fluor,
3063
+ lockTransparency: false,
3064
+ brush: fluorTwinBrush(init.brush),
3065
+ inputFilter: void 0,
3066
+ light: null,
3067
+ fluor: null,
3068
+ thermo: null
3069
+ });
3070
+ }
3071
+ /** The stroke's twin over the 4-channel thermochromic surface (see `heat.ts`). */
3072
+ makeThermoTwin(thermo) {
3073
+ const init = this.init;
3074
+ const hotHex = init.brush.thermochromic.hotColor;
3075
+ const hotRgb = hotHex ? hexToRgb8(hotHex) : [0, 0, 0];
3076
+ const threshold = init.brush.thermochromic.threshold;
3077
+ const host = {
3078
+ update: (_id, surface) => {
3079
+ this.thermoResult = hotHex ? finalizeThermoStroke(surface, thermo, hotRgb, threshold) : surface;
3080
+ },
3081
+ commit: (_s, surface) => {
3082
+ if (!surface) {
3083
+ this.thermoResult = null;
3084
+ return;
3085
+ }
3086
+ this.thermoResult = hotHex ? finalizeThermoStroke(surface, thermo, hotRgb, threshold) : surface;
3087
+ }
3088
+ };
3089
+ return new _PaintStrokeSession(host, {
3090
+ ...init,
3091
+ surface: hotHex ? emptyThermo() : thermo,
3092
+ lockTransparency: false,
3093
+ brush: thermoTwinBrush(init.brush),
3094
+ color: [hotRgb[0], hotRgb[1], hotRgb[2], 255],
3095
+ colorHex: hotHex ?? "#000000",
3096
+ secondary: [hotRgb[0], hotRgb[1], hotRgb[2], 255],
3097
+ inputFilter: void 0,
3098
+ mixerLoad: hotHex ? { reservoir: [hotRgb[0] / 255, hotRgb[1] / 255, hotRgb[2] / 255], amount: 1, pickup: null } : init.mixerLoad,
3099
+ paint: null,
3100
+ effect: null,
3101
+ light: null,
3102
+ fluor: null,
3103
+ thermo: null
2235
3104
  });
2236
3105
  }
2237
3106
  /** Light paint: the layer's light channel after the stroke, or `null` when the stroke has no twin or left it unchanged. */
2238
3107
  lightChannel() {
2239
3108
  return this.lightResult;
2240
3109
  }
3110
+ /** UV-fluorescent ink: the layer's fluorescence channel after the stroke, or `null` when unchanged. */
3111
+ fluorChannel() {
3112
+ return this.fluorResult;
3113
+ }
3114
+ /** Thermochromic ink: the layer's thermochromic channel after the stroke, or `null` when unchanged. */
3115
+ thermoChannel() {
3116
+ return this.thermoResult;
3117
+ }
2241
3118
  tileTarget() {
2242
3119
  return {
2243
3120
  offset: this.init.offset,
@@ -2268,6 +3145,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
2268
3145
  this.lastRaw = points[points.length - 1];
2269
3146
  this.host.sampled?.(this, points);
2270
3147
  this.lightTwin?.push(points);
3148
+ this.fluorTwin?.push(points);
3149
+ this.thermoTwin?.push(points);
2271
3150
  if (this.wetDriver) {
2272
3151
  this.wetUpdate(this.wetDriver.push(points));
2273
3152
  return;
@@ -2278,9 +3157,12 @@ var PaintStrokeSession = class _PaintStrokeSession {
2278
3157
  stickers() {
2279
3158
  return this.stickerSpray ? this.stickerSpray.stickers.map((s) => ({ ...s })) : null;
2280
3159
  }
2281
- /** Impasto strokes: the layer's thick-paint channels with the stroke so far. */
3160
+ /** Impasto or iridescent strokes: the layer's thick-paint channels with the stroke so far. */
2282
3161
  thickChannels() {
2283
- return this.thickDriver?.channels() ?? null;
3162
+ if (this.thickDriver) return this.thickDriver.channels();
3163
+ const f = this.filmBuffer ?? this.wetDriver?.init.film ?? null;
3164
+ if (f && this.init.thick) return { ...this.init.thick.channels, film: f.surface() };
3165
+ return null;
2284
3166
  }
2285
3167
  /** Shows a wet stroke's progress (the wet layer's display). */
2286
3168
  wetUpdate(rects) {
@@ -2299,6 +3181,8 @@ var PaintStrokeSession = class _PaintStrokeSession {
2299
3181
  this.finishWet();
2300
3182
  }
2301
3183
  finishWet() {
3184
+ if (this.fluorTwin?.open) this.fluorTwin.end();
3185
+ if (this.thermoTwin?.open) this.thermoTwin.end();
2302
3186
  const d = this.wetDriver;
2303
3187
  const changed = d.dabCount > 0;
2304
3188
  this.result = { layerId: this.layerId, bounds: changed ? d.bounds : null, dabs: d.dabCount, strokeId: null, wet: true };
@@ -2309,6 +3193,10 @@ var PaintStrokeSession = class _PaintStrokeSession {
2309
3193
  update(rects) {
2310
3194
  if (!rects.length) return;
2311
3195
  for (const r of rects) this.bounds = union(this.bounds, r);
3196
+ if (this.invisibleDaylight) {
3197
+ this.host.update(this.layerId, this.base, rects);
3198
+ return;
3199
+ }
2312
3200
  const ch = this.base.channels;
2313
3201
  if (!this.dry) {
2314
3202
  for (const key of this.fresh) this.live.set(key, { data: this.live.get(key).data, uniform: null });
@@ -2350,18 +3238,24 @@ var PaintStrokeSession = class _PaintStrokeSession {
2350
3238
  this.update(this.dry ? this.dry.finish() : this.thickDriver ? this.thickDriver.finish() : this.stickerSpray ? this.stickerSpray.finish() : this.wet.finish());
2351
3239
  this.closed = true;
2352
3240
  if (this.lightTwin?.open) this.lightTwin.end();
3241
+ if (this.fluorTwin?.open) this.fluorTwin.end();
3242
+ if (this.thermoTwin?.open) this.thermoTwin.end();
2353
3243
  const ch = this.base.channels;
2354
3244
  const tiles = new Map(this.base.tiles);
2355
3245
  let changed = false;
2356
- for (const key of this.touched) {
2357
- const tile = finishTile(this.live.get(key).data, ch, this.base.defaultValue);
2358
- const prev = this.base.tiles.get(key);
2359
- if (tile) tiles.set(key, tile);
2360
- else tiles.delete(key);
2361
- if (!sameTile(prev, tile)) changed = true;
3246
+ if (this.invisibleDaylight) {
3247
+ changed = this.fluorResult !== null || this.thermoResult !== null || this.lightResult !== null;
3248
+ } else {
3249
+ for (const key of this.touched) {
3250
+ const tile = finishTile(this.live.get(key).data, ch, this.base.defaultValue);
3251
+ const prev = this.base.tiles.get(key);
3252
+ if (tile) tiles.set(key, tile);
3253
+ else tiles.delete(key);
3254
+ if (!sameTile(prev, tile)) changed = true;
3255
+ }
2362
3256
  }
2363
3257
  if (this.thickDriver && this.thickDriver.dabCount > 0) changed = true;
2364
- const surface = changed ? new TiledSurface(ch, tiles, this.base.defaultValue) : null;
3258
+ const surface = changed ? this.invisibleDaylight ? this.base : new TiledSurface(ch, tiles, this.base.defaultValue) : null;
2365
3259
  const dabs = this.dry ? this.dry.dabCount : this.thickDriver ? this.thickDriver.dabCount : this.stickerSpray ? this.stickerSpray.dabCount : this.wet.dabCount;
2366
3260
  this.result = { layerId: this.layerId, bounds: changed ? this.bounds : null, dabs, strokeId: null };
2367
3261
  if (this.stickerSpray?.skipped.size) this.result.skippedStickers = [...this.stickerSpray.skipped];
@@ -2375,7 +3269,11 @@ var PaintStrokeSession = class _PaintStrokeSession {
2375
3269
  if (this.closed) return;
2376
3270
  this.closed = true;
2377
3271
  if (this.lightTwin?.open) this.lightTwin.cancel();
3272
+ if (this.fluorTwin?.open) this.fluorTwin.cancel();
3273
+ if (this.thermoTwin?.open) this.thermoTwin.cancel();
2378
3274
  this.lightResult = null;
3275
+ this.fluorResult = null;
3276
+ this.thermoResult = null;
2379
3277
  this.host.commit(this, null, true);
2380
3278
  }
2381
3279
  };
@@ -3105,6 +4003,7 @@ function paintWetTestStroke(brush, paper, color, width, height) {
3105
4003
  w.paint("thumb", { x: 0, y: 0, width, height: half }, null, 0.4, Array.from(pigmentOf([r / 255, g / 255, b / 255], 0), (v) => v * 0.05));
3106
4004
  w.paint("thumb", { x: 0, y: half, width, height: height - half }, null, 0.4, Array.from(pigmentOf([232 / 255, 196 / 255, 64 / 255], 0), (v) => v * 0.05));
3107
4005
  }
4006
+ const film = brush.iridescence && brush.wet.tool === "paint" ? new FilmBuffer(emptySurface(4, 0)) : null;
3108
4007
  const driver = new WetStrokeDriver({
3109
4008
  world: w,
3110
4009
  laneId: "thumb",
@@ -3115,12 +4014,14 @@ function paintWetTestStroke(brush, paper, color, width, height) {
3115
4014
  paperDepth: paper.depth,
3116
4015
  paperColor: hexToRgb8(paper.color),
3117
4016
  seed: 7,
3118
- copies: [IDENTITY_COPY]
4017
+ copies: [IDENTITY_COPY],
4018
+ film
3119
4019
  });
3120
4020
  driver.push(thumbnailStroke(width, height));
3121
4021
  driver.finish();
3122
4022
  if (brush.wet.tool === "blow") w.stepSync(4);
3123
- return w.display("thumb").surface;
4023
+ const surf = w.display("thumb").surface;
4024
+ return film ? new LitCache().lit(surf, { ...emptyThickChannels(), film: film.surface() }, DEFAULT_LIGHTING) : surf;
3124
4025
  }
3125
4026
  function thumbnailStroke(width, height) {
3126
4027
  const pts = [];
@@ -3139,6 +4040,7 @@ function paintTestStroke(surface, brush, paper, color, width, height, stampArt)
3139
4040
  let result = surface;
3140
4041
  let thick = null;
3141
4042
  let light = null;
4043
+ let fluor = null;
3142
4044
  const session = new PaintStrokeSession(
3143
4045
  {
3144
4046
  update: () => {
@@ -3147,6 +4049,7 @@ function paintTestStroke(surface, brush, paper, color, width, height, stampArt)
3147
4049
  result = s ?? result;
3148
4050
  thick = sess.thickChannels();
3149
4051
  light = sess.lightChannel();
4052
+ fluor = sess.fluorChannel();
3150
4053
  }
3151
4054
  },
3152
4055
  {
@@ -3164,16 +4067,19 @@ function paintTestStroke(surface, brush, paper, color, width, height, stampArt)
3164
4067
  affineCopies: [],
3165
4068
  copies: [IDENTITY_COPY],
3166
4069
  mixerLoad: brushLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255]),
3167
- thick: brush.engine === "impasto" ? { channels: emptyThickChannels() } : void 0,
4070
+ thick: brush.engine === "impasto" || brush.iridescence ? { channels: emptyThickChannels() } : void 0,
3168
4071
  sticker: brush.engine === "sticker" ? { nextId: 1 } : void 0,
3169
4072
  light: brush.light.amount > 0 && brush.engine !== "wet" && brush.engine !== "sticker" ? { surface: emptyLight() } : null,
4073
+ fluor: brush.fluorescence.amount > 0 && brush.engine !== "wet" && brush.engine !== "sticker" ? { surface: emptyFluor() } : null,
3170
4074
  stampArt
3171
4075
  }
3172
4076
  );
3173
4077
  session.push(thumbnailStroke(width, height));
3174
4078
  session.end();
3175
4079
  const lit = thick ? new LitCache().lit(result, thick, DEFAULT_LIGHTING) : result;
3176
- return { surface: light || thick ? new LightCache().glow(lit, light ?? void 0, thick ?? void 0) : lit, light };
4080
+ const glowed = light || thick ? new LightCache().glow(lit, light ?? void 0, thick ?? void 0) : lit;
4081
+ const uvLit = fluor ? new UvCache().render(glowed, fluor, { on: true, strength: 1, ambient: 0.15 }) : glowed;
4082
+ return { surface: uvLit, light, fluor };
3177
4083
  }
3178
4084
  function renderBrushThumbnail(brush, options) {
3179
4085
  const width = Math.max(16, Math.min(512, Math.round(options.width ?? 160)));
@@ -3191,11 +4097,12 @@ function renderBrushThumbnail(brush, options) {
3191
4097
  surface = fillRect(surface, { x: 0, y: half, width, height: height - half }, [232, 196, 64, 255]);
3192
4098
  }
3193
4099
  let light = null;
4100
+ let fluor = null;
3194
4101
  if (b.engine === "wet") surface = paintWetTestStroke(b, options.paper, options.color, width, height);
3195
- else ({ surface, light } = paintTestStroke(surface, b, options.paper, options.color, width, height, options.stampArt));
4102
+ else ({ surface, light, fluor } = paintTestStroke(surface, b, options.paper, options.color, width, height, options.stampArt));
3196
4103
  const layer = { type: "pixel", id: "thumbnail", name: "thumbnail", visible: true, opacity: 1, blend: "normal", surface, offset: { x: 0, y: 0 } };
3197
- const paper = light && brushPreviewsOnDark(b) ? { ...options.paper, color: BRUSH_PREVIEW_DARK_PAPER } : options.paper;
3198
- const spill = light ? computeSpill(surface, light, { radius: Math.max(4, height / 6), strength: 1 }) : null;
4104
+ const paper = (light || fluor) && brushPreviewsOnDark(b) ? { ...options.paper, color: BRUSH_PREVIEW_DARK_PAPER } : options.paper;
4105
+ const spill = light ? computeSpill(surface, light, { radius: Math.max(4, height / 6), strength: 1 }) : fluor ? computeUvSpill(fluor, 1) : null;
3199
4106
  const layers = [paperNode(paper), layer];
3200
4107
  if (spill) layers.push({ type: "pixel", id: "thumbnail-light", name: "light", visible: true, opacity: 1, blend: "linear-dodge", surface: spill, offset: { x: 0, y: 0 } });
3201
4108
  const data = createPaintCompositor().composite(layers, { x: 0, y: 0, width, height }, { width, height });
@@ -3725,6 +4632,8 @@ function engineChanges(base, next) {
3725
4632
  return JSON.stringify(a) === JSON.stringify(b) ? void 0 : copyJSON(b);
3726
4633
  };
3727
4634
  const { id: _i, builtin: _b, ...rest } = diff(base, next) ?? {};
4635
+ if (base.iridescence && !next.iridescence) rest.iridescence = null;
4636
+ if (base.tipGrain && !next.tipGrain) rest.tipGrain = null;
3728
4637
  return rest;
3729
4638
  }
3730
4639
 
@@ -3851,7 +4760,10 @@ function liveStrokeRefusal(brush) {
3851
4760
  case "smudge":
3852
4761
  return `The ${brush.name} brush moves the paint under it, so it can't be redrawn on its own; rasterize the layer first (rasterize_stroke_layer) or blend on a pixel layer.`;
3853
4762
  }
4763
+ if (brush.mutation) return `The ${brush.name} brush paints mutation strokes (which fade once an agent reads them), so it goes on a pixel layer (add_layer).`;
3854
4764
  if (brush.light.amount > 0) return `The ${brush.name} brush lays light, which a stroke layer doesn't redraw yet; paint light on a pixel layer (add_layer), or set light.amount to 0 (set_brush).`;
4765
+ if (brush.fluorescence.amount > 0) return `The ${brush.name} brush lays UV-fluorescent ink, which a stroke layer doesn't redraw yet; paint it on a pixel layer (add_layer), or set fluorescence.amount to 0 (set_brush).`;
4766
+ 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).`;
3855
4767
  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.`;
3856
4768
  return null;
3857
4769
  }
@@ -4179,6 +5091,15 @@ async function replayPainting(source, target, options = {}) {
4179
5091
  if (e.layerId && target.getLayer(e.layerId)) target.wetPaper({ layerId: e.layerId, rect: sr(d.rect), amount: d.amount });
4180
5092
  break;
4181
5093
  }
5094
+ case "ghost-release": {
5095
+ const d = e.data;
5096
+ if (e.layerId && target.getLayer(e.layerId)) target.wet.releaseGhost(e.layerId, d?.linger);
5097
+ break;
5098
+ }
5099
+ case "ghost-keep": {
5100
+ if (e.layerId && target.getLayer(e.layerId)) target.wet.keepGhost(e.layerId);
5101
+ break;
5102
+ }
4182
5103
  }
4183
5104
  } catch {
4184
5105
  }
@@ -4421,6 +5342,27 @@ function effectLayersToJSON(map, keep) {
4421
5342
  return Object.keys(out).length ? out : void 0;
4422
5343
  }
4423
5344
 
5345
+ // src/paint-editor/mutation.ts
5346
+ function mutationBrushRefusal(brush) {
5347
+ if (!brush.mutation) return null;
5348
+ if (brush.engine === "eraser" || brush.engine === "blender" || brush.engine === "smudge" || brush.engine === "mixer" || brush.engine === "sticker") {
5349
+ return `Mutation paint adds strokes that fade when an agent reads them; ${brush.engine} brushes cannot use mutation.`;
5350
+ }
5351
+ if ((brush.behavior?.pickup ?? 0) > 0 || brush.behavior?.dirty) {
5352
+ return "Mutation brushes cannot pick up canvas colour (behavior.pickup must be 0 and behavior.dirty false).";
5353
+ }
5354
+ if (brush.engine === "wet") {
5355
+ const tool = brush.wet?.tool ?? "paint";
5356
+ if (tool !== "paint") {
5357
+ return `Wet mutation brushes must use wet.tool: "paint" (got "${tool}").`;
5358
+ }
5359
+ if ((brush.light?.amount ?? 0) > 0) {
5360
+ return "Wet mutation brushes use the wet light channel to track fading paint, so light.amount must be 0.";
5361
+ }
5362
+ }
5363
+ return null;
5364
+ }
5365
+
4424
5366
  // src/paint-editor/collaboration.ts
4425
5367
  var LAYER_FIELDS = ["name", "visible", "opacity", "blend"];
4426
5368
  function createPaintChangeFeed(controller, options = {}) {
@@ -5392,9 +6334,17 @@ var PaintEditorController = class _PaintEditorController {
5392
6334
  wetOptions;
5393
6335
  /** Where the light comes from for thick paint (saved with the document, not undo steps). */
5394
6336
  lighting = { ...DEFAULT_LIGHTING };
6337
+ /** Where the viewer looks from for thin-film iridescence and specular highlights (editor state, not saved with the document). */
6338
+ viewTilt = { ...DEFAULT_VIEW_TILT };
5395
6339
  litCaches = /* @__PURE__ */ new Map();
5396
6340
  /** The live impasto stroke's channels. */
5397
6341
  liveThick = null;
6342
+ /** The UV / blacklight setting (saved with the document, not undo steps). */
6343
+ uvLight = { ...DEFAULT_UV_LIGHT };
6344
+ uvCaches = /* @__PURE__ */ new Map();
6345
+ thermoCaches = /* @__PURE__ */ new Map();
6346
+ liveFluor = null;
6347
+ liveThermo = null;
5398
6348
  /** Set while an operation that changes wet paint runs: the simulation before it (for undo). */
5399
6349
  wetOp = null;
5400
6350
  /** The effects provider slot (see `setEffectsProvider`). */
@@ -5409,6 +6359,15 @@ var PaintEditorController = class _PaintEditorController {
5409
6359
  rasterizeSticker;
5410
6360
  effectsRenderer = new PaintEffectsRenderer();
5411
6361
  paperEffectNodeCache = null;
6362
+ mutations = [];
6363
+ nextMutationId = 1;
6364
+ mutationListeners = /* @__PURE__ */ new Set();
6365
+ mutationAgentConnected = false;
6366
+ mutationDirector = null;
6367
+ heldEvaporation = /* @__PURE__ */ new Map();
6368
+ tempLayerTargets = /* @__PURE__ */ new Map();
6369
+ answeringMutation = false;
6370
+ lastMutationTick = null;
5412
6371
  constructor(options = {}) {
5413
6372
  this.historyLimit = Math.max(1, options.historyLimit ?? 200);
5414
6373
  this.rasterizeSticker = options.rasterizeSticker ?? defaultStickerRasterizer;
@@ -5673,6 +6632,7 @@ var PaintEditorController = class _PaintEditorController {
5673
6632
  effectPaintRendering: Boolean(this.effectPaintPending || this.pathStroke),
5674
6633
  mixerLoad: this.mixerLoad,
5675
6634
  view: this.view,
6635
+ viewTilt: { ...this.viewTilt },
5676
6636
  hand: this.hand,
5677
6637
  effectPlayback: { ...this.effectPlaybackState, time: this.effectTime },
5678
6638
  selectedStroke: this.selectedStroke,
@@ -5698,6 +6658,10 @@ var PaintEditorController = class _PaintEditorController {
5698
6658
  gpu: this.wet.gpuMode,
5699
6659
  gpuActive: Boolean(this.wet.world?.gpuActive)
5700
6660
  },
6661
+ uvLight: { ...this.uvLight },
6662
+ heat: heatStats(this.doc.heat),
6663
+ mutations: this.mutations.map((m) => this.toMutationInfo(m)),
6664
+ hasMutationAgent: this.mutationListeners.size > 0 || this.mutationAgentConnected,
5701
6665
  version: this.version
5702
6666
  };
5703
6667
  return this.cached;
@@ -5804,7 +6768,10 @@ var PaintEditorController = class _PaintEditorController {
5804
6768
  const wetIds = ids.filter((id) => this.wet.wetLayerIds().includes(id));
5805
6769
  if (!wetIds.length) return [];
5806
6770
  this.beginWetOp();
5807
- for (const id of wetIds) this.wet.dry(id);
6771
+ for (const id of wetIds) {
6772
+ this.wet.dry(id);
6773
+ this.finishWetMutationsOnDry(id);
6774
+ }
5808
6775
  this.logWet({ kind: "dry", layerId: wetIds.join(",") });
5809
6776
  return wetIds;
5810
6777
  }
@@ -5840,6 +6807,7 @@ var PaintEditorController = class _PaintEditorController {
5840
6807
  this.wet.restore(e.wet.lost ? null : e.wet.before);
5841
6808
  if (paper !== this.doc.paper) this.wet.paperChanged();
5842
6809
  }
6810
+ this.reconcileMutationsAfterUndo();
5843
6811
  this.notifyCommit({ kind: "undo", label: e.label, origin: this.getOrigin() });
5844
6812
  this.emit();
5845
6813
  return e.label;
@@ -5896,6 +6864,7 @@ var PaintEditorController = class _PaintEditorController {
5896
6864
  this.beginWetOp();
5897
6865
  this.wet.reset();
5898
6866
  }
6867
+ this.resetMutations();
5899
6868
  this.commit("New canvas", next);
5900
6869
  return next;
5901
6870
  }
@@ -5907,11 +6876,13 @@ var PaintEditorController = class _PaintEditorController {
5907
6876
  height: update.height ?? this.doc.canvas.height,
5908
6877
  dpi: update.dpi ?? this.doc.canvas.dpi
5909
6878
  });
5910
- if (this.wet.world && (canvas.width !== this.doc.canvas.width || canvas.height !== this.doc.canvas.height)) {
6879
+ const resized = canvas.width !== this.doc.canvas.width || canvas.height !== this.doc.canvas.height;
6880
+ if (this.wet.world && resized) {
5911
6881
  this.dryLayersFirst(this.wet.wetLayerIds());
5912
6882
  this.wet.reset();
5913
6883
  }
5914
- this.commit("Canvas size", this.withWetEvents({ ...this.doc, canvas }));
6884
+ const heat = resized && this.doc.heat ? shiftHeatField(this.doc.heat, canvas.width, canvas.height, 0, 0) : this.doc.heat;
6885
+ this.commit("Canvas size", this.withWetEvents({ ...this.doc, canvas, ...heat ? { heat } : {} }));
5915
6886
  return canvas;
5916
6887
  }
5917
6888
  /**
@@ -5956,9 +6927,30 @@ var PaintEditorController = class _PaintEditorController {
5956
6927
  }
5957
6928
  }
5958
6929
  let next = { ...shiftDocument(this.withWetEvents(this.doc), left, top), canvas: { ...was, width, height } };
6930
+ if (next.heat) next = { ...next, heat: shiftHeatField(next.heat, left, top, width, height) };
5959
6931
  for (const id of keptWet) {
5960
6932
  const l = findLayer(next.layers, id)?.layer;
5961
- if (isPixelLayer(l)) next = { ...next, layers: mapLayer(next.layers, id, () => ({ ...l, surface: translateSurface(l.surface, l.offset.x, l.offset.y), offset: { x: 0, y: 0 } })) };
6933
+ if (isPixelLayer(l)) {
6934
+ const ox = l.offset.x;
6935
+ const oy = l.offset.y;
6936
+ next = { ...next, layers: mapLayer(next.layers, id, () => ({ ...l, surface: translateSurface(l.surface, ox, oy), offset: { x: 0, y: 0 } })) };
6937
+ const tc = next.thick?.[id];
6938
+ if (tc && (ox || oy)) {
6939
+ next = {
6940
+ ...next,
6941
+ thick: {
6942
+ ...next.thick,
6943
+ [id]: {
6944
+ height: translateSurface(tc.height, ox, oy),
6945
+ material: translateSurface(tc.material, ox, oy),
6946
+ film: translateSurface(tc.film, ox, oy)
6947
+ }
6948
+ }
6949
+ };
6950
+ }
6951
+ if (next.fluor?.[id] && (ox || oy)) next = { ...next, fluor: { ...next.fluor, [id]: translateSurface(next.fluor[id], ox, oy) } };
6952
+ if (next.thermo?.[id] && (ox || oy)) next = { ...next, thermo: { ...next.thermo, [id]: translateSurface(next.thermo[id], ox, oy) } };
6953
+ }
5962
6954
  }
5963
6955
  if (this.wet.world) {
5964
6956
  if (keptWet.length) this.wet.extend(width, height, left, top, next.paper);
@@ -6023,7 +7015,7 @@ var PaintEditorController = class _PaintEditorController {
6023
7015
  describeLayer(l, parentId = findLayer(this.doc.layers, l.id)?.parent?.id ?? null, depth = findLayer(this.doc.layers, l.id)?.path.length ?? 0) {
6024
7016
  let bounds = null;
6025
7017
  if (l.type === "pixel") {
6026
- const b = contentBounds(l.surface);
7018
+ const b = unionRect(contentBounds(l.surface), this.doc.fluor?.[l.id] ? contentBounds(this.doc.fluor[l.id]) : null);
6027
7019
  bounds = b ? { ...b, x: b.x + l.offset.x, y: b.y + l.offset.y } : null;
6028
7020
  } else for (const c of flattenLayers(l.children)) if (c.type === "pixel") bounds = unionRect(bounds, this.describeLayer(c, l.id, depth + 1).bounds);
6029
7021
  return {
@@ -6039,6 +7031,7 @@ var PaintEditorController = class _PaintEditorController {
6039
7031
  bounds,
6040
7032
  ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: this.doc.liveStrokes[l.id].length } : {},
6041
7033
  ...l.effects?.items.length ? { effects: l.effects.items.length } : {},
7034
+ ...l.type === "pixel" && l.temporary ? { temporary: true } : {},
6042
7035
  parentId,
6043
7036
  depth
6044
7037
  };
@@ -6059,7 +7052,8 @@ var PaintEditorController = class _PaintEditorController {
6059
7052
  return l;
6060
7053
  }
6061
7054
  setActiveLayer(id) {
6062
- this.requireLayer(id);
7055
+ const l = this.requireLayer(id);
7056
+ if (isPixelLayer(l) && l.temporary) throw new PaintEditorError("Temporary mutation layers fade away on their own; paint on the layer below.");
6063
7057
  if (this.doc.activeLayerId === id) return;
6064
7058
  this.doc = { ...this.doc, activeLayerId: id };
6065
7059
  this.emit();
@@ -6212,7 +7206,18 @@ var PaintEditorController = class _PaintEditorController {
6212
7206
  const layers = mapLayer(this.doc.layers, id, () => null);
6213
7207
  let active = this.doc.activeLayerId;
6214
7208
  if (active && !findLayer(layers, active)) active = flattenLayers(layers).filter((x) => x.type === "pixel").pop()?.id ?? null;
6215
- this.commit("Remove layer", { ...this.doc, layers, activeLayerId: active, ...this.thickWithout(flattenLayers([l]).map((x) => x.id)), ...this.stickersWithout(flattenLayers([l]).map((x) => x.id)), ...this.liveStrokesWithout(flattenLayers([l]).map((x) => x.id)), ...this.lightWithout(flattenLayers([l]).map((x) => x.id)) });
7209
+ const removedIds = flattenLayers([l]).map((x) => x.id);
7210
+ this.commit("Remove layer", {
7211
+ ...this.doc,
7212
+ layers,
7213
+ activeLayerId: active,
7214
+ ...this.thickWithout(removedIds),
7215
+ ...this.stickersWithout(removedIds),
7216
+ ...this.liveStrokesWithout(removedIds),
7217
+ ...this.lightWithout(removedIds),
7218
+ ...this.fluorWithout(removedIds),
7219
+ ...this.thermoWithout(removedIds)
7220
+ });
6216
7221
  return { removed: id, activeLayerId: active };
6217
7222
  }
6218
7223
  duplicateLayer(id) {
@@ -6227,10 +7232,14 @@ var PaintEditorController = class _PaintEditorController {
6227
7232
  const stickers = { ...this.doc.stickers ?? {} };
6228
7233
  const liveStrokes = { ...this.doc.liveStrokes ?? {} };
6229
7234
  const light = { ...this.doc.light ?? {} };
7235
+ const fluor = { ...this.doc.fluor ?? {} };
7236
+ const thermo = { ...this.doc.thermo ?? {} };
6230
7237
  const copy = (l) => {
6231
7238
  const id2 = newPaintLayerId();
6232
7239
  if (light[l.id]) light[id2] = light[l.id];
6233
7240
  if (this.lightSpills[l.id]) this.lightSpills[id2] = { ...this.lightSpills[l.id] };
7241
+ if (fluor[l.id]) fluor[id2] = fluor[l.id];
7242
+ if (thermo[l.id]) thermo[id2] = thermo[l.id];
6234
7243
  if (thick[l.id]) thick[id2] = thick[l.id];
6235
7244
  if (stickers[l.id]) stickers[id2] = stickers[l.id].map((x) => ({ ...x }));
6236
7245
  if (liveStrokes[l.id]) liveStrokes[id2] = liveStrokes[l.id].map((x) => ({ ...x, id: `s${this.strokeCounter++}`, layerId: id2 }));
@@ -6238,7 +7247,20 @@ var PaintEditorController = class _PaintEditorController {
6238
7247
  };
6239
7248
  const dup = { ...copy(loc.layer), name: `${loc.layer.name} copy` };
6240
7249
  const layers = insertLayer(this.doc.layers, dup, loc.parent?.id ?? null, loc.index + 1);
6241
- this.commit("Duplicate layer", this.withWetEvents({ ...this.doc, layers, activeLayerId: dup.id, ...this.doc.thick ? { thick } : {}, ...this.doc.stickers ? { stickers } : {}, ...this.doc.liveStrokes ? { liveStrokes } : {}, ...this.doc.light ? { light } : {} }));
7250
+ this.commit(
7251
+ "Duplicate layer",
7252
+ this.withWetEvents({
7253
+ ...this.doc,
7254
+ layers,
7255
+ activeLayerId: dup.id,
7256
+ ...this.doc.thick ? { thick } : {},
7257
+ ...this.doc.stickers ? { stickers } : {},
7258
+ ...this.doc.liveStrokes ? { liveStrokes } : {},
7259
+ ...this.doc.light ? { light } : {},
7260
+ ...this.doc.fluor ? { fluor } : {},
7261
+ ...this.doc.thermo ? { thermo } : {}
7262
+ })
7263
+ );
6242
7264
  return this.describeLayer(dup);
6243
7265
  }
6244
7266
  /** Moves a layer to `index` (0 = bottom) within `parentId` (default its own parent; `null` = top level). */
@@ -6285,10 +7307,30 @@ var PaintEditorController = class _PaintEditorController {
6285
7307
  const merged = { ...below, surface, offset: { x: 0, y: 0 } };
6286
7308
  const layers = mapLayer(mapLayer(this.doc.layers, upper.id, () => null), below.id, () => merged);
6287
7309
  const toDoc = (t, l) => t ? translateSurface(t, l.offset.x, l.offset.y) : void 0;
6288
- 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, translateSurface(upper.surface, upper.offset.x, upper.offset.y)) : void 0;
7310
+ const upperDoc = translateSurface(upper.surface, upper.offset.x, upper.offset.y);
7311
+ 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;
6289
7312
  const lightAfter = this.lightWithout([upper.id, below.id]).light ?? (this.doc.light ? { ...this.doc.light } : void 0);
6290
7313
  const light = mergedLight ? { ...lightAfter ?? {}, [below.id]: mergedLight } : lightAfter;
6291
- this.commit("Merge down", this.withWetEvents({ ...this.doc, layers, activeLayerId: below.id, ...this.thickWithout([upper.id]), ...this.stickersWithout([upper.id, below.id]), ...this.liveStrokesWithout([upper.id, below.id]), ...light ? { light } : {} }));
7314
+ const mergedFluor = this.doc.fluor?.[upper.id] || this.doc.fluor?.[below.id] ? mergeFourChannel(toDoc(this.doc.fluor?.[below.id], below), upper.visible ? toDoc(this.doc.fluor?.[upper.id], upper) : void 0) : void 0;
7315
+ const fluorAfter = this.fluorWithout([upper.id, below.id]).fluor ?? (this.doc.fluor ? { ...this.doc.fluor } : void 0);
7316
+ const fluor = mergedFluor ? { ...fluorAfter ?? {}, [below.id]: mergedFluor } : fluorAfter;
7317
+ const mergedThermo = this.doc.thermo?.[upper.id] || this.doc.thermo?.[below.id] ? mergeFourChannel(toDoc(this.doc.thermo?.[below.id], below), upper.visible ? toDoc(this.doc.thermo?.[upper.id], upper) : void 0) : void 0;
7318
+ const thermoAfter = this.thermoWithout([upper.id, below.id]).thermo ?? (this.doc.thermo ? { ...this.doc.thermo } : void 0);
7319
+ const thermo = mergedThermo ? { ...thermoAfter ?? {}, [below.id]: mergedThermo } : thermoAfter;
7320
+ this.commit(
7321
+ "Merge down",
7322
+ this.withWetEvents({
7323
+ ...this.doc,
7324
+ layers,
7325
+ activeLayerId: below.id,
7326
+ ...this.thickWithout([upper.id]),
7327
+ ...this.stickersWithout([upper.id, below.id]),
7328
+ ...this.liveStrokesWithout([upper.id, below.id]),
7329
+ ...light ? { light } : {},
7330
+ ...fluor ? { fluor } : {},
7331
+ ...thermo ? { thermo } : {}
7332
+ })
7333
+ );
6292
7334
  return this.describeLayer(merged);
6293
7335
  }
6294
7336
  /** Removes every pixel of a layer. */
@@ -6300,7 +7342,22 @@ var PaintEditorController = class _PaintEditorController {
6300
7342
  this.wet.world.removeLane(l.id);
6301
7343
  }
6302
7344
  const bounds = this.describeLayer(l).bounds;
6303
- this.commit("Clear layer", this.withPaintEvent({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: emptySurface(4) })), ...this.thickWithout([l.id]), ...this.stickersWithout([l.id]), ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {}, ...this.lightWithout([l.id]) }, { kind: "clear", layerId: l.id }));
7345
+ this.commit(
7346
+ "Clear layer",
7347
+ this.withPaintEvent(
7348
+ {
7349
+ ...this.doc,
7350
+ layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: emptySurface(4) })),
7351
+ ...this.thickWithout([l.id]),
7352
+ ...this.stickersWithout([l.id]),
7353
+ ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {},
7354
+ ...this.lightWithout([l.id]),
7355
+ ...this.fluorWithout([l.id]),
7356
+ ...this.thermoWithout([l.id])
7357
+ },
7358
+ { kind: "clear", layerId: l.id }
7359
+ )
7360
+ );
6304
7361
  return { layerId: l.id, bounds };
6305
7362
  }
6306
7363
  /**
@@ -6346,7 +7403,23 @@ var PaintEditorController = class _PaintEditorController {
6346
7403
  const surface = this.applyCoverage(l, coverage, rect, apply);
6347
7404
  const lit = this.doc.light?.[l.id];
6348
7405
  const light = lit ? withLayerLight(this.doc.light, l.id, scaleLight(lit, 1 - opacity)) : void 0;
6349
- this.commit("Fill layer", this.withPaintEvent(this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), ...light ? { light } : {} }), { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }));
7406
+ const fl = this.doc.fluor?.[l.id];
7407
+ const fluor = fl ? withLayerLight(this.doc.fluor, l.id, scaleFluor(fl, 1 - opacity)) : void 0;
7408
+ const th = this.doc.thermo?.[l.id];
7409
+ const thermo = th && opacity >= 0.5 ? this.thermoWithout([l.id]).thermo : void 0;
7410
+ this.commit(
7411
+ "Fill layer",
7412
+ this.withPaintEvent(
7413
+ this.withWetEvents({
7414
+ ...this.doc,
7415
+ layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })),
7416
+ ...light ? { light } : {},
7417
+ ...fluor ? { fluor } : {},
7418
+ ...thermo ? { thermo } : {}
7419
+ }),
7420
+ { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }
7421
+ )
7422
+ );
6350
7423
  return { layerId: l.id, bounds: rect };
6351
7424
  }
6352
7425
  /**
@@ -7237,6 +8310,8 @@ var PaintEditorController = class _PaintEditorController {
7237
8310
  }
7238
8311
  useBrush(next, spec, builtin) {
7239
8312
  next = normalizePaintBrush(next, next);
8313
+ const refusal = mutationBrushRefusal(next);
8314
+ if (refusal) throw new PaintEditorError(refusal);
7240
8315
  if (builtin) next.builtin = true;
7241
8316
  else delete next.builtin;
7242
8317
  this.brush = next;
@@ -7485,6 +8560,8 @@ var PaintEditorController = class _PaintEditorController {
7485
8560
  return l;
7486
8561
  })();
7487
8562
  const brush = this.strokeBrush(init);
8563
+ const mutRefusal = mutationBrushRefusal(brush);
8564
+ if (mutRefusal) throw new PaintEditorError(mutRefusal);
7488
8565
  if (this.previewing && isWetBrush(brush)) throw new PaintEditorError(`Wet paint can't be previewed; the ${brush.name} brush paints wet media. Suggest it with a dry brush.`);
7489
8566
  if (brush.ghost) {
7490
8567
  if (brush.light.amount > 0) throw new PaintEditorError(`Ghost paint (${brush.name}) shares the wet light channel and can't emit light; set light.amount to 0 or ghost to null (set_brush).`);
@@ -7508,6 +8585,12 @@ var PaintEditorController = class _PaintEditorController {
7508
8585
  throw new PaintEditorError(`Layer "${layer.name}" has ghost paint fading on it, which uses the wet light channel; paint light on another layer (add_layer), or finish the ghost first (dry_paint).`);
7509
8586
  }
7510
8587
  }
8588
+ if (brush.fluorescence.amount > 0 && brush.engine === "sticker") {
8589
+ throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take fluorescence; paint UV ink with a dab or particle brush (uv-marker; list_brush_presets lists them), or set fluorescence.amount to 0 (set_brush).`);
8590
+ }
8591
+ if (brush.thermochromic.hotColor !== null && brush.engine === "sticker") {
8592
+ throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take thermochromic ink; paint thermochromic ink with a dab or particle brush (thermo-ink; list_brush_presets lists them), or set thermochromic.hotColor to null (set_brush).`);
8593
+ }
7511
8594
  if (doc.liveStrokes?.[layer.id]) {
7512
8595
  const why = liveStrokeRefusal(brush);
7513
8596
  if (why) throw new PaintEditorError(`Layer "${layer.name}" is a stroke layer. ${why}`);
@@ -7529,9 +8612,16 @@ var PaintEditorController = class _PaintEditorController {
7529
8612
  if (isWetBrush(brush)) {
7530
8613
  this.requireWetIdle("painting");
7531
8614
  if (layer.offset.x || layer.offset.y) {
7532
- const moved = { ...layer, surface: translateSurface(layer.surface, layer.offset.x, layer.offset.y), offset: { x: 0, y: 0 } };
7533
- this.doc = { ...this.doc, layers: mapLayer(this.doc.layers, layer.id, () => moved) };
7534
- if (doc !== this.doc) doc = { ...doc, layers: mapLayer(doc.layers, layer.id, () => moved) };
8615
+ const ox = layer.offset.x;
8616
+ const oy = layer.offset.y;
8617
+ const moved = { ...layer, surface: translateSurface(layer.surface, ox, oy), offset: { x: 0, y: 0 } };
8618
+ const moveThick = (t) => {
8619
+ const tc = t?.[layer.id];
8620
+ if (!tc) return t;
8621
+ return { ...t, [layer.id]: { height: translateSurface(tc.height, ox, oy), material: translateSurface(tc.material, ox, oy), film: translateSurface(tc.film, ox, oy) } };
8622
+ };
8623
+ this.doc = { ...this.doc, layers: mapLayer(this.doc.layers, layer.id, () => moved), ...this.doc.thick?.[layer.id] ? { thick: moveThick(this.doc.thick) } : {} };
8624
+ if (doc !== this.doc) doc = { ...doc, layers: mapLayer(doc.layers, layer.id, () => moved), ...doc.thick?.[layer.id] ? { thick: moveThick(doc.thick) } : {} };
7535
8625
  layer = moved;
7536
8626
  }
7537
8627
  if (!this.wet.wetLayerIds().includes(layer.id) && this.wet.wetLayerIds().length >= MAX_WET_LANES) {
@@ -7546,6 +8636,9 @@ var PaintEditorController = class _PaintEditorController {
7546
8636
  const load = init.load ?? brushLoadForStroke(brush, this.mixerLoad, [r / 255, g / 255, b / 255]);
7547
8637
  const seedW = init.seed ?? mix32(this.seedCounter * 2654435761 >>> 0) & 2147483647;
7548
8638
  const copiesW = (init.copies ?? this.strokeCopies()).filter((m) => m.some((v, i) => v !== IDENTITY_AFFINE[i]));
8639
+ const wetMask = init.maskStencils ? this.stencilSelection(init.maskStencils) : init.stencils !== false ? this.stencilSelection() : null;
8640
+ const needWetFilm = Boolean(brush.wet.tool === "paint" && brush.iridescence || brush.wet.tool === "blot" && doc.thick?.[id]?.film.tiles.size);
8641
+ const wetFilm = needWetFilm ? new FilmBuffer((doc.thick?.[id] ?? emptyThickChannels()).film, layer.offset, wetMask) : null;
7549
8642
  const driver = new WetStrokeDriver({
7550
8643
  world,
7551
8644
  laneId: id,
@@ -7557,9 +8650,10 @@ var PaintEditorController = class _PaintEditorController {
7557
8650
  paperColor: hexToRgb8(doc.paper.color),
7558
8651
  seed: seedW,
7559
8652
  copies: [IDENTITY_AFFINE, ...copiesW].map(affineCopy),
7560
- mask: init.maskStencils ? this.stencilSelection(init.maskStencils) : init.stencils !== false ? this.stencilSelection() : null,
8653
+ mask: wetMask,
7561
8654
  paint: effect ? null : wetGradient,
7562
8655
  effect: effect?.pixels ?? null,
8656
+ film: wetFilm,
7563
8657
  speed: this.wet.speed
7564
8658
  });
7565
8659
  init = { ...init, seed: seedW, load };
@@ -7569,6 +8663,23 @@ var PaintEditorController = class _PaintEditorController {
7569
8663
  const dried = findLayer(this.doc.layers, layer.id).layer;
7570
8664
  layer = dried;
7571
8665
  }
8666
+ if (brush.mutation && !isWetBrush(brush) && !this.answeringMutation && !this.previewing && !this.getOrigin().startsWith("agent:")) {
8667
+ const targetLayer = layer;
8668
+ const existing = this.mutations.find((m) => m.state === "settling" && m.medium === "dry" && m.layerId === targetLayer.id && m.tempLayerId && findLayer(doc.layers, m.tempLayerId));
8669
+ if (existing?.tempLayerId) {
8670
+ layer = findLayer(doc.layers, existing.tempLayerId).layer;
8671
+ } else {
8672
+ const temp = {
8673
+ ...createPaintLayer("Mutation"),
8674
+ clipped: targetLayer.clipped,
8675
+ temporary: true
8676
+ };
8677
+ this.tempLayerTargets.set(temp.id, targetLayer.id);
8678
+ this.doc = { ...this.doc, layers: insertAfter(this.doc.layers, targetLayer.id, temp) };
8679
+ if (doc !== this.doc) doc = { ...doc, layers: insertAfter(doc.layers, targetLayer.id, temp) };
8680
+ layer = temp;
8681
+ }
8682
+ }
7572
8683
  let color = init.color === void 0 ? this.ownColour(init.brushId) ?? this.color : parseColor(init.color);
7573
8684
  const tracing = this.guides.tracing;
7574
8685
  if (init.color === void 0 && init.start && tracing?.visible && tracing.sampleColors) color = this.sampleTracingColor(init.start.x, init.start.y) ?? color;
@@ -7585,6 +8696,8 @@ var PaintEditorController = class _PaintEditorController {
7585
8696
  const rgba = rgbaOf(color);
7586
8697
  const seed = init.seed ?? mix32(this.seedCounter++ * 2654435761 >>> 0) & 2147483647;
7587
8698
  const copies = (init.copies ?? this.strokeCopies()).filter((m) => m.some((v, i) => v !== IDENTITY_AFFINE[i]));
8699
+ const hasThick = Boolean(doc.thick?.[layer.id]);
8700
+ const needThick = brush.engine === "impasto" || Boolean(brush.iridescence) || brush.engine === "eraser" && hasThick || Boolean(wet?.driver.init.film);
7588
8701
  const session = new PaintStrokeSession(host, {
7589
8702
  layerId: layer.id,
7590
8703
  surface: layer.surface,
@@ -7604,9 +8717,11 @@ var PaintEditorController = class _PaintEditorController {
7604
8717
  mixerLoad: init.load ?? brushLoadForStroke(brush, this.mixerLoad, [rgba[0] / 255, rgba[1] / 255, rgba[2] / 255]),
7605
8718
  secondary: rgbaOf(this.secondaryColor),
7606
8719
  wet,
7607
- thick: brush.engine === "impasto" ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
8720
+ thick: needThick ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
7608
8721
  // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
7609
8722
  light: !wet && brush.engine !== "sticker" && (brush.light.amount > 0 || doc.light?.[layer.id]) ? { surface: doc.light?.[layer.id] ?? emptyLight() } : null,
8723
+ fluor: brush.engine !== "sticker" && (brush.fluorescence.amount > 0 || doc.fluor?.[layer.id]) ? { surface: doc.fluor?.[layer.id] ?? emptyFluor() } : null,
8724
+ thermo: brush.engine !== "sticker" && (brush.thermochromic.hotColor !== null || doc.thermo?.[layer.id]) ? { surface: doc.thermo?.[layer.id] ?? emptyThermo() } : null,
7610
8725
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc), art: this.stickerArt.get, variant: (ref2) => this.stickerArt.variantFor(ref2, this.colorScheme === "dark") } : void 0,
7611
8726
  stampArt: this.stampArt,
7612
8727
  paint: effect ? null : this.strokeGradient(init, brush, color),
@@ -7647,12 +8762,18 @@ var PaintEditorController = class _PaintEditorController {
7647
8762
  const host = {
7648
8763
  update: (layerId, surface, rects) => {
7649
8764
  if (this.session?.init.wet) {
8765
+ const ch2 = this.session.thickChannels();
8766
+ this.liveThick = ch2 ? { layerId, channels: ch2 } : null;
7650
8767
  this.wet.syncDisplays();
7651
8768
  return;
7652
8769
  }
7653
8770
  this.live = { layerId, surface };
7654
8771
  const ch = this.session?.thickChannels();
7655
8772
  this.liveThick = ch ? { layerId, channels: ch } : null;
8773
+ const fl = this.session?.fluorChannel();
8774
+ this.liveFluor = fl ? { layerId, surface: fl } : null;
8775
+ const th = this.session?.thermoChannel();
8776
+ this.liveThermo = th ? { layerId, surface: th } : null;
7656
8777
  this.emit(rects);
7657
8778
  },
7658
8779
  sampled: (s, points) => {
@@ -7671,6 +8792,8 @@ var PaintEditorController = class _PaintEditorController {
7671
8792
  this.live = null;
7672
8793
  this.liveLayers = null;
7673
8794
  this.liveThick = null;
8795
+ this.liveFluor = null;
8796
+ this.liveThermo = null;
7674
8797
  const wetStroke = Boolean(s.init.wet);
7675
8798
  if (wetStroke) this.wet.driver = null;
7676
8799
  const brush = s.init.brush;
@@ -7683,6 +8806,8 @@ var PaintEditorController = class _PaintEditorController {
7683
8806
  if (wetStroke) this.wet.restore(snap);
7684
8807
  this.doc = { ...this.doc, layers: before.layers };
7685
8808
  this.wet.syncDisplays();
8809
+ } else if (this.doc.layers !== before.layers) {
8810
+ this.doc = { ...this.doc, layers: before.layers };
7686
8811
  }
7687
8812
  this.emit();
7688
8813
  this.notifySample(cancelled ? "cancel" : "end", s, [], cancelled ? void 0 : null);
@@ -7698,13 +8823,33 @@ var PaintEditorController = class _PaintEditorController {
7698
8823
  s.result.wet = this.wet.isWet(s.layerId);
7699
8824
  }
7700
8825
  const l = this.getLayer(s.layerId);
7701
- const ch = s.thickChannels();
7702
- const thick = ch ? { ...this.doc.thick ?? {}, [l.id]: ch } : this.doc.thick;
8826
+ const thick = withLayerThick(this.doc.thick, l.id, s.thickChannels());
7703
8827
  const sprayed = s.stickers();
7704
8828
  const stickers = sprayed ? { ...this.doc.stickers ?? {}, [l.id]: [...this.doc.stickers?.[l.id] ?? [], ...sprayed] } : this.doc.stickers;
7705
8829
  const wetLight = wetStroke && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null;
7706
8830
  const light = withLayerLight(this.doc.light, l.id, s.lightChannel() ?? wetLight);
7707
- this.commit(`${brush.name} stroke`, this.withWetEvents(withLiveStroke({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), strokes: [...this.doc.strokes, stroke], ...thick ? { thick } : {}, ...stickers ? { stickers } : {}, ...light ? { light } : {} }, stroke)), wetStroke ? before : this.doc);
8831
+ const fluor = withLayerLight(this.doc.fluor, l.id, s.fluorChannel());
8832
+ const thermo = withLayerLight(this.doc.thermo, l.id, s.thermoChannel());
8833
+ this.commit(
8834
+ `${brush.name} stroke`,
8835
+ this.withWetEvents(
8836
+ withLiveStroke(
8837
+ {
8838
+ ...this.doc,
8839
+ layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })),
8840
+ strokes: [...this.doc.strokes, stroke],
8841
+ ...thick ? { thick } : {},
8842
+ ...stickers ? { stickers } : {},
8843
+ ...light ? { light } : {},
8844
+ ...fluor ? { fluor } : {},
8845
+ ...thermo ? { thermo } : {}
8846
+ },
8847
+ stroke
8848
+ )
8849
+ ),
8850
+ before
8851
+ );
8852
+ this.recordMutationStroke(stroke, s.result?.bounds ?? null);
7708
8853
  this.notifySample("end", s, [], stroke.id);
7709
8854
  }
7710
8855
  };
@@ -7782,6 +8927,8 @@ var PaintEditorController = class _PaintEditorController {
7782
8927
  this.live = null;
7783
8928
  this.liveLayers = null;
7784
8929
  this.liveThick = null;
8930
+ this.liveFluor = null;
8931
+ this.liveThermo = null;
7785
8932
  const masks = s.init.selection ? (s.maskStencils ?? this.guides.stencils).filter((x) => x.enabled && x.mode === "mask") : [];
7786
8933
  const load = s.init.mixerLoad;
7787
8934
  const result = this.paintStroke({ layerId: s.layerId, points: s.points, color: s.init.colorHex, brush: brushToJSON(s.init.brush), seed: s.init.seed, copies: s.init.affineCopies, ...load.reservoir || load.pickup ? { load } : {}, paint, snapToPerspective: false, stencils: false, maskStencils: masks });
@@ -7800,6 +8947,8 @@ var PaintEditorController = class _PaintEditorController {
7800
8947
  this.live = null;
7801
8948
  this.liveLayers = null;
7802
8949
  this.liveThick = null;
8950
+ this.liveFluor = null;
8951
+ this.liveThermo = null;
7803
8952
  this.emit();
7804
8953
  this.notifySample("cancel", s, []);
7805
8954
  throw e;
@@ -7820,18 +8969,27 @@ var PaintEditorController = class _PaintEditorController {
7820
8969
  const stroke = this.record(s);
7821
8970
  s.result.strokeId = stroke.id;
7822
8971
  this.paintedBounds.set(stroke, s.result?.bounds ?? null);
8972
+ const targetId = this.tempLayerTargets.get(s.layerId);
8973
+ if (targetId && !findLayer(out.layers, s.layerId)) {
8974
+ const temp = findLayer(this.doc.layers, s.layerId)?.layer;
8975
+ if (temp) out = { ...out, layers: insertAfter(out.layers, targetId, temp) };
8976
+ }
7823
8977
  const l = findLayer(out.layers, s.layerId).layer;
7824
8978
  const offset = s.init.wet ? { x: 0, y: 0 } : l.offset;
7825
- const ch = s.thickChannels();
8979
+ const thick = withLayerThick(out.thick, l.id, s.thickChannels());
7826
8980
  const sprayed = s.stickers();
7827
8981
  const lit = s.lightChannel() ?? (s.init.wet && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null);
8982
+ const fl = s.fluorChannel();
8983
+ const th = s.thermoChannel();
7828
8984
  out = withLiveStroke({
7829
8985
  ...out,
7830
8986
  layers: mapLayer(out.layers, l.id, () => ({ ...l, surface, offset })),
7831
8987
  strokes: [...out.strokes, stroke],
7832
- ...ch ? { thick: { ...out.thick ?? {}, [l.id]: ch } } : {},
8988
+ ...thick ? { thick } : {},
7833
8989
  ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {},
7834
- ...lit ? { light: withLayerLight(out.light, l.id, lit) } : {}
8990
+ ...lit ? { light: withLayerLight(out.light, l.id, lit) } : {},
8991
+ ...fl ? { fluor: withLayerLight(out.fluor, l.id, fl) } : {},
8992
+ ...th ? { thermo: withLayerLight(out.thermo, l.id, th) } : {}
7835
8993
  }, stroke);
7836
8994
  }
7837
8995
  };
@@ -7864,8 +9022,11 @@ var PaintEditorController = class _PaintEditorController {
7864
9022
  }
7865
9023
  const { doc, result, brush } = run;
7866
9024
  if (result.mixerLoad) this.keepLoad(brush, result.mixerLoad);
7867
- if (doc !== before || this.wetOp) this.commit(`${brush.name} stroke`, this.withWetEvents(doc), before);
7868
- else this.emit([]);
9025
+ if (doc !== before || this.wetOp) {
9026
+ this.commit(`${brush.name} stroke`, this.withWetEvents(doc), before);
9027
+ const lastStroke = this.doc.strokes[this.doc.strokes.length - 1];
9028
+ if (lastStroke && lastStroke.id === result.strokeId) this.recordMutationStroke(lastStroke, result.bounds);
9029
+ } else this.emit([]);
7869
9030
  return result;
7870
9031
  }
7871
9032
  /**
@@ -7904,8 +9065,13 @@ var PaintEditorController = class _PaintEditorController {
7904
9065
  }
7905
9066
  const run = last;
7906
9067
  if (run?.result.mixerLoad) this.keepLoad(run.brush, run.result.mixerLoad);
7907
- if (doc !== before || this.wetOp) this.commit(`${run?.brush.name ?? "Brush"} path stroke`, this.withWetEvents(doc), before);
7908
- else this.emit([]);
9068
+ if (doc !== before || this.wetOp) {
9069
+ this.commit(`${run?.brush.name ?? "Brush"} path stroke`, this.withWetEvents(doc), before);
9070
+ for (const sid of strokeIds) {
9071
+ const recorded = this.doc.strokes.find((s) => s.id === sid);
9072
+ if (recorded) this.recordMutationStroke(recorded, this.paintedBounds.get(recorded) ?? null);
9073
+ }
9074
+ } else this.emit([]);
7909
9075
  const layerId = run?.result.layerId ?? stroke.layerId ?? this.doc.activeLayerId ?? "";
7910
9076
  return { layerId, bounds, dabs, strokeIds, ...run?.result.mixerLoad ? { mixerLoad: run.result.mixerLoad } : {}, ...run?.result.wet !== void 0 ? { wet: run.result.wet } : {} };
7911
9077
  }
@@ -8029,6 +9195,137 @@ var PaintEditorController = class _PaintEditorController {
8029
9195
  this.commit("Clear light", { ...this.doc, light: t.light });
8030
9196
  return { layerId: l.id, cleared: true };
8031
9197
  }
9198
+ // --- UV blacklight and thermochromic heat ---------------------------------------
9199
+ fluorWithout(ids) {
9200
+ const t = this.doc.fluor;
9201
+ if (!t || !ids.some((id) => t[id])) return {};
9202
+ const out = { ...t };
9203
+ for (const id of ids) delete out[id];
9204
+ return { fluor: out };
9205
+ }
9206
+ thermoWithout(ids) {
9207
+ const t = this.doc.thermo;
9208
+ if (!t || !ids.some((id) => t[id])) return {};
9209
+ const out = { ...t };
9210
+ for (const id of ids) delete out[id];
9211
+ return { thermo: out };
9212
+ }
9213
+ /** The UV / blacklight view setting. */
9214
+ getUvLight() {
9215
+ return { ...this.uvLight };
9216
+ }
9217
+ /** Layers that carry UV-fluorescent ink. */
9218
+ fluorescentLayerIds() {
9219
+ return Object.entries(this.doc.fluor ?? {}).filter(([id, s]) => s.tiles.size > 0 && Boolean(findLayer(this.doc.layers, id))).map(([id]) => id);
9220
+ }
9221
+ /** Layers that carry thermochromic ink. */
9222
+ thermochromicLayerIds() {
9223
+ return Object.entries(this.doc.thermo ?? {}).filter(([id, s]) => s.tiles.size > 0 && Boolean(findLayer(this.doc.layers, id))).map(([id]) => id);
9224
+ }
9225
+ /**
9226
+ * Switches or tunes the virtual UV / blacklight (`on`, `strength` 0..2, `ambient` 0..1). When
9227
+ * `on` is true, the composite dims to `ambient` and fluorescent ink (`brush.fluorescence`) glows
9228
+ * in its stroke colour with a Linear Dodge halo. Saved with the document, not an undo step.
9229
+ */
9230
+ setUvLight(update) {
9231
+ if (update.on !== void 0 && typeof update.on !== "boolean") throw new PaintEditorError("on must be true or false.");
9232
+ if (update.strength !== void 0 && !(typeof update.strength === "number" && Number.isFinite(update.strength) && update.strength >= 0 && update.strength <= 2)) {
9233
+ throw new PaintEditorError(`strength ${update.strength} is out of range; use 0..2.`);
9234
+ }
9235
+ if (update.ambient !== void 0 && !(typeof update.ambient === "number" && Number.isFinite(update.ambient) && update.ambient >= 0 && update.ambient <= 1)) {
9236
+ throw new PaintEditorError(`ambient ${update.ambient} is out of range; use 0..1.`);
9237
+ }
9238
+ this.uvLight = normalizeUvLight(update, this.uvLight);
9239
+ this.emit("all");
9240
+ return { ...this.uvLight };
9241
+ }
9242
+ /**
9243
+ * Reads the document's heat field: its temperature summary (`peak`, `mean`, `hotCells`) and,
9244
+ * when `points` are given (document pixels), the bilinearly sampled temperature (0..1) at each.
9245
+ */
9246
+ getHeat(points = []) {
9247
+ const samples = points.map((p, i) => {
9248
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) throw new PaintEditorError(`points[${i}] needs finite x and y in document pixels.`);
9249
+ return { x: p.x, y: p.y, temperature: this.doc.heat ? Math.round(sampleHeatAt(this.doc.heat, p.x, p.y) * 1e3) / 1e3 : 0 };
9250
+ });
9251
+ return { ...heatStats(this.doc.heat), samples, thermochromicLayerIds: this.thermochromicLayerIds() };
9252
+ }
9253
+ /**
9254
+ * Warms (or with `temperature` 0 cools) the canvas heat field along `points` (document pixels)
9255
+ * with a soft brush of `radius` px (default 28) towards `temperature` 0..1 (default 1). Where
9256
+ * temperature reaches `HEAT_DRY_THRESHOLD` (0.5), any wet paint on those tiles dries at once.
9257
+ * One undo step.
9258
+ */
9259
+ paintHeat(options) {
9260
+ this.requireIdle("painting heat");
9261
+ this.requireWetIdle("painting heat");
9262
+ const points = this.checkPoints(options.points);
9263
+ const radius = options.radius ?? 28;
9264
+ if (!(typeof radius === "number" && Number.isFinite(radius) && radius >= 1 && radius <= 512)) {
9265
+ throw new PaintEditorError(`radius ${options.radius} is out of range; use 1..512 px.`);
9266
+ }
9267
+ const temperature = options.temperature ?? 1;
9268
+ if (!(typeof temperature === "number" && Number.isFinite(temperature) && temperature >= 0 && temperature <= 1)) {
9269
+ throw new PaintEditorError(`temperature ${options.temperature} is out of range; use 0..1 (0 cools back to ambient).`);
9270
+ }
9271
+ const before = this.doc;
9272
+ const { width, height } = this.doc.canvas;
9273
+ const field = this.doc.heat ? cloneHeatField(this.doc.heat) : createHeatField(width, height);
9274
+ const { stats } = paintHeatField(field, points, radius, temperature);
9275
+ const dried = this.dryWetFromHeat(field);
9276
+ const next = { ...this.doc, ...stats.max > 0 ? { heat: field } : {} };
9277
+ if (stats.max === 0) delete next.heat;
9278
+ if (next !== before || this.wetOp) this.commit("Paint heat", this.withWetEvents(next), before);
9279
+ else this.emit([]);
9280
+ return { ...stats, dried };
9281
+ }
9282
+ /**
9283
+ * Advances the heat field by `steps` simulation steps (1..200, default 10): heat diffuses into
9284
+ * neighbouring cells and cools towards 0 by Newtonian cooling. Newly heated tiles above the dry
9285
+ * threshold dry any wet paint on them. Not an undo step (time passing never is).
9286
+ */
9287
+ advanceHeat(stepsOrOptions = 10) {
9288
+ this.requireIdle("advancing heat");
9289
+ this.requireWetIdle("advancing heat");
9290
+ const rawSteps = typeof stepsOrOptions === "number" ? stepsOrOptions : stepsOrOptions?.steps;
9291
+ const steps = rawSteps ?? 10;
9292
+ if (!(Number.isInteger(steps) && steps >= 1 && steps <= 200)) {
9293
+ throw new PaintEditorError(`steps ${rawSteps} is out of range; use a whole number 1..200.`);
9294
+ }
9295
+ if (!this.doc.heat || heatStats(this.doc.heat).max === 0) {
9296
+ return { steps, max: 0, mean: 0, hotCells: 0, bounds: null, dried: [] };
9297
+ }
9298
+ const field = cloneHeatField(this.doc.heat);
9299
+ const stats = advanceHeatField(field, steps);
9300
+ const dried = this.dryWetFromHeat(field);
9301
+ const next = { ...this.doc, ...stats.max > 0 ? { heat: field } : {} };
9302
+ if (stats.max === 0) delete next.heat;
9303
+ this.doc = this.withWetEvents(next);
9304
+ this.docRevision++;
9305
+ this.emit("all");
9306
+ return { steps, ...stats, dried };
9307
+ }
9308
+ /** Cools the whole canvas heat field to 0 at once (one undo step when anything was warm). */
9309
+ clearHeat() {
9310
+ this.requireIdle("clearing heat");
9311
+ if (!this.doc.heat || heatStats(this.doc.heat).max === 0) {
9312
+ return { cleared: false };
9313
+ }
9314
+ const { heat: _gone, ...rest } = this.doc;
9315
+ this.commit("Clear heat", rest);
9316
+ return { cleared: true };
9317
+ }
9318
+ /** Dries any wet paint on tiles where `field` is at or above `HEAT_DRY_THRESHOLD`. */
9319
+ dryWetFromHeat(field) {
9320
+ const ids = this.wet.wetLayerIds();
9321
+ if (!ids.length) return [];
9322
+ const hotRect = hotCellsBounds(field, HEAT_DRY_THRESHOLD);
9323
+ if (!hotRect) return [];
9324
+ this.beginWetOp();
9325
+ const dried = ids.flatMap((id) => this.wet.dry(id, hotRect)).filter((d) => d.bounds !== null);
9326
+ if (dried.length) this.logWet({ kind: "dry", data: hotRect });
9327
+ return dried;
9328
+ }
8032
9329
  // --- Thick paint and light -------------------------------------------------------
8033
9330
  /** The document's `thick` without these layers (as a spread: `{}` when unchanged). */
8034
9331
  thickWithout(ids) {
@@ -8050,13 +9347,29 @@ var PaintEditorController = class _PaintEditorController {
8050
9347
  this.emit("all");
8051
9348
  return { ...this.lighting };
8052
9349
  }
9350
+ /** Where the viewer looks from for thin-film iridescence and specular highlights (-1..1; editor state, not saved with the document). */
9351
+ getViewTilt() {
9352
+ return { ...this.viewTilt };
9353
+ }
9354
+ /** Tilts the view vector for thin-film iridescence and specular highlights (-1..1; editor state, not saved with the document, not an undo step). */
9355
+ setViewTilt(update) {
9356
+ if (update.x !== void 0 && !(Number.isFinite(update.x) && update.x >= -1 && update.x <= 1)) {
9357
+ throw new PaintEditorError(`x ${update.x} is out of range; use -1..1.`);
9358
+ }
9359
+ if (update.y !== void 0 && !(Number.isFinite(update.y) && update.y >= -1 && update.y <= 1)) {
9360
+ throw new PaintEditorError(`y ${update.y} is out of range; use -1..1.`);
9361
+ }
9362
+ this.viewTilt = normalizeViewTilt(update, this.viewTilt);
9363
+ this.emit("all");
9364
+ return { ...this.viewTilt };
9365
+ }
8053
9366
  /** A layer's relief: where it is, its highest point (0..255) and its tiles; `null` when it has none. */
8054
9367
  reliefOf(layerId) {
8055
9368
  const id = layerId ?? this.doc.activeLayerId;
8056
9369
  if (!id) return null;
8057
9370
  this.requireLayer(id);
8058
9371
  const ch = this.doc.thick?.[id];
8059
- if (!ch) return null;
9372
+ if (!ch || !hasThickChannels(ch)) return null;
8060
9373
  const r = reliefInfo(ch);
8061
9374
  return { layerId: id, bounds: r.bounds, maxHeight: r.maxHeight };
8062
9375
  }
@@ -8087,6 +9400,7 @@ var PaintEditorController = class _PaintEditorController {
8087
9400
  const paper = { ...this.doc.paper, scale: Math.min(8, this.doc.paper.scale * scale) };
8088
9401
  target.adopt({ state: { canvas: target.doc.canvas, paper, layers: emptiedLayers(this.doc.layers), activeLayerId: this.doc.activeLayerId, strokes: [] }, extensions: {}, problems: [] });
8089
9402
  target.lighting = { ...this.lighting };
9403
+ target.uvLight = { ...this.uvLight };
8090
9404
  target.effects.set(this.effects.provider);
8091
9405
  if (w === width && h === height) for (const [key, img] of this.effectPaintPixels) target.effectPaintPixels.set(key, img);
8092
9406
  return target;
@@ -8130,6 +9444,7 @@ var PaintEditorController = class _PaintEditorController {
8130
9444
  * bindings do). Steps at the wet speed unless paused; resolves when the frame's steps are done.
8131
9445
  */
8132
9446
  tickWet(now) {
9447
+ this.tickMutations(now);
8133
9448
  return this.wet.tick(now);
8134
9449
  }
8135
9450
  /** Resolves when the wet kernel is as fast as it will get here (`null` when nothing is wet yet). */
@@ -8204,7 +9519,9 @@ var PaintEditorController = class _PaintEditorController {
8204
9519
  async advanceWet(options) {
8205
9520
  const steps = options.steps ?? Math.round((options.seconds ?? 1) * this.wet.speed);
8206
9521
  if (!(Number.isInteger(steps) && steps >= 0 && steps <= 1e5)) throw new PaintEditorError(`steps ${steps} is out of range; use 0..100000 (or seconds).`);
9522
+ const seconds = options.seconds ?? steps / this.wet.speed;
8207
9523
  const run = await this.wet.advance(steps);
9524
+ if (seconds > 0) this.advanceMutations(seconds);
8208
9525
  this.emit();
8209
9526
  const st = this.wetState();
8210
9527
  return { steps: run, clock: st.clock, stillWet: st.wet, layers: st.layers };
@@ -8223,7 +9540,11 @@ var PaintEditorController = class _PaintEditorController {
8223
9540
  if (!ids.length) return { dried: [], stillWet: this.wet.isWet() };
8224
9541
  const before = this.doc;
8225
9542
  this.beginWetOp();
8226
- const dried = ids.flatMap((id) => this.wet.dry(id, rect));
9543
+ const dried = ids.flatMap((id) => {
9544
+ const r = this.wet.dry(id, rect);
9545
+ if (!rect) this.finishWetMutationsOnDry(id);
9546
+ return r;
9547
+ });
8227
9548
  this.logWet({ kind: "dry", layerId: options.layerId, data: rect });
8228
9549
  this.commit("Dry paint", this.withWetEvents(this.doc), before);
8229
9550
  return { dried, stillWet: this.wet.isWet() };
@@ -8257,6 +9578,15 @@ var PaintEditorController = class _PaintEditorController {
8257
9578
  setWetParams(layerId, update) {
8258
9579
  const l = this.wetLayer(layerId, "change the wet settings of");
8259
9580
  this.requireWetIdle("changing wet settings");
9581
+ if (this.heldEvaporation.has(l.id)) {
9582
+ const current = this.wet.info(l.id).params;
9583
+ const intended = normalizeWetParams({ ...current, evaporation: this.heldEvaporation.get(l.id), ...update });
9584
+ this.heldEvaporation.set(l.id, intended.evaporation);
9585
+ this.wet.setParams(l.id, { ...update, evaporation: 0 });
9586
+ this.recordWetSetting({ kind: "params", layerId: l.id, data: intended });
9587
+ this.emit([]);
9588
+ return intended;
9589
+ }
8260
9590
  const params = this.wet.setParams(l.id, update);
8261
9591
  this.recordWetSetting({ kind: "params", layerId: l.id, data: params });
8262
9592
  this.emit([]);
@@ -8362,6 +9692,55 @@ var PaintEditorController = class _PaintEditorController {
8362
9692
  }
8363
9693
  return out;
8364
9694
  }
9695
+ /** The layers with thermochromic paint shifted toward its hot colour where the canvas is warm. */
9696
+ thermoLayers(layers) {
9697
+ const thermo = this.doc.thermo;
9698
+ const heat = this.doc.heat;
9699
+ if (!thermo || !heat || heat.max === 0) return layers;
9700
+ let out = layers;
9701
+ for (const [id, th] of Object.entries(thermo)) {
9702
+ const l = findLayer(out, id)?.layer;
9703
+ if (!isPixelLayer(l) || !th.tiles.size) continue;
9704
+ let cache = this.thermoCaches.get(id);
9705
+ if (!cache) this.thermoCaches.set(id, cache = new ThermoCache());
9706
+ const shifted = cache.render(l.surface, th, l.offset, heat);
9707
+ if (shifted !== l.surface) out = mapLayer(out, id, () => ({ ...l, surface: shifted }));
9708
+ }
9709
+ return out;
9710
+ }
9711
+ /**
9712
+ * Under the UV blacklight: every pixel layer's daylight paint dims to `uvLight.ambient`, any
9713
+ * fluorescent ink (`doc.fluor[id]`) glows in its own colour, and each fluorescent layer casts a
9714
+ * Linear Dodge (`::uv`) spill layer directly above it.
9715
+ */
9716
+ uvLayers(layers) {
9717
+ if (!this.uvLight.on) return layers;
9718
+ const fluor = this.doc.fluor ?? {};
9719
+ const visit = (list) => {
9720
+ let out = [];
9721
+ for (const l of list) {
9722
+ if (l.type === "group") {
9723
+ out.push({ ...l, children: visit(l.children) });
9724
+ continue;
9725
+ }
9726
+ if (!isPixelLayer(l)) {
9727
+ out.push(l);
9728
+ continue;
9729
+ }
9730
+ let cache = this.uvCaches.get(l.id);
9731
+ if (!cache) this.uvCaches.set(l.id, cache = new UvCache());
9732
+ const fl = fluor[l.id];
9733
+ const surface = cache.render(l.surface, fl, this.uvLight);
9734
+ out.push(surface !== l.surface ? { ...l, surface } : l);
9735
+ const spill = fl ? cache.spill(fl, this.uvLight) : null;
9736
+ if (spill) {
9737
+ out.push({ type: "pixel", id: `${l.id}${UV_SPILL_SUFFIX}`, name: `${l.name} UV`, visible: l.visible, opacity: l.opacity, blend: "linear-dodge", surface: spill, offset: { ...l.offset } });
9738
+ }
9739
+ }
9740
+ return out;
9741
+ };
9742
+ return visit(layers);
9743
+ }
8365
9744
  /** The layers with thick paint lit (each thick layer's pixels replaced by its lit tiles). */
8366
9745
  litLayers(layers) {
8367
9746
  const thick = this.doc.thick;
@@ -8375,7 +9754,7 @@ var PaintEditorController = class _PaintEditorController {
8375
9754
  if (!ch || !isPixelLayer(l)) continue;
8376
9755
  let cache = this.litCaches.get(id);
8377
9756
  if (!cache) this.litCaches.set(id, cache = new LitCache());
8378
- const lit = cache.lit(l.surface, ch, this.lighting);
9757
+ const lit = cache.lit(l.surface, ch, this.lighting, this.viewTilt);
8379
9758
  if (lit !== l.surface) out = mapLayer(out, id, () => ({ ...l, surface: lit }));
8380
9759
  }
8381
9760
  return out;
@@ -8433,7 +9812,7 @@ var PaintEditorController = class _PaintEditorController {
8433
9812
  }
8434
9813
  renderLayers(options, view = false) {
8435
9814
  const base = (options.wet ?? "display") === "dried" ? this.driedLayers() : this.currentLayers();
8436
- let layers = this.lightLayers(this.litLayers(base));
9815
+ let layers = this.lightLayers(this.uvLayers(this.litLayers(this.thermoLayers(base))));
8437
9816
  const backlight = options.paper !== false && !options.layerIds ? this.backlightLayer(base) : null;
8438
9817
  if (backlight) layers = [...layers, backlight];
8439
9818
  if (options.layerIds) {
@@ -8441,7 +9820,7 @@ var PaintEditorController = class _PaintEditorController {
8441
9820
  for (const id of options.layerIds) picked.push(findLayer(layers, id)?.layer ?? this.requireLayer(id));
8442
9821
  layers = picked.map((l) => ({ ...l, visible: true }));
8443
9822
  }
8444
- const under = options.paper === false ? [] : [paperNode(this.doc.paper, this.lighting)];
9823
+ const under = options.paper === false ? [] : [paperNode(uvPaper(this.doc.paper, this.uvLight), this.lighting)];
8445
9824
  if (options.paper !== false && this.doc.paperEffect) under.push(this.paperEffectNode(this.doc.paperEffect));
8446
9825
  if (view) under.push(...this.underlayLayers());
8447
9826
  return [...under, ...layers];
@@ -8476,8 +9855,36 @@ var PaintEditorController = class _PaintEditorController {
8476
9855
  /** The document as JSON with inline PNG assets. */
8477
9856
  /** The document as saved: wet paint dried where it is (deterministically); wet settings as data. */
8478
9857
  savedState() {
8479
- const layers = this.driedLayers(this.doc.layers);
8480
- return layers === this.doc.layers ? this.doc : { ...this.doc, layers };
9858
+ let state = this.doc;
9859
+ for (const m of this.mutations) {
9860
+ if (m.medium !== "dry" || !m.tempLayerId || !findLayer(state.layers, m.tempLayerId)) continue;
9861
+ if (m.state === "claimed" || m.state === "answered") {
9862
+ const tempId = m.tempLayerId;
9863
+ state = {
9864
+ ...state,
9865
+ layers: mapLayer(state.layers, tempId, () => null),
9866
+ strokes: state.strokes.filter((s) => s.layerId !== tempId)
9867
+ };
9868
+ } else {
9869
+ state = this.mergeTempLayerIntoTarget(state, m.tempLayerId, m.layerId);
9870
+ }
9871
+ }
9872
+ for (const l of flattenLayers(state.layers)) {
9873
+ if (isPixelLayer(l) && l.temporary) {
9874
+ const targetId = this.tempLayerTargets.get(l.id);
9875
+ if (targetId && findLayer(state.layers, targetId)) {
9876
+ state = this.mergeTempLayerIntoTarget(state, l.id, targetId);
9877
+ } else {
9878
+ state = {
9879
+ ...state,
9880
+ layers: mapLayer(state.layers, l.id, () => null),
9881
+ strokes: state.strokes.filter((s) => s.layerId !== l.id)
9882
+ };
9883
+ }
9884
+ }
9885
+ }
9886
+ const layers = this.driedLayers(state.layers);
9887
+ return layers === state.layers ? state : { ...state, layers };
8481
9888
  }
8482
9889
  /** Wet settings saved with the document (`null` when they are all defaults). */
8483
9890
  wetJSON() {
@@ -8485,7 +9892,10 @@ var PaintEditorController = class _PaintEditorController {
8485
9892
  for (const [id, set] of this.wet.settings) {
8486
9893
  if (!findLayer(this.doc.layers, id)) continue;
8487
9894
  const ghost = this.wet.world?.hasLane(id) ? this.wet.world.getGhost(id) : set.ghost ?? null;
8488
- layers[id] = { params: { ...set.params }, flow: { ...set.flow }, ...ghost ? { ghost } : {} };
9895
+ const savedGhost = ghost?.trigger === "agent" ? null : ghost;
9896
+ const heldEvap = this.heldEvaporation.get(id);
9897
+ const params = heldEvap !== void 0 ? { ...set.params, evaporation: heldEvap } : { ...set.params };
9898
+ layers[id] = { params, flow: { ...set.flow }, ...savedGhost ? { ghost: savedGhost } : {} };
8489
9899
  }
8490
9900
  const flat = this.wet.tilt.angle === 0;
8491
9901
  if (!Object.keys(layers).length && flat) return void 0;
@@ -8550,13 +9960,37 @@ var PaintEditorController = class _PaintEditorController {
8550
9960
  else delete ext.paperEffect;
8551
9961
  if (this.lighting.azimuth !== DEFAULT_LIGHTING.azimuth || this.lighting.elevation !== DEFAULT_LIGHTING.elevation || this.lighting.relief !== DEFAULT_LIGHTING.relief) ext.lighting = { ...this.lighting };
8552
9962
  else delete ext.lighting;
9963
+ const fluor = {};
9964
+ for (const [id, f] of Object.entries(this.doc.fluor ?? {})) {
9965
+ if (!findLayer(this.doc.layers, id)) continue;
9966
+ const saved = fluorToJSON(f);
9967
+ if (saved) fluor[id] = saved;
9968
+ }
9969
+ if (Object.keys(fluor).length) ext.fluor = fluor;
9970
+ else delete ext.fluor;
9971
+ const thermo = {};
9972
+ for (const [id, th] of Object.entries(this.doc.thermo ?? {})) {
9973
+ if (!findLayer(this.doc.layers, id)) continue;
9974
+ const saved = thermoToJSON(th);
9975
+ if (saved) thermo[id] = saved;
9976
+ }
9977
+ if (Object.keys(thermo).length) ext.thermo = thermo;
9978
+ else delete ext.thermo;
9979
+ const savedHeat = this.doc.heat ? heatToJSON(this.doc.heat) : null;
9980
+ if (savedHeat) ext.heat = savedHeat;
9981
+ else delete ext.heat;
9982
+ if (this.uvLight.on !== DEFAULT_UV_LIGHT.on || this.uvLight.strength !== DEFAULT_UV_LIGHT.strength || this.uvLight.ambient !== DEFAULT_UV_LIGHT.ambient) ext.uvLight = { ...this.uvLight };
9983
+ else delete ext.uvLight;
8553
9984
  return ext;
8554
9985
  }
8555
9986
  /** Reads `layerChannels` and `lighting` from loaded extensions into the document. */
8556
9987
  readThick(state, ext, problems) {
8557
9988
  if (Array.isArray(ext.brushSources)) this.brushSources.set(ext.brushSources.map((x, i) => readBrushSource(x, `brushSources[${i}]`, problems)).filter((x) => x !== null));
8558
9989
  this.lighting = normalizeLighting(ext.lighting ?? {}, DEFAULT_LIGHTING);
9990
+ this.uvLight = normalizeUvLight(ext.uvLight ?? {}, DEFAULT_UV_LIGHT);
8559
9991
  this.litCaches.clear();
9992
+ this.uvCaches.clear();
9993
+ this.thermoCaches.clear();
8560
9994
  if (ext.stickers && typeof ext.stickers === "object") {
8561
9995
  const stickers = {};
8562
9996
  for (const [id, list] of Object.entries(ext.stickers)) {
@@ -8627,6 +10061,35 @@ var PaintEditorController = class _PaintEditorController {
8627
10061
  }
8628
10062
  if (Object.keys(light).length) state = { ...state, light };
8629
10063
  }
10064
+ if (ext.fluor && typeof ext.fluor === "object") {
10065
+ const fluor = {};
10066
+ for (const [id, v] of Object.entries(ext.fluor)) {
10067
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer)) continue;
10068
+ try {
10069
+ fluor[id] = fluorFromJSON(v);
10070
+ } catch (e) {
10071
+ problems.push(`The fluorescent ink of layer "${id}" could not be read (${e.message}); it loads without fluorescence.`);
10072
+ }
10073
+ }
10074
+ if (Object.keys(fluor).length) state = { ...state, fluor };
10075
+ }
10076
+ if (ext.thermo && typeof ext.thermo === "object") {
10077
+ const thermo = {};
10078
+ for (const [id, v] of Object.entries(ext.thermo)) {
10079
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer)) continue;
10080
+ try {
10081
+ thermo[id] = thermoFromJSON(v);
10082
+ } catch (e) {
10083
+ problems.push(`The thermochromic ink of layer "${id}" could not be read (${e.message}); it loads without thermochromic shift.`);
10084
+ }
10085
+ }
10086
+ if (Object.keys(thermo).length) state = { ...state, thermo };
10087
+ }
10088
+ if (ext.heat !== void 0) {
10089
+ const heat = heatFromJSON(ext.heat, state.canvas.width, state.canvas.height);
10090
+ if (heat) state = { ...state, heat };
10091
+ else problems.push("The heat field could not be read; the canvas loads cool.");
10092
+ }
8630
10093
  const raw = ext.layerChannels;
8631
10094
  if (!raw || typeof raw !== "object") return state;
8632
10095
  const thick = {};
@@ -8659,6 +10122,7 @@ var PaintEditorController = class _PaintEditorController {
8659
10122
  }
8660
10123
  this.undoStack = [];
8661
10124
  this.redoStack = [];
10125
+ this.resetMutations();
8662
10126
  this.doc = this.readThick(loaded.state, loaded.extensions, loaded.problems);
8663
10127
  for (const list of Object.values(this.doc.liveStrokes ?? {})) {
8664
10128
  for (const s of list) {
@@ -8676,6 +10140,10 @@ var PaintEditorController = class _PaintEditorController {
8676
10140
  delete this.extensions.brushSources;
8677
10141
  delete this.extensions.effectLayers;
8678
10142
  delete this.extensions.paperEffect;
10143
+ delete this.extensions.fluor;
10144
+ delete this.extensions.thermo;
10145
+ delete this.extensions.heat;
10146
+ delete this.extensions.uvLight;
8679
10147
  this.viewCompositor.clear();
8680
10148
  this.emit();
8681
10149
  const refs = Object.values(this.doc.stickers ?? {}).flatMap((list) => list.map((x) => x.ref));
@@ -8737,7 +10205,7 @@ var PaintEditorController = class _PaintEditorController {
8737
10205
  exportPsd() {
8738
10206
  const driedBase = this.driedLayers(this.doc.layers);
8739
10207
  const backlight = this.backlightLayer(driedBase);
8740
- const dried = [...this.lightLayers(this.litLayers(driedBase)), ...backlight ? [backlight] : []];
10208
+ const dried = [...this.lightLayers(this.uvLayers(this.litLayers(this.thermoLayers(driedBase)))), ...backlight ? [backlight] : []];
8741
10209
  const { width, height, dpi } = this.doc.canvas;
8742
10210
  const paperPixels = this.compositePixels({ layerIds: [], paper: true });
8743
10211
  const paperLayer = {
@@ -9420,8 +10888,24 @@ var PaintEditorController = class _PaintEditorController {
9420
10888
  withLiveStrokes(doc, layer, list, dirty) {
9421
10889
  if (isLayerLocked(layer, "pixels")) throw new PaintEditorError(`Layer "${layer.name}" has its pixels locked; unlock it (update_layer locks) first.`);
9422
10890
  const current = findLayer(doc.layers, layer.id)?.layer;
9423
- const surface = this.drawLiveStrokes(current ?? layer, list, dirty);
9424
- const next = { ...doc, layers: mapLayer(doc.layers, layer.id, (l) => ({ ...l, surface, offset: { x: 0, y: 0 } })), liveStrokes: { ...doc.liveStrokes ?? {}, [layer.id]: [...list] } };
10891
+ const hasFilm = list.some((s) => Boolean(s.brush.iridescence)) || Boolean(doc.thick?.[layer.id]);
10892
+ const surface = this.drawLiveStrokes(current ?? layer, list, hasFilm ? null : dirty);
10893
+ let thick = doc.thick;
10894
+ if (hasFilm) {
10895
+ const plain = { ...current ?? layer, visible: true, locks: void 0, offset: { x: 0, y: 0 } };
10896
+ const plainDoc = { ...doc, liveStrokes: void 0, stickers: void 0 };
10897
+ let s = emptySurface(4);
10898
+ let ch = list.some((st) => Boolean(st.brush.iridescence)) ? emptyThickChannels() : null;
10899
+ if (ch) {
10900
+ for (const st of list) {
10901
+ const step = this.drawLiveStroke(plainDoc, plain, s, st, ch);
10902
+ s = step.surface;
10903
+ ch = step.thick ?? ch;
10904
+ }
10905
+ }
10906
+ thick = withLayerThick(doc.thick, layer.id, ch);
10907
+ }
10908
+ const next = { ...doc, layers: mapLayer(doc.layers, layer.id, (l) => ({ ...l, surface, offset: { x: 0, y: 0 } })), liveStrokes: { ...doc.liveStrokes ?? {}, [layer.id]: [...list] }, ...thick ? { thick } : {} };
9425
10909
  return this.withPaintEvent(next, { kind: "live-strokes", layerId: layer.id, data: { strokes: list.map((x) => JSON.parse(JSON.stringify(x))) } });
9426
10910
  }
9427
10911
  /** Where a stroke painted (document pixels): as learnt when it was painted or drawn, else an estimate from its points and brush. */
@@ -9439,23 +10923,27 @@ var PaintEditorController = class _PaintEditorController {
9439
10923
  return out;
9440
10924
  }
9441
10925
  /** Draws one stroke onto `surface` of a plain copy of the layer; returns the result and the area it painted. */
9442
- drawLiveStroke(doc, plain, surface, st) {
10926
+ drawLiveStroke(doc, plain, surface, st, thick) {
9443
10927
  let out = surface;
10928
+ let outThick = thick;
9444
10929
  const host = {
9445
10930
  update: () => {
9446
10931
  },
9447
- commit: (_s, drawn, cancelled) => {
9448
- if (!cancelled && drawn) out = drawn;
10932
+ commit: (s, drawn, cancelled) => {
10933
+ if (!cancelled && drawn) {
10934
+ out = drawn;
10935
+ outThick = s.thickChannels() ?? outThick;
10936
+ }
9449
10937
  }
9450
10938
  };
9451
10939
  const paint = st.paint && this.liveEffectAt !== null && isLiveEffect(st) ? framePaint(st.paint, this.liveEffectAt) : st.paint;
9452
10940
  const init = { layerId: plain.id, color: st.color, brush: st.brush, seed: st.seed, copies: st.copies ?? [], ...st.load ? { load: st.load } : {}, ...paint ? { paint } : {}, snapToPerspective: false, stencils: false, maskStencils: st.stencils ?? [] };
9453
- const session = this.sessionFor({ ...doc, layers: mapLayer(doc.layers, plain.id, () => ({ ...plain, surface })) }, init, host);
10941
+ const session = this.sessionFor({ ...doc, layers: mapLayer(doc.layers, plain.id, () => ({ ...plain, surface })), ...outThick ? { thick: { ...doc.thick ?? {}, [plain.id]: outThick } } : {} }, init, host);
9454
10942
  session.push(st.points);
9455
10943
  const result = session.end();
9456
10944
  const had = this.paintedBounds.get(st);
9457
10945
  this.paintedBounds.set(st, had && result.bounds ? unionRect(had, result.bounds) : result.bounds ?? had ?? null);
9458
- return { surface: out, bounds: result.bounds };
10946
+ return { surface: out, bounds: result.bounds, thick: outThick };
9459
10947
  }
9460
10948
  /**
9461
10949
  * A stroke layer's pixels drawn from its strokes, bottom first. With `dirty` (the areas the old
@@ -9839,6 +11327,532 @@ var PaintEditorController = class _PaintEditorController {
9839
11327
  });
9840
11328
  }
9841
11329
  }
11330
+ // --- Mutation paint --------------------------------------------------------------
11331
+ /**
11332
+ * Hears each mutation once it has settled (`settle` seconds after the user's last mutation
11333
+ * stroke). Registering a listener marks an agent as connected (`state.hasMutationAgent`).
11334
+ */
11335
+ onMutationRequest(listener) {
11336
+ const had = this.mutationListeners.size > 0;
11337
+ this.mutationListeners.add(listener);
11338
+ if (!had) this.emit([]);
11339
+ return () => {
11340
+ this.mutationListeners.delete(listener);
11341
+ if (this.mutationListeners.size === 0) this.emit([]);
11342
+ };
11343
+ }
11344
+ /** Marks whether a mutation-answering agent is connected (for hosts that wake agents via tools). */
11345
+ setMutationAgentConnected(connected) {
11346
+ if (this.mutationAgentConnected === connected) return;
11347
+ this.mutationAgentConnected = connected;
11348
+ this.emit([]);
11349
+ }
11350
+ /** Attaches a `PaintDirector` so `answerMutation` and `readMutation` animate the agent's cursor. */
11351
+ setMutationDirector(director) {
11352
+ this.mutationDirector = director;
11353
+ }
11354
+ /** Lists pending mutations (or all mutations when `includeAll` is true). */
11355
+ listMutations(options = {}) {
11356
+ const list = options.includeAll ? this.mutations : this.mutations.filter((m) => m.state === "pending" || m.state === "settling");
11357
+ return list.map((m) => this.toMutationInfo(m));
11358
+ }
11359
+ /** One mutation by id (`undefined` when unknown). */
11360
+ getMutation(id) {
11361
+ const m = this.mutations.find((x) => x.id === id);
11362
+ return m ? this.toMutationInfo(m) : void 0;
11363
+ }
11364
+ /**
11365
+ * Claims a mutation (`id`) and starts its fade. Returns the strokes (points, colour, brush), a
11366
+ * PNG of the area around them (`cropPng`), and a preview of the whole painting (`canvasPng`) so
11367
+ * the agent can choose where to answer. Refused if already claimed, answered or declined.
11368
+ */
11369
+ readMutation(id, options = {}) {
11370
+ const m = this.mutations.find((x) => x.id === id);
11371
+ if (!m) throw new PaintEditorError(`Unknown mutation "${id}". Call list_mutations for the pending ids.`);
11372
+ if (m.state === "claimed" || m.state === "answered") {
11373
+ throw new PaintEditorError(`Mutation "${id}" has already been claimed${m.claimedBy ? ` by ${m.claimedBy}` : ""}.`);
11374
+ }
11375
+ if (m.state === "declined") {
11376
+ throw new PaintEditorError(`Mutation "${id}" was already declined.`);
11377
+ }
11378
+ const { width, height } = this.doc.canvas;
11379
+ const pad = Math.max(0, Math.round(options.padding ?? 32));
11380
+ const baseRect = m.bounds ?? { x: 0, y: 0, width, height };
11381
+ const cropRect = clipRect(
11382
+ { x: baseRect.x - pad, y: baseRect.y - pad, width: baseRect.width + pad * 2, height: baseRect.height + pad * 2 },
11383
+ width,
11384
+ height
11385
+ ) ?? { x: 0, y: 0, width, height };
11386
+ const full2 = this.compositePixels({ wet: "display" });
11387
+ const cropData = new Uint8ClampedArray(cropRect.width * cropRect.height * 4);
11388
+ for (let y = 0; y < cropRect.height; y++) {
11389
+ const srcRow = ((cropRect.y + y) * width + cropRect.x) * 4;
11390
+ cropData.set(full2.data.subarray(srcRow, srcRow + cropRect.width * 4), y * cropRect.width * 4);
11391
+ }
11392
+ const cropPngBase64 = bytesToBase64(encodePng(cropRect.width, cropRect.height, cropData));
11393
+ const preview = this.renderPreview({ wet: "display", maxSide: options.maxSide ?? 512 });
11394
+ const canvasPngBase64 = bytesToBase64(encodePng(preview.width, preview.height, preview.data));
11395
+ const actorId = options.actor ?? (this.getOrigin() !== "user" ? this.getOrigin() : "agent");
11396
+ m.state = "claimed";
11397
+ m.claimedBy = actorId;
11398
+ m.fadeRemaining = m.fade;
11399
+ if (m.medium === "wet") {
11400
+ this.releaseMutationEvaporation(m.layerId);
11401
+ this.wet.releaseGhost(m.layerId, m.fade);
11402
+ this.recordWetSetting({ kind: "ghost-release", layerId: m.layerId, data: { linger: m.fade } });
11403
+ }
11404
+ if (this.mutationDirector && m.bounds) {
11405
+ const pid = actorId.replace(/^agent:/, "") || "mutation-agent";
11406
+ this.mutationDirector.presence.set({
11407
+ id: pid,
11408
+ name: "Agent",
11409
+ color: "#7c3aed",
11410
+ x: m.bounds.x + m.bounds.width / 2,
11411
+ y: m.bounds.y + m.bounds.height / 2,
11412
+ message: "Reading\u2026"
11413
+ });
11414
+ }
11415
+ this.emit([]);
11416
+ return {
11417
+ id: m.id,
11418
+ state: "claimed",
11419
+ layerId: m.layerId,
11420
+ medium: m.medium,
11421
+ bounds: m.bounds ? { ...m.bounds } : null,
11422
+ fade: m.fade,
11423
+ strokes: m.strokes.map((s) => ({
11424
+ ...s,
11425
+ points: s.points.map((p) => ({ ...p })),
11426
+ bounds: s.bounds ? { ...s.bounds } : null
11427
+ })),
11428
+ cropPng: {
11429
+ width: cropRect.width,
11430
+ height: cropRect.height,
11431
+ rect: cropRect,
11432
+ data: cropPngBase64
11433
+ },
11434
+ canvasPng: {
11435
+ width: preview.width,
11436
+ height: preview.height,
11437
+ data: canvasPngBase64
11438
+ }
11439
+ };
11440
+ }
11441
+ /**
11442
+ * Paints the agent's answer to a claimed mutation (`id`). When a `PaintDirector` is attached (or
11443
+ * passed in `options`), the agent's cursor is seen painting live; all strokes commit as one undo
11444
+ * step with origin `agent:<id>`. A second answer to the same id is refused.
11445
+ */
11446
+ async answerMutation(id, input, options = {}) {
11447
+ const m = this.mutations.find((x) => x.id === id);
11448
+ if (!m) throw new PaintEditorError(`Unknown mutation "${id}". Call list_mutations for the pending ids.`);
11449
+ if (m.state === "answered") throw new PaintEditorError(`Mutation "${id}" has already been answered.`);
11450
+ if (m.state === "declined") throw new PaintEditorError(`Mutation "${id}" was declined and cannot be answered.`);
11451
+ if (m.state !== "claimed") throw new PaintEditorError(`Mutation "${id}" has not been claimed yet; call read_mutation("${id}") first.`);
11452
+ if (!Array.isArray(input?.strokes) || !input.strokes.length) {
11453
+ throw new PaintEditorError("strokes is empty; give at least one stroke with points.");
11454
+ }
11455
+ const targetLayerId = input.layerId ?? m.layerId;
11456
+ this.paintTarget(targetLayerId, "paint on");
11457
+ const cleanedStrokes = input.strokes.map((s) => {
11458
+ const pts = this.checkPoints(s.points);
11459
+ const layerId = s.layerId ?? targetLayerId;
11460
+ this.paintTarget(layerId, "paint on");
11461
+ const baseBrush = s.brushId !== void 0 ? this.requireBrush(s.brushId) : this.brush;
11462
+ const stripAutoGhost = baseBrush.ghost?.trigger === "agent" && !s.brush?.ghost;
11463
+ const brushPatch = {
11464
+ ...s.brush ?? {},
11465
+ mutation: null,
11466
+ ...stripAutoGhost ? { ghost: null } : {}
11467
+ };
11468
+ return {
11469
+ ...s,
11470
+ layerId,
11471
+ brush: brushPatch,
11472
+ points: pts
11473
+ };
11474
+ });
11475
+ m.state = "answered";
11476
+ try {
11477
+ for (const s of cleanedStrokes) {
11478
+ await this.prepareStrokePathEffect(s, [s.points]);
11479
+ }
11480
+ } catch (e) {
11481
+ m.state = "claimed";
11482
+ throw e;
11483
+ }
11484
+ const rawActorId = (options.actor?.id ?? m.claimedBy ?? "mutation-agent").replace(/^agent:/, "") || "mutation-agent";
11485
+ const actor = options.actor ?? {
11486
+ id: rawActorId,
11487
+ name: "Agent",
11488
+ color: "#7c3aed"
11489
+ };
11490
+ const origin = this.getOrigin() !== "user" ? this.getOrigin() : `agent:${actor.id}`;
11491
+ const director = options.director ?? this.mutationDirector;
11492
+ if (director) {
11493
+ const perf = await this.withOrigin(origin, async () => {
11494
+ this.answeringMutation = true;
11495
+ try {
11496
+ return await director.perform(
11497
+ actor,
11498
+ [{ kind: "strokes", strokes: cleanedStrokes, label: "Mutation answer", ...input.note ? { note: input.note } : {} }],
11499
+ { key: m.id }
11500
+ );
11501
+ } finally {
11502
+ this.answeringMutation = false;
11503
+ }
11504
+ });
11505
+ const first = perf[0];
11506
+ if (!first || !first.ok) {
11507
+ m.state = "claimed";
11508
+ throw new PaintEditorError(first ? first.message : "The mutation answer failed.");
11509
+ }
11510
+ const res = first.result;
11511
+ m.answerStrokeIds = [...res.strokeIds];
11512
+ this.cached = null;
11513
+ this.emit([]);
11514
+ return {
11515
+ id: m.id,
11516
+ state: "answered",
11517
+ layerId: res.layerId,
11518
+ strokeIds: res.strokeIds,
11519
+ bounds: res.bounds,
11520
+ dabs: res.dabs,
11521
+ ...res.wet !== void 0 ? { wet: res.wet } : {}
11522
+ };
11523
+ }
11524
+ return this.withOrigin(origin, () => {
11525
+ this.answeringMutation = true;
11526
+ try {
11527
+ return this.group("Mutation answer", () => {
11528
+ let bounds = null;
11529
+ let dabs = 0;
11530
+ const strokeIds = [];
11531
+ let wet;
11532
+ for (const s of cleanedStrokes) {
11533
+ const r = this.paintStroke(s);
11534
+ bounds = unionRect(bounds, r.bounds);
11535
+ dabs += r.dabs;
11536
+ if (r.strokeId) strokeIds.push(r.strokeId);
11537
+ if (r.wet !== void 0) wet = r.wet;
11538
+ }
11539
+ m.answerStrokeIds = [...strokeIds];
11540
+ this.cached = null;
11541
+ return {
11542
+ id: m.id,
11543
+ state: "answered",
11544
+ layerId: targetLayerId,
11545
+ strokeIds,
11546
+ bounds,
11547
+ dabs,
11548
+ ...wet !== void 0 ? { wet } : {}
11549
+ };
11550
+ });
11551
+ } catch (e) {
11552
+ m.state = "claimed";
11553
+ throw e;
11554
+ } finally {
11555
+ this.answeringMutation = false;
11556
+ }
11557
+ });
11558
+ }
11559
+ /**
11560
+ * Gives up on a mutation (`id`) without answering. The user's paint stays as ordinary paint (wet
11561
+ * strokes stay in the wet layer, and dry temporary layers merge down into their target layer).
11562
+ */
11563
+ declineMutation(id, reason) {
11564
+ const m = this.mutations.find((x) => x.id === id);
11565
+ if (!m) throw new PaintEditorError(`Unknown mutation "${id}". Call list_mutations for the pending ids.`);
11566
+ if (m.state === "answered") throw new PaintEditorError(`Mutation "${id}" has already been answered.`);
11567
+ if (m.state === "declined") return this.toMutationInfo(m);
11568
+ m.state = "declined";
11569
+ if (reason?.trim()) m.declineReason = reason.trim();
11570
+ this.keepMutationPaint(m);
11571
+ if (this.mutationDirector && m.claimedBy) {
11572
+ this.mutationDirector.presence.remove(m.claimedBy.replace(/^agent:/, "") || "mutation-agent");
11573
+ }
11574
+ this.emit();
11575
+ return this.toMutationInfo(m);
11576
+ }
11577
+ /**
11578
+ * Advances mutation timers (`settle`, `patience`, and dry temporary-layer `fade`) by `seconds`
11579
+ * of simulated time. Called automatically from `tickMutations` and `advanceWet`.
11580
+ */
11581
+ advanceMutations(seconds) {
11582
+ if (!(seconds > 0) || !this.mutations.length) return;
11583
+ let changed = false;
11584
+ let dirty = [];
11585
+ for (const m of [...this.mutations]) {
11586
+ const wasState = m.state;
11587
+ if (wasState === "settling" || wasState === "pending" || wasState === "claimed") {
11588
+ m.age += seconds;
11589
+ changed = true;
11590
+ }
11591
+ if (wasState === "settling") {
11592
+ m.sinceLastStroke += seconds;
11593
+ if (m.sinceLastStroke >= m.settle - 1e-9) {
11594
+ m.state = "pending";
11595
+ m.sincePending = Math.max(0, m.sinceLastStroke - m.settle);
11596
+ const info = this.toMutationInfo(m);
11597
+ for (const listener of [...this.mutationListeners]) {
11598
+ listener(info);
11599
+ }
11600
+ if (m.state === "pending" && m.sincePending >= m.patience - 1e-9) {
11601
+ this.expireMutation(m);
11602
+ dirty = "all";
11603
+ }
11604
+ }
11605
+ } else if (wasState === "pending") {
11606
+ m.sincePending += seconds;
11607
+ if (m.sincePending >= m.patience - 1e-9) {
11608
+ this.expireMutation(m);
11609
+ dirty = "all";
11610
+ }
11611
+ } else if (wasState === "claimed" || wasState === "answered") {
11612
+ if (m.medium === "dry" && m.tempLayerId) {
11613
+ changed = true;
11614
+ m.fadeRemaining = Math.max(0, m.fadeRemaining - seconds);
11615
+ if (m.fadeRemaining <= 1e-9) {
11616
+ this.removeTempMutationLayer(m);
11617
+ dirty = "all";
11618
+ } else {
11619
+ const opacity = m.fade > 0 ? Math.max(0, Math.min(1, m.fadeRemaining / m.fade)) : 0;
11620
+ const tempId = m.tempLayerId;
11621
+ if (findLayer(this.doc.layers, tempId)) {
11622
+ this.doc = { ...this.doc, layers: mapLayer(this.doc.layers, tempId, (l) => ({ ...l, opacity })) };
11623
+ dirty = "all";
11624
+ }
11625
+ }
11626
+ }
11627
+ }
11628
+ }
11629
+ if (changed) {
11630
+ this.cached = null;
11631
+ this.emit(dirty);
11632
+ }
11633
+ }
11634
+ /** Steps mutation timers with the wall clock (`performance.now()`, in ms). */
11635
+ tickMutations(now) {
11636
+ if (!Number.isFinite(now)) return;
11637
+ if (this.lastMutationTick === null) {
11638
+ this.lastMutationTick = now;
11639
+ return;
11640
+ }
11641
+ const dt = Math.max(0, Math.min(1, (now - this.lastMutationTick) / 1e3));
11642
+ this.lastMutationTick = now;
11643
+ if (dt > 0 && !this.session) {
11644
+ this.advanceMutations(dt);
11645
+ }
11646
+ }
11647
+ toMutationInfo(m) {
11648
+ return {
11649
+ id: m.id,
11650
+ state: m.state,
11651
+ layerId: m.layerId,
11652
+ medium: m.medium,
11653
+ bounds: m.bounds ? { ...m.bounds } : null,
11654
+ strokeCount: m.strokes.length,
11655
+ age: Math.round(m.age * 100) / 100,
11656
+ settle: m.settle,
11657
+ patience: m.patience,
11658
+ fade: m.fade,
11659
+ ...m.claimedBy ? { claimedBy: m.claimedBy } : {},
11660
+ ...m.answerStrokeIds ? { answerStrokeIds: [...m.answerStrokeIds] } : {},
11661
+ ...m.declineReason ? { declineReason: m.declineReason } : {}
11662
+ };
11663
+ }
11664
+ recordMutationStroke(stroke, bounds) {
11665
+ const mut = stroke.brush.mutation;
11666
+ if (!mut || this.answeringMutation || this.previewing || this.getOrigin().startsWith("agent:")) return;
11667
+ const medium = isWetBrush(stroke.brush) ? "wet" : "dry";
11668
+ const tempLayerId = medium === "dry" && this.tempLayerTargets.has(stroke.layerId) ? stroke.layerId : void 0;
11669
+ const targetLayerId = tempLayerId ? this.tempLayerTargets.get(tempLayerId) : stroke.layerId;
11670
+ let entry = this.mutations.find((m) => m.state === "settling" && m.layerId === targetLayerId && m.medium === medium);
11671
+ if (!entry) {
11672
+ entry = {
11673
+ id: `m${this.nextMutationId++}`,
11674
+ state: "settling",
11675
+ layerId: targetLayerId,
11676
+ ...tempLayerId ? { tempLayerId } : {},
11677
+ medium,
11678
+ strokes: [],
11679
+ bounds: null,
11680
+ settle: mut.settle,
11681
+ patience: mut.patience,
11682
+ fade: mut.fade,
11683
+ age: 0,
11684
+ sinceLastStroke: 0,
11685
+ sincePending: 0,
11686
+ fadeRemaining: mut.fade
11687
+ };
11688
+ this.mutations.push(entry);
11689
+ if (medium === "wet") this.holdMutationEvaporation(targetLayerId);
11690
+ } else {
11691
+ entry.settle = mut.settle;
11692
+ entry.patience = mut.patience;
11693
+ entry.fade = mut.fade;
11694
+ entry.fadeRemaining = mut.fade;
11695
+ entry.sinceLastStroke = 0;
11696
+ if (tempLayerId && !entry.tempLayerId) entry.tempLayerId = tempLayerId;
11697
+ }
11698
+ const strokeRect = bounds ?? strokeBounds(stroke.points);
11699
+ entry.strokes.push({
11700
+ id: stroke.id,
11701
+ color: stroke.color,
11702
+ brushId: stroke.brush.id,
11703
+ brushName: stroke.brush.name,
11704
+ size: stroke.brush.tip.size,
11705
+ points: stroke.points.map((p) => ({ ...p })),
11706
+ bounds: strokeRect ? { ...strokeRect } : null
11707
+ });
11708
+ entry.bounds = unionRect(entry.bounds, strokeRect);
11709
+ this.cached = null;
11710
+ }
11711
+ holdMutationEvaporation(layerId) {
11712
+ if (this.heldEvaporation.has(layerId)) return;
11713
+ const currentEvap = (this.wet.settings.get(layerId)?.params ?? this.wet.info(layerId).params).evaporation;
11714
+ this.heldEvaporation.set(layerId, currentEvap);
11715
+ this.wet.setParams(layerId, { evaporation: 0 });
11716
+ }
11717
+ releaseMutationEvaporation(layerId) {
11718
+ const stillWaiting = this.mutations.some(
11719
+ (m) => m.medium === "wet" && m.layerId === layerId && (m.state === "settling" || m.state === "pending")
11720
+ );
11721
+ if (stillWaiting) return;
11722
+ const saved = this.heldEvaporation.get(layerId);
11723
+ if (saved === void 0) return;
11724
+ this.heldEvaporation.delete(layerId);
11725
+ if (findLayer(this.doc.layers, layerId)) {
11726
+ this.wet.setParams(layerId, { evaporation: saved });
11727
+ }
11728
+ }
11729
+ finishWetMutationsOnDry(layerId) {
11730
+ for (const m of this.mutations) {
11731
+ if (m.medium === "wet" && m.layerId === layerId && (m.state === "settling" || m.state === "pending")) {
11732
+ m.state = "declined";
11733
+ m.declineReason = "Dried before an agent claimed it.";
11734
+ }
11735
+ }
11736
+ this.releaseMutationEvaporation(layerId);
11737
+ }
11738
+ expireMutation(m) {
11739
+ m.state = "declined";
11740
+ m.declineReason = "No agent answered before patience ran out.";
11741
+ this.keepMutationPaint(m);
11742
+ }
11743
+ keepMutationPaint(m) {
11744
+ if (m.medium === "wet") {
11745
+ this.releaseMutationEvaporation(m.layerId);
11746
+ this.wet.keepGhost(m.layerId);
11747
+ this.recordWetSetting({ kind: "ghost-keep", layerId: m.layerId });
11748
+ return;
11749
+ }
11750
+ const tempId = m.tempLayerId;
11751
+ if (!tempId) return;
11752
+ m.tempLayerId = void 0;
11753
+ this.tempLayerTargets.delete(tempId);
11754
+ if (!findLayer(this.doc.layers, tempId)) return;
11755
+ this.doc = this.mergeTempLayerIntoTarget(this.doc, tempId, m.layerId);
11756
+ for (const e of this.undoStack) {
11757
+ if (findLayer(e.before.layers, tempId)) e.before = this.mergeTempLayerIntoTarget(e.before, tempId, m.layerId);
11758
+ if (findLayer(e.after.layers, tempId)) e.after = this.mergeTempLayerIntoTarget(e.after, tempId, m.layerId);
11759
+ }
11760
+ while (this.undoStack.length >= 2 && !findLayer(this.undoStack[this.undoStack.length - 2].before.layers, tempId) && this.undoStack[this.undoStack.length - 1].before.strokes.some((s) => m.strokes.some((ms) => ms.id === s.id)) && this.undoStack[this.undoStack.length - 2].after.strokes.some((s) => m.strokes.some((ms) => ms.id === s.id))) {
11761
+ const top = this.undoStack.pop();
11762
+ const prev = this.undoStack[this.undoStack.length - 1];
11763
+ prev.after = top.after;
11764
+ }
11765
+ }
11766
+ mergeTempLayerIntoTarget(doc, tempLayerId, targetLayerId) {
11767
+ const upper = findLayer(doc.layers, tempLayerId)?.layer;
11768
+ const below = findLayer(doc.layers, targetLayerId)?.layer;
11769
+ if (!isPixelLayer(upper)) return doc;
11770
+ if (!isPixelLayer(below)) {
11771
+ return {
11772
+ ...doc,
11773
+ layers: mapLayer(doc.layers, tempLayerId, () => null),
11774
+ strokes: doc.strokes.filter((s) => s.layerId !== tempLayerId)
11775
+ };
11776
+ }
11777
+ const rect = unionRect(this.describeLayer(below).bounds, this.describeLayer(upper).bounds);
11778
+ let surface = below.surface;
11779
+ let offset = below.offset;
11780
+ if (rect) {
11781
+ const base = { ...below, opacity: 1, blend: "normal", visible: true, clipped: void 0, mask: void 0 };
11782
+ const top = { ...upper, opacity: 1, visible: true };
11783
+ const px = createPaintCompositor().composite([base, top], rect, this.sources());
11784
+ surface = surfaceFromPixels({ width: rect.width, height: rect.height, data: px }, 4, rect.x, rect.y);
11785
+ offset = { x: 0, y: 0 };
11786
+ }
11787
+ const merged = { ...below, surface, offset };
11788
+ const layers = mapLayer(mapLayer(doc.layers, upper.id, () => null), below.id, () => merged);
11789
+ const strokes = doc.strokes.map((s) => s.layerId === tempLayerId ? { ...s, layerId: targetLayerId } : s);
11790
+ return { ...doc, layers, strokes };
11791
+ }
11792
+ removeTempMutationLayer(m) {
11793
+ const tempId = m.tempLayerId;
11794
+ if (!tempId) return;
11795
+ m.tempLayerId = void 0;
11796
+ this.tempLayerTargets.delete(tempId);
11797
+ const strip = (d) => {
11798
+ if (!findLayer(d.layers, tempId) && !d.strokes.some((s) => s.layerId === tempId)) return d;
11799
+ return {
11800
+ ...d,
11801
+ layers: mapLayer(d.layers, tempId, () => null),
11802
+ strokes: d.strokes.filter((s) => s.layerId !== tempId)
11803
+ };
11804
+ };
11805
+ this.doc = strip(this.doc);
11806
+ const mutStrokeIds = new Set(m.strokes.map((s) => s.id));
11807
+ for (const e of this.undoStack) {
11808
+ const isMutationCreationStep = !e.before.strokes.some((s) => mutStrokeIds.has(s.id)) && e.after.strokes.some((s) => mutStrokeIds.has(s.id));
11809
+ if (!isMutationCreationStep) {
11810
+ e.before = strip(e.before);
11811
+ e.after = strip(e.after);
11812
+ }
11813
+ }
11814
+ }
11815
+ reconcileMutationsAfterUndo() {
11816
+ if (!this.mutations.length) return;
11817
+ const remainingIds = new Set(this.doc.strokes.map((s) => s.id));
11818
+ const kept = [];
11819
+ for (const m of this.mutations) {
11820
+ m.strokes = m.strokes.filter((s) => remainingIds.has(s.id));
11821
+ if (m.strokes.length === 0) {
11822
+ this.mutationDirector?.cancel(m.id);
11823
+ if (m.claimedBy && this.mutationDirector) {
11824
+ this.mutationDirector.presence.remove(m.claimedBy.replace(/^agent:/, "") || "mutation-agent");
11825
+ }
11826
+ if (m.tempLayerId) {
11827
+ const tempId = m.tempLayerId;
11828
+ this.tempLayerTargets.delete(tempId);
11829
+ if (findLayer(this.doc.layers, tempId)) {
11830
+ this.doc = {
11831
+ ...this.doc,
11832
+ layers: mapLayer(this.doc.layers, tempId, () => null),
11833
+ strokes: this.doc.strokes.filter((s) => s.layerId !== tempId)
11834
+ };
11835
+ }
11836
+ }
11837
+ if (m.medium === "wet") {
11838
+ this.releaseMutationEvaporation(m.layerId);
11839
+ }
11840
+ } else {
11841
+ m.bounds = m.strokes.reduce((acc, s) => unionRect(acc, s.bounds), null);
11842
+ kept.push(m);
11843
+ }
11844
+ }
11845
+ this.mutations = kept;
11846
+ }
11847
+ resetMutations() {
11848
+ for (const m of this.mutations) {
11849
+ this.mutationDirector?.cancel(m.id);
11850
+ }
11851
+ this.mutations = [];
11852
+ this.heldEvaporation.clear();
11853
+ this.tempLayerTargets.clear();
11854
+ this.lastMutationTick = null;
11855
+ }
9842
11856
  /** A layer's tile surface (live while stroking), for thumbnails. */
9843
11857
  layerSurface(id) {
9844
11858
  const l = findLayer(this.currentLayers(), id)?.layer;
@@ -9989,6 +12003,7 @@ var MAX_IMAGE_SIDE = 8192;
9989
12003
  var movePoint = (p, dx, dy) => ({ x: p.x + dx, y: p.y + dy });
9990
12004
  var moveRect = (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy });
9991
12005
  var SPILL_SUFFIX = "::light";
12006
+ var UV_SPILL_SUFFIX = "::uv";
9992
12007
  function withLayerLight(light, layerId, next) {
9993
12008
  if (!next) return light ? { ...light } : void 0;
9994
12009
  const out = { ...light ?? {} };
@@ -9996,6 +12011,13 @@ function withLayerLight(light, layerId, next) {
9996
12011
  else delete out[layerId];
9997
12012
  return out;
9998
12013
  }
12014
+ function withLayerThick(thick, layerId, next) {
12015
+ if (!next) return thick ? { ...thick } : void 0;
12016
+ const out = { ...thick ?? {} };
12017
+ if (hasThickChannels(next)) out[layerId] = next;
12018
+ else delete out[layerId];
12019
+ return out;
12020
+ }
9999
12021
  var BACKLIGHT_LAYER_ID = "__backlight";
10000
12022
  function insertAfter(layers, id, node) {
10001
12023
  const i = layers.findIndex((l) => l.id === id);
@@ -10207,16 +12229,42 @@ var PaintDirector = class {
10207
12229
  options;
10208
12230
  clock;
10209
12231
  queue;
12232
+ cancelled = /* @__PURE__ */ new Set();
12233
+ currentRun = null;
12234
+ /** Cancels any queued or running performance with `key` (e.g. a mutation that was undone). */
12235
+ cancel(key) {
12236
+ this.cancelled.add(key);
12237
+ if (this.currentRun?.key === key) {
12238
+ const { actorId, cancelSession } = this.currentRun;
12239
+ this.currentRun = null;
12240
+ cancelSession?.();
12241
+ this.presence.remove(actorId);
12242
+ }
12243
+ }
10210
12244
  /** Acts out `commands` in order for `actor`. Resolves with one result per command. */
10211
- perform(actor, commands) {
12245
+ perform(actor, commands, options) {
12246
+ const key = options?.key;
12247
+ if (key) this.cancelled.delete(key);
10212
12248
  return this.queue.run(actor, async () => {
12249
+ if (key && this.cancelled.has(key)) {
12250
+ return commands.map((command) => ({ ok: false, command, message: "The mutation was cancelled." }));
12251
+ }
12252
+ if (key) this.currentRun = { actorId: actor.id, key };
10213
12253
  const results = [];
10214
- for (const command of commands) {
10215
- try {
10216
- results.push({ ok: true, command, result: await this.performOne(actor.id, command) });
10217
- } catch (error) {
10218
- results.push({ ok: false, command, message: error instanceof Error ? error.message : String(error) });
12254
+ try {
12255
+ for (const command of commands) {
12256
+ if (key && this.cancelled.has(key)) {
12257
+ results.push({ ok: false, command, message: "The mutation was cancelled." });
12258
+ continue;
12259
+ }
12260
+ try {
12261
+ results.push({ ok: true, command, result: await this.performOne(actor.id, command, key) });
12262
+ } catch (error) {
12263
+ results.push({ ok: false, command, message: error instanceof Error ? error.message : String(error) });
12264
+ }
10219
12265
  }
12266
+ } finally {
12267
+ if (this.currentRun?.key === key) this.currentRun = null;
10220
12268
  }
10221
12269
  return results;
10222
12270
  });
@@ -10242,7 +12290,7 @@ var PaintDirector = class {
10242
12290
  this.presence.set({ id, message: text });
10243
12291
  await this.clock.wait(this.ms(ms));
10244
12292
  }
10245
- async performOne(id, command) {
12293
+ async performOne(id, command, key) {
10246
12294
  const c = this.controller;
10247
12295
  switch (command.kind) {
10248
12296
  case "say":
@@ -10257,10 +12305,10 @@ var PaintDirector = class {
10257
12305
  return void 0;
10258
12306
  case "stroke":
10259
12307
  await this.say(id, command.note, 500);
10260
- return this.stroke(id, command.stroke);
12308
+ return this.stroke(id, command.stroke, void 0, key);
10261
12309
  case "strokes":
10262
12310
  await this.say(id, command.note, 500);
10263
- return this.strokes(id, command.strokes, command.label);
12311
+ return this.strokes(id, command.strokes, command.label, key);
10264
12312
  case "fill": {
10265
12313
  await this.say(id, command.note, 500);
10266
12314
  const { width, height } = c.getState().document.canvas;
@@ -10297,19 +12345,26 @@ var PaintDirector = class {
10297
12345
  }
10298
12346
  }
10299
12347
  /** Paints a stroke live: begins it, pushes its points frame by frame as the cursor reaches them, then ends it (one undo step). */
10300
- async stroke(id, init, within = (step) => step()) {
12348
+ async stroke(id, init, within = (step) => step(), key) {
10301
12349
  const c = this.controller;
10302
12350
  const points = c.checkPoints(init.points);
10303
12351
  const { points: _points, ...rest } = init;
10304
12352
  const session = within(() => c.beginStroke({ snapToPerspective: false, start: points[0], ...rest }));
12353
+ if (key && this.currentRun?.key === key) {
12354
+ this.currentRun.cancelSession = () => {
12355
+ if (c.activeStroke === session) session.cancel();
12356
+ };
12357
+ }
10305
12358
  try {
10306
12359
  await this.glide(id, points[0]);
12360
+ if (key && this.cancelled.has(key)) throw new PaintEditorError("The mutation was cancelled.");
10307
12361
  this.presence.set({ id, pressed: true });
10308
12362
  const at = strokeSchedule(points);
10309
12363
  const duration = this.ms(strokeDuration(points, this.options.maxStrokeMs));
10310
12364
  let next = 0;
10311
12365
  const frames = Math.max(1, Math.round(duration / FRAME));
10312
12366
  for (let f = 1; f <= frames; f++) {
12367
+ if (key && this.cancelled.has(key)) throw new PaintEditorError("The mutation was cancelled.");
10313
12368
  const t = f / frames;
10314
12369
  const from = next;
10315
12370
  while (next < points.length && at[next] <= t + 1e-9) next++;
@@ -10320,6 +12375,7 @@ var PaintDirector = class {
10320
12375
  }
10321
12376
  if (f < frames) await this.clock.wait(duration / frames);
10322
12377
  }
12378
+ if (key && this.cancelled.has(key)) throw new PaintEditorError("The mutation was cancelled.");
10323
12379
  if (next < points.length) session.push(points.slice(next));
10324
12380
  this.presence.set({ id, pressed: false });
10325
12381
  return session.end();
@@ -10327,10 +12383,12 @@ var PaintDirector = class {
10327
12383
  if (c.activeStroke === session) session.cancel();
10328
12384
  this.presence.set({ id, pressed: false });
10329
12385
  throw error;
12386
+ } finally {
12387
+ if (key && this.currentRun?.key === key) delete this.currentRun.cancelSession;
10330
12388
  }
10331
12389
  }
10332
12390
  /** Paints several strokes live in turn and commits them as one undo step (e.g. a path's subpaths). */
10333
- async strokes(id, inits, label) {
12391
+ async strokes(id, inits, label, key) {
10334
12392
  const c = this.controller;
10335
12393
  if (!Array.isArray(inits) || !inits.length) throw new PaintEditorError("strokes is empty; give at least one stroke.");
10336
12394
  const checked = inits.map((s) => ({ ...s, points: c.checkPoints(s.points) }));
@@ -10344,7 +12402,8 @@ var PaintDirector = class {
10344
12402
  const strokeIds = [];
10345
12403
  let last = null;
10346
12404
  for (const s of checked) {
10347
- const r = await this.stroke(id, s, within);
12405
+ if (key && this.cancelled.has(key)) throw new PaintEditorError("The mutation was cancelled.");
12406
+ const r = await this.stroke(id, s, within, key);
10348
12407
  layerId = r.layerId;
10349
12408
  bounds = unionRect2(bounds, r.bounds);
10350
12409
  dabs += r.dabs;
@@ -11290,7 +13349,7 @@ function createPaintThickTools(c) {
11290
13349
  {
11291
13350
  name: "set_lighting",
11292
13351
  title: "Move the light",
11293
- description: "Sets where the light comes from for thick paint (impasto relief) and metallic paper: azimuth (degrees clockwise from the top of the canvas; 315 is top left), elevation (5 grazing, deep shadows .. 90 straight on, no relief) and relief (0 flat .. 1 strong). Nothing is repainted: the relief is lit at render time. Saved with the document, not an undo step. Returns the lighting.",
13352
+ description: "Sets where the light comes from for thick paint (impasto relief), thin-film iridescence and metallic paper: azimuth (degrees clockwise from the top of the canvas; 315 is top left), elevation (5 grazing, deep shadows .. 90 straight on, no relief) and relief (0 flat .. 1 strong). Nothing is repainted: the relief and iridescence are lit at render time. Saved with the document, not an undo step. Returns the lighting.",
11294
13353
  inputSchema: {
11295
13354
  type: "object",
11296
13355
  properties: {
@@ -11303,6 +13362,29 @@ function createPaintThickTools(c) {
11303
13362
  annotations: { title: "Move the light", ...SET3 },
11304
13363
  handler: (a) => c.setLighting(a)
11305
13364
  },
13365
+ {
13366
+ name: "set_view_tilt",
13367
+ title: "Tilt the view angle for iridescence",
13368
+ description: "Tilts the viewer\u2019s look angle for thin-film iridescence and specular highlights: x (-1 left .. 1 right, default 0) and y (-1 top .. 1 bottom, default 0). Moves the view vector, not the light, so iridescent paint (brushes with iridescence, such as scarab-lacquer, soap-film, pearl-glaze) shifts hue across the spectrum as the angle changes. Editor state (not saved with the document, not an undo step). Returns the view tilt.",
13369
+ inputSchema: {
13370
+ type: "object",
13371
+ properties: {
13372
+ x: { type: "number", minimum: -1, maximum: 1, description: "Horizontal view tilt, -1 (left) .. 1 (right); default 0." },
13373
+ y: { type: "number", minimum: -1, maximum: 1, description: "Vertical view tilt, -1 (top) .. 1 (bottom); default 0." }
13374
+ },
13375
+ additionalProperties: false
13376
+ },
13377
+ annotations: { title: "Tilt the view angle for iridescence", ...SET3 },
13378
+ handler: (a) => c.setViewTilt(a)
13379
+ },
13380
+ {
13381
+ name: "get_view_tilt",
13382
+ title: "Read the view tilt",
13383
+ description: "Reads the viewer\u2019s look angle for thin-film iridescence and specular highlights: x and y in -1..1 (0, 0 is straight on). Changes nothing.",
13384
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13385
+ annotations: { title: "Read the view tilt", ...READ4 },
13386
+ handler: () => c.getViewTilt()
13387
+ },
11306
13388
  {
11307
13389
  name: "get_relief",
11308
13390
  title: "Read a layer\u2019s relief",
@@ -11327,6 +13409,111 @@ function createPaintThickTools(c) {
11327
13409
  inputSchema: { type: "object", properties: { on: { type: "boolean", description: "true dries every wet stroke as it ends." } }, required: ["on"], additionalProperties: false },
11328
13410
  annotations: { title: "Dry wet strokes at once", ...SET3 },
11329
13411
  handler: ({ on }) => ({ instantDry: c.setInstantDry(on) })
13412
+ },
13413
+ {
13414
+ name: "set_uv_light",
13415
+ title: "Set UV blacklight",
13416
+ description: "Switches the virtual UV blacklight (on) and sets its glow strength (0..2, default 1) and ambient daylight level (0..1, default 0.15). When on, the paper and daylight paint dim to ambient and fluorescent strokes (fluorescence.amount > 0, including invisible UV ink) glow and spill in their own colour. Saved with the document, not an undo step. Returns the UV light settings and fluorescentLayerIds.",
13417
+ inputSchema: {
13418
+ type: "object",
13419
+ properties: {
13420
+ on: { type: "boolean", description: "Whether the UV blacklight is switched on (default false)." },
13421
+ strength: { type: "number", minimum: 0, maximum: 2, description: "UV emission and spill strength, 0..2 (default 1)." },
13422
+ ambient: { type: "number", minimum: 0, maximum: 1, description: "How much normal daylight remains under UV, 0 (pitch dark) .. 1 (full daylight, default 0.15)." }
13423
+ },
13424
+ additionalProperties: false
13425
+ },
13426
+ annotations: { title: "Set UV blacklight", ...SET3 },
13427
+ handler: (a) => ({ ...c.setUvLight(a), fluorescentLayerIds: c.fluorescentLayerIds() })
13428
+ },
13429
+ {
13430
+ name: "get_uv_light",
13431
+ title: "Read UV blacklight",
13432
+ description: "Reads the virtual UV blacklight settings (on, strength, ambient) and the ids of layers that hold UV-fluorescent ink (fluorescentLayerIds). Changes nothing.",
13433
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13434
+ annotations: { title: "Read UV blacklight", ...READ4 },
13435
+ handler: () => ({ ...c.getUvLight(), fluorescentLayerIds: c.fluorescentLayerIds() })
13436
+ },
13437
+ {
13438
+ name: "paint_heat",
13439
+ title: "Warm or cool the canvas",
13440
+ description: "Applies a heat brush along points on the document\u2019s coarse heat field (radius in document pixels, default 24; temperature 0 cools .. 1 warms, default 1). Thermochromic strokes (thermochromic.hotColor) shift smoothly toward their hot colour where local temperature crosses their threshold, and wet paint under hot tiles (temperature >= 0.5) dries in place. One undo step. Returns the heat field stats (max, mean, hotCells, bounds) and any dried wet layers.",
13441
+ inputSchema: {
13442
+ type: "object",
13443
+ properties: {
13444
+ points: {
13445
+ type: "array",
13446
+ minItems: 1,
13447
+ description: "Polyline of document positions to warm or cool along.",
13448
+ items: {
13449
+ type: "object",
13450
+ properties: {
13451
+ x: { type: "number", description: "Document x in pixels." },
13452
+ y: { type: "number", description: "Document y in pixels." }
13453
+ },
13454
+ required: ["x", "y"],
13455
+ additionalProperties: false
13456
+ }
13457
+ },
13458
+ radius: { type: "number", minimum: 1, maximum: 512, description: "Heat falloff radius in document pixels, 1..512 (default 24)." },
13459
+ temperature: { type: "number", minimum: 0, maximum: 1, description: "Target temperature along the stroke, 0 (cool) .. 1 (hot, default 1)." }
13460
+ },
13461
+ required: ["points"],
13462
+ additionalProperties: false
13463
+ },
13464
+ annotations: { title: "Warm or cool the canvas", ...WRITE4 },
13465
+ coordinates: { in: ["points"], out: ["bounds", "dried[].bounds"] },
13466
+ handler: ({ points, radius, temperature }) => c.paintHeat({ points, radius, temperature })
13467
+ },
13468
+ {
13469
+ name: "advance_heat",
13470
+ title: "Spread and cool heat",
13471
+ description: "Advances the document\u2019s heat field by steps (1..200, default 10) of thermal diffusion and Newtonian cooling, so warm patches spread outward and fade back toward cold paint. Any wet paint under hot cells dries. Not an undo step (time passing never is). Returns steps, the updated heat stats (max, mean, hotCells, bounds) and any dried wet layers.",
13472
+ inputSchema: {
13473
+ type: "object",
13474
+ properties: {
13475
+ steps: { type: "integer", minimum: 1, maximum: 200, description: "Diffusion and cooling steps to run, 1..200 (default 10)." }
13476
+ },
13477
+ additionalProperties: false
13478
+ },
13479
+ annotations: { title: "Spread and cool heat", ...SET3 },
13480
+ coordinates: { out: ["bounds", "dried[].bounds"] },
13481
+ handler: ({ steps }) => c.advanceHeat(steps)
13482
+ },
13483
+ {
13484
+ name: "clear_heat",
13485
+ title: "Cool the whole canvas",
13486
+ description: "Cools the entire document heat field back to 0 at once so all thermochromic paint returns to its cold colour. One undo step when the canvas was warm. Returns { cleared }.",
13487
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
13488
+ annotations: { title: "Cool the whole canvas", ...WRITE4 },
13489
+ handler: () => c.clearHeat()
13490
+ },
13491
+ {
13492
+ name: "get_heat",
13493
+ title: "Read canvas heat",
13494
+ description: "Reads the document\u2019s heat field stats (max, mean, hotCells, bounds), optional temperature samples (0..1) at points in document pixels, and thermochromicLayerIds. Changes nothing.",
13495
+ inputSchema: {
13496
+ type: "object",
13497
+ properties: {
13498
+ points: {
13499
+ type: "array",
13500
+ description: "Optional document positions (px) at which to sample the local temperature (0..1).",
13501
+ items: {
13502
+ type: "object",
13503
+ properties: {
13504
+ x: { type: "number", description: "Document x in pixels." },
13505
+ y: { type: "number", description: "Document y in pixels." }
13506
+ },
13507
+ required: ["x", "y"],
13508
+ additionalProperties: false
13509
+ }
13510
+ }
13511
+ },
13512
+ additionalProperties: false
13513
+ },
13514
+ annotations: { title: "Read canvas heat", ...READ4 },
13515
+ coordinates: { in: ["points"], out: ["bounds", "samples"] },
13516
+ handler: ({ points }) => ({ ...c.getHeat(points), thermochromicLayerIds: c.thermochromicLayerIds() })
11330
13517
  }
11331
13518
  ];
11332
13519
  }
@@ -11394,9 +13581,220 @@ function createPaintReplayTools(c) {
11394
13581
  ];
11395
13582
  }
11396
13583
 
11397
- // src/paint-editor/evolution-tools.ts
13584
+ // src/paint-editor/mutation-tools.ts
11398
13585
  var READ6 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
11399
13586
  var WRITE5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13587
+ var SET4 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13588
+ var POINTS = {
13589
+ type: "array",
13590
+ minItems: 1,
13591
+ maxItems: 2e4,
13592
+ description: "The path in document pixels (raw samples; the brush smooths them). One point paints one dab.",
13593
+ items: {
13594
+ type: "object",
13595
+ properties: {
13596
+ x: { type: "number", description: "Document x in pixels." },
13597
+ y: { type: "number", description: "Document y in pixels." },
13598
+ pressure: { type: "number", minimum: 0, maximum: 1, description: "Pen pressure 0..1 (default 1)." },
13599
+ t: { type: "number", description: "Time in milliseconds (optional; drives velocity dynamics)." },
13600
+ tiltX: { type: "number", minimum: -90, maximum: 90, description: "Pen tilt in degrees towards +x (Pointer Events tiltX)." },
13601
+ tiltY: { type: "number", minimum: -90, maximum: 90, description: "Pen tilt in degrees towards +y (Pointer Events tiltY)." },
13602
+ altitude: { type: "number", minimum: 0, maximum: 1.5708, description: "Pen altitude in radians: 1.5708 upright, 0 lying flat (overrides tiltX / tiltY)." },
13603
+ azimuth: { type: "number", minimum: 0, maximum: 6.2832, description: "Where the pen leans, radians clockwise from +x (with altitude)." },
13604
+ twist: { type: "number", minimum: 0, maximum: 359, description: "Barrel rotation in degrees (brushes with rotation barrel)." }
13605
+ },
13606
+ required: ["x", "y"],
13607
+ additionalProperties: false
13608
+ }
13609
+ };
13610
+ function createMutationTools(c, options = {}) {
13611
+ return [
13612
+ {
13613
+ name: "list_mutations",
13614
+ title: "List pending mutations",
13615
+ description: 'Lists mutation exchanges waiting for an agent (or all of them when includeAll is true): each with id, state ("settling" | "pending" | "claimed" | "answered" | "declined"), layerId, medium ("wet" | "dry"), bounds in document pixels, strokeCount, age in seconds, settle, patience and fade. Use an id with read_mutation or decline_mutation.',
13616
+ inputSchema: {
13617
+ type: "object",
13618
+ properties: {
13619
+ includeAll: {
13620
+ type: "boolean",
13621
+ description: "Include settling, claimed, answered and declined mutations as well as pending ones (default false)."
13622
+ }
13623
+ },
13624
+ additionalProperties: false
13625
+ },
13626
+ annotations: { title: "List pending mutations", ...READ6 },
13627
+ coordinates: { out: ["mutations[].bounds"] },
13628
+ handler: (a) => ({ mutations: c.listMutations({ includeAll: Boolean(a.includeAll) }) })
13629
+ },
13630
+ {
13631
+ name: "read_mutation",
13632
+ title: "Read and claim a mutation",
13633
+ description: 'Claims a mutation by id and starts its fade so the user\u2019s stroke sinks away as you read it. Returns the mutation\u2019s id, state ("claimed"), layerId, medium, bounds, fade duration in seconds, the strokes (id, color, brushId, brushName, size, points and bounds in document pixels), cropPng (base64 PNG and rect of the area around the strokes) and canvasPng (base64 PNG preview of the whole painting so you can choose where to answer). Refused if already claimed, answered or declined. Follow with answer_mutation or decline_mutation.',
13634
+ inputSchema: {
13635
+ type: "object",
13636
+ properties: {
13637
+ id: {
13638
+ type: "string",
13639
+ description: 'Mutation id from list_mutations (e.g. "m1").'
13640
+ },
13641
+ padding: {
13642
+ type: "integer",
13643
+ minimum: 0,
13644
+ maximum: 512,
13645
+ description: "Padding around the mutation bounds for cropPng, in document pixels (default 32)."
13646
+ },
13647
+ maxSide: {
13648
+ type: "integer",
13649
+ minimum: 64,
13650
+ maximum: 2048,
13651
+ description: "Longest side of the canvasPng preview in pixels (default 512)."
13652
+ }
13653
+ },
13654
+ required: ["id"],
13655
+ additionalProperties: false
13656
+ },
13657
+ annotations: { title: "Read and claim a mutation", ...WRITE5 },
13658
+ coordinates: { out: ["bounds", "strokes[].bounds", "strokes[].points[]", "cropPng.rect"] },
13659
+ handler: (a) => c.readMutation(a.id, {
13660
+ actor: options.actor?.id,
13661
+ padding: a.padding,
13662
+ maxSide: a.maxSide
13663
+ })
13664
+ },
13665
+ {
13666
+ name: "answer_mutation",
13667
+ title: "Answer a mutation with strokes",
13668
+ description: 'Paints your answer to a claimed mutation (from read_mutation). Pass one or more strokes (each with points in document pixels, and optional brushId, brush facets, color, paint, layerId and seed). When a director is attached, your cursor glides across the canvas and paints live; all strokes commit as one undo step with origin agent:<id>. A second answer to the same mutation id is refused. Returns the mutation id, state ("answered"), layerId, new strokeIds, painted bounds and dab count.',
13669
+ inputSchema: {
13670
+ type: "object",
13671
+ properties: {
13672
+ id: {
13673
+ type: "string",
13674
+ description: 'Claimed mutation id from read_mutation (e.g. "m1").'
13675
+ },
13676
+ strokes: {
13677
+ type: "array",
13678
+ minItems: 1,
13679
+ maxItems: 200,
13680
+ description: "The strokes that form the answer, painted in order as one undo step.",
13681
+ items: {
13682
+ type: "object",
13683
+ properties: {
13684
+ points: POINTS,
13685
+ layerId: {
13686
+ type: "string",
13687
+ description: "Layer id from list_layers for this stroke (default the answer layerId or the mutation layerId)."
13688
+ },
13689
+ brushId: {
13690
+ type: "string",
13691
+ description: "Brush id from list_brush_presets (default the active brush)."
13692
+ },
13693
+ brush: {
13694
+ type: "object",
13695
+ description: "Facets to change for this stroke only, laid over brushId or the active brush (as set_brush takes them).",
13696
+ properties: BRUSH_PATCH_PROPERTIES,
13697
+ additionalProperties: false
13698
+ },
13699
+ color: {
13700
+ type: "string",
13701
+ pattern: "^#[0-9a-fA-F]{6}$",
13702
+ description: "Paint colour #rrggbb (default the paint colour)."
13703
+ },
13704
+ paint: {
13705
+ ...PAINT_SOURCE_JSON_SCHEMA,
13706
+ description: "Colour or gradient paint for this stroke."
13707
+ },
13708
+ seed: {
13709
+ type: "integer",
13710
+ minimum: 0,
13711
+ description: "Jitter seed for reproducible marks (default a new one)."
13712
+ },
13713
+ snapToPerspective: {
13714
+ type: "boolean",
13715
+ description: "Snap the points to the perspective guide (default false)."
13716
+ }
13717
+ },
13718
+ required: ["points"],
13719
+ additionalProperties: false
13720
+ }
13721
+ },
13722
+ layerId: {
13723
+ type: "string",
13724
+ description: "Default pixel layer id from list_layers for the answer strokes (default the mutation\u2019s layerId)."
13725
+ },
13726
+ note: {
13727
+ type: "string",
13728
+ description: "Short note shown next to the agent cursor while painting (optional)."
13729
+ }
13730
+ },
13731
+ required: ["id", "strokes"],
13732
+ additionalProperties: false
13733
+ },
13734
+ annotations: { title: "Answer a mutation with strokes", ...WRITE5 },
13735
+ coordinates: { in: ["strokes[].points[]"], out: ["bounds"] },
13736
+ handler: async (a) => {
13737
+ await c.wetIdle();
13738
+ const rawStrokes = a.strokes;
13739
+ const strokes = [];
13740
+ for (const s of rawStrokes) {
13741
+ const base = s.brushId ? c.getBrush(s.brushId) : c.getState().brush;
13742
+ const brush = s.brush ? c.brushChanges(s.brush, s.brushId) : void 0;
13743
+ if (base) {
13744
+ const refs = brushStickerRefs(normalizePaintBrush({ ...brush ?? {}, id: base.id }, base));
13745
+ if (refs.length) await c.prepareStickers(refs);
13746
+ }
13747
+ strokes.push({
13748
+ points: s.points,
13749
+ ...s.layerId !== void 0 ? { layerId: s.layerId } : {},
13750
+ ...s.brushId !== void 0 ? { brushId: s.brushId } : {},
13751
+ ...brush !== void 0 ? { brush } : {},
13752
+ ...s.color !== void 0 ? { color: s.color } : {},
13753
+ ...s.paint !== void 0 ? { paint: s.paint } : {},
13754
+ ...s.seed !== void 0 ? { seed: s.seed } : {},
13755
+ ...s.snapToPerspective !== void 0 ? { snapToPerspective: s.snapToPerspective } : {}
13756
+ });
13757
+ }
13758
+ return c.answerMutation(
13759
+ a.id,
13760
+ {
13761
+ strokes,
13762
+ ...a.layerId !== void 0 ? { layerId: a.layerId } : {},
13763
+ ...a.note !== void 0 ? { note: a.note } : {}
13764
+ },
13765
+ { director: options.director, actor: options.actor }
13766
+ );
13767
+ }
13768
+ },
13769
+ {
13770
+ name: "decline_mutation",
13771
+ title: "Decline a mutation",
13772
+ description: "Declines a mutation without answering so the user\u2019s paint stays as ordinary paint (wet strokes stay in the wet layer and dry temporary layers merge down into their target layer). Returns the updated mutation info.",
13773
+ inputSchema: {
13774
+ type: "object",
13775
+ properties: {
13776
+ id: {
13777
+ type: "string",
13778
+ description: 'Mutation id from list_mutations (e.g. "m1").'
13779
+ },
13780
+ reason: {
13781
+ type: "string",
13782
+ description: "Why the mutation was declined (optional)."
13783
+ }
13784
+ },
13785
+ required: ["id"],
13786
+ additionalProperties: false
13787
+ },
13788
+ annotations: { title: "Decline a mutation", ...SET4 },
13789
+ coordinates: { out: ["bounds"] },
13790
+ handler: (a) => c.declineMutation(a.id, a.reason)
13791
+ }
13792
+ ];
13793
+ }
13794
+
13795
+ // src/paint-editor/evolution-tools.ts
13796
+ var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13797
+ var WRITE6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
11400
13798
  var UNIT2 = (description) => ({ type: "number", minimum: 0, maximum: 1, description });
11401
13799
  var EVOLUTION_MUTATION_SCHEMA = {
11402
13800
  type: "object",
@@ -11441,7 +13839,7 @@ function createPaintEvolutionTools(c) {
11441
13839
  required: ["strokeIds"],
11442
13840
  additionalProperties: false
11443
13841
  },
11444
- annotations: { title: "Breed stroke variants", ...WRITE5 },
13842
+ annotations: { title: "Breed stroke variants", ...WRITE6 },
11445
13843
  coordinates: { out: ["proposals[].bounds"] },
11446
13844
  handler: ({ strokeIds, count, mutation, seed }, context) => {
11447
13845
  const actor = context?.actor?.id;
@@ -11484,7 +13882,7 @@ function createPaintEvolutionTools(c) {
11484
13882
  required: ["proposalIds"],
11485
13883
  additionalProperties: false
11486
13884
  },
11487
- annotations: { title: "Breed from paint proposals", ...WRITE5 },
13885
+ annotations: { title: "Breed from paint proposals", ...WRITE6 },
11488
13886
  coordinates: { out: ["proposals[].bounds"] },
11489
13887
  handler: ({ proposalIds, count, mutation, seed }, context) => {
11490
13888
  const actor = context?.actor?.id;
@@ -11512,7 +13910,7 @@ function createPaintEvolutionTools(c) {
11512
13910
  required: ["proposalId"],
11513
13911
  additionalProperties: false
11514
13912
  },
11515
- annotations: { title: "Get a proposal\u2019s stroke genome", ...READ6 },
13913
+ annotations: { title: "Get a proposal\u2019s stroke genome", ...READ7 },
11516
13914
  coordinates: { out: ["bounds", "strokes[].points[]"] },
11517
13915
  handler: ({ proposalId }) => c.getProposalGenome(proposalId)
11518
13916
  }
@@ -11531,15 +13929,15 @@ function createPaintEvolutionToolProvider(controller, options = {}) {
11531
13929
  }
11532
13930
 
11533
13931
  // src/paint-editor/tools.ts
11534
- var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
11535
- var WRITE6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
11536
- var SET4 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13932
+ var READ8 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13933
+ var WRITE7 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13934
+ var SET5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
11537
13935
  var DESTRUCTIVE3 = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
11538
13936
  var EMPTY3 = { type: "object", properties: {}, additionalProperties: false };
11539
13937
  var COLOR3 = { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: 'Colour #rrggbb, e.g. "#2a4b8d".' };
11540
13938
  var LAYER_ID4 = { type: "string", description: "Layer id from list_layers (default the active layer)." };
11541
13939
  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)." };
11542
- var POINTS = {
13940
+ var POINTS2 = {
11543
13941
  type: "array",
11544
13942
  minItems: 1,
11545
13943
  maxItems: 2e4,
@@ -11581,7 +13979,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11581
13979
  title: "Get the canvas",
11582
13980
  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.",
11583
13981
  inputSchema: EMPTY3,
11584
- annotations: { title: "Get the canvas", ...READ7 },
13982
+ annotations: { title: "Get the canvas", ...READ8 },
11585
13983
  coordinates: { out: ["bounds"] },
11586
13984
  handler: () => {
11587
13985
  const s = c.getState();
@@ -11642,7 +14040,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11642
14040
  },
11643
14041
  additionalProperties: false
11644
14042
  },
11645
- annotations: { title: "Resize the canvas", ...SET4 },
14043
+ annotations: { title: "Resize the canvas", ...SET5 },
11646
14044
  handler: (a) => c.setCanvas({ width: a.width, height: a.height, dpi: a.dpi })
11647
14045
  });
11648
14046
  const EXTEND_SIDE = (side) => ({ type: "integer", minimum: 0, maximum: MAX_CANVAS_SIDE - 1, description: `Pixels to add on the ${side} (default 0).` });
@@ -11655,7 +14053,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11655
14053
  properties: { left: EXTEND_SIDE("left"), top: EXTEND_SIDE("top"), right: EXTEND_SIDE("right"), bottom: EXTEND_SIDE("bottom") },
11656
14054
  additionalProperties: false
11657
14055
  },
11658
- annotations: { title: "Extend the canvas", ...WRITE6 },
14056
+ annotations: { title: "Extend the canvas", ...WRITE7 },
11659
14057
  handler: (a) => c.extendCanvas({ left: a.left, top: a.top, right: a.right, bottom: a.bottom })
11660
14058
  });
11661
14059
  add({
@@ -11663,7 +14061,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11663
14061
  title: "List papers",
11664
14062
  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.",
11665
14063
  inputSchema: EMPTY3,
11666
- annotations: { title: "List papers", ...READ7 },
14064
+ annotations: { title: "List papers", ...READ8 },
11667
14065
  handler: () => ({
11668
14066
  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 })),
11669
14067
  families: [...PAPER_FAMILIES],
@@ -11675,7 +14073,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11675
14073
  title: "Get the paper",
11676
14074
  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).",
11677
14075
  inputSchema: EMPTY3,
11678
- annotations: { title: "Get the paper", ...READ7 },
14076
+ annotations: { title: "Get the paper", ...READ8 },
11679
14077
  handler: () => JSON.parse(JSON.stringify(c.document.paper))
11680
14078
  });
11681
14079
  const tuning = {
@@ -11713,7 +14111,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11713
14111
  },
11714
14112
  additionalProperties: false
11715
14113
  },
11716
- annotations: { title: "Set the paper", ...SET4 },
14114
+ annotations: { title: "Set the paper", ...SET5 },
11717
14115
  handler: (a) => c.setPaper(a)
11718
14116
  });
11719
14117
  add({
@@ -11721,7 +14119,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11721
14119
  title: "List layers",
11722
14120
  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.",
11723
14121
  inputSchema: EMPTY3,
11724
- annotations: { title: "List layers", ...READ7 },
14122
+ annotations: { title: "List layers", ...READ8 },
11725
14123
  coordinates: { out: ["layers[].bounds"] },
11726
14124
  handler: () => ({ layers: c.listLayers(), activeLayerId: c.document.activeLayerId })
11727
14125
  });
@@ -11739,7 +14137,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11739
14137
  },
11740
14138
  additionalProperties: false
11741
14139
  },
11742
- annotations: { title: "Add a layer", ...WRITE6 },
14140
+ annotations: { title: "Add a layer", ...WRITE7 },
11743
14141
  coordinates: { out: ["bounds"] },
11744
14142
  handler: (a) => c.addLayer({ name: a.name, type: a.type, aboveId: a.aboveId, parentId: a.parentId })
11745
14143
  });
@@ -11775,7 +14173,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11775
14173
  required: ["layerId"],
11776
14174
  additionalProperties: false
11777
14175
  },
11778
- annotations: { title: "Change a layer", ...SET4 },
14176
+ annotations: { title: "Change a layer", ...SET5 },
11779
14177
  coordinates: { out: ["bounds"] },
11780
14178
  handler: ({ layerId, ...u }) => c.updateLayer(layerId, u)
11781
14179
  });
@@ -11784,7 +14182,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11784
14182
  title: "List blend modes",
11785
14183
  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.",
11786
14184
  inputSchema: EMPTY3,
11787
- annotations: { title: "List blend modes", ...READ7 },
14185
+ annotations: { title: "List blend modes", ...READ8 },
11788
14186
  handler: () => ({
11789
14187
  modes: BLEND_MODES.map((m) => ({
11790
14188
  id: m.id,
@@ -11800,7 +14198,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11800
14198
  title: "Choose the active layer",
11801
14199
  description: "Makes a layer active: strokes, fills and clears without a layerId go to it. Editor state, not an undo step. Returns the layer.",
11802
14200
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers." } }, required: ["layerId"], additionalProperties: false },
11803
- annotations: { title: "Choose the active layer", ...SET4 },
14201
+ annotations: { title: "Choose the active layer", ...SET5 },
11804
14202
  coordinates: { out: ["bounds"] },
11805
14203
  handler: ({ layerId }) => {
11806
14204
  c.setActiveLayer(layerId);
@@ -11820,7 +14218,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11820
14218
  title: "Duplicate a layer",
11821
14219
  description: "Copies a layer (a group with its children) directly above it; the copy becomes active. One undo step. Returns the copy.",
11822
14220
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers." } }, required: ["layerId"], additionalProperties: false },
11823
- annotations: { title: "Duplicate a layer", ...WRITE6 },
14221
+ annotations: { title: "Duplicate a layer", ...WRITE7 },
11824
14222
  coordinates: { out: ["bounds"] },
11825
14223
  handler: ({ layerId }) => c.duplicateLayer(layerId)
11826
14224
  });
@@ -11838,7 +14236,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11838
14236
  required: ["layerId", "index"],
11839
14237
  additionalProperties: false
11840
14238
  },
11841
- annotations: { title: "Reorder a layer", ...WRITE6 },
14239
+ annotations: { title: "Reorder a layer", ...WRITE7 },
11842
14240
  coordinates: { out: ["bounds"] },
11843
14241
  handler: ({ layerId, index, parentId }) => c.moveLayer(layerId, index, parentId)
11844
14242
  });
@@ -11867,7 +14265,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11867
14265
  },
11868
14266
  additionalProperties: false
11869
14267
  },
11870
- annotations: { title: "Fill a layer", ...WRITE6 },
14268
+ annotations: { title: "Fill a layer", ...WRITE7 },
11871
14269
  coordinates: { out: ["bounds"] },
11872
14270
  handler: (a) => act({ kind: "fill", fill: a }, () => c.fillLayer(a))
11873
14271
  });
@@ -11914,7 +14312,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11914
14312
  },
11915
14313
  additionalProperties: false
11916
14314
  },
11917
- annotations: { title: "Fill a region (bucket / flats)", ...WRITE6 },
14315
+ annotations: { title: "Fill a region (bucket / flats)", ...WRITE7 },
11918
14316
  coordinates: { in: ["", "points[]"], out: ["bounds", "regions[].point", "regions[].bounds"] },
11919
14317
  handler: (a) => act({ kind: "fill-region", fill: a }, () => c.fillRegion(a))
11920
14318
  });
@@ -11938,7 +14336,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11938
14336
  },
11939
14337
  additionalProperties: false
11940
14338
  },
11941
- annotations: { title: "Find enclosed unfilled regions", ...READ7 },
14339
+ annotations: { title: "Find enclosed unfilled regions", ...READ8 },
11942
14340
  coordinates: { out: ["regions[].point", "regions[].centroid", "regions[].bounds"] },
11943
14341
  handler: (a) => c.findUnfilledRegions(a)
11944
14342
  });
@@ -11956,7 +14354,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11956
14354
  title: "Get a brush",
11957
14355
  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.",
11958
14356
  inputSchema: { type: "object", properties: { id: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." } }, additionalProperties: false },
11959
- annotations: { title: "Get a brush", ...READ7 },
14357
+ annotations: { title: "Get a brush", ...READ8 },
11960
14358
  handler: ({ id }) => {
11961
14359
  const st = c.getState();
11962
14360
  const b = id === void 0 ? st.brush : c.getBrush(id);
@@ -11970,7 +14368,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11970
14368
  title: "Set the brush",
11971
14369
  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).",
11972
14370
  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 },
11973
- annotations: { title: "Set the brush", ...SET4 },
14371
+ annotations: { title: "Set the brush", ...SET5 },
11974
14372
  handler: async ({ id, unset, ...patch }) => {
11975
14373
  const stamps = patch.tip?.stamps;
11976
14374
  if (stamps) await c.prepareStickers(stamps);
@@ -11991,7 +14389,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
11991
14389
  },
11992
14390
  additionalProperties: false
11993
14391
  },
11994
- annotations: { title: "Set the paint colour", ...SET4 },
14392
+ annotations: { title: "Set the paint colour", ...SET5 },
11995
14393
  handler: ({ color, secondaryColor, locked }) => {
11996
14394
  if (color === void 0 && secondaryColor === void 0 && locked === void 0) throw new Error("Pass color, secondaryColor or locked.");
11997
14395
  if (color !== void 0) c.setColor(color);
@@ -12006,7 +14404,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12006
14404
  title: "Set the paint (colour or gradient)",
12007
14405
  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.',
12008
14406
  inputSchema: { type: "object", properties: { paint: PAINT_SOURCE_JSON_SCHEMA }, required: ["paint"], additionalProperties: false },
12009
- annotations: { title: "Set the paint (colour or gradient)", ...SET4 },
14407
+ annotations: { title: "Set the paint (colour or gradient)", ...SET5 },
12010
14408
  handler: ({ paint }) => {
12011
14409
  const p = c.setPaint(paint);
12012
14410
  const s = c.getState();
@@ -12027,7 +14425,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12027
14425
  required: ["x", "y"],
12028
14426
  additionalProperties: false
12029
14427
  },
12030
- annotations: { title: "Pick a colour", ...READ7 },
14428
+ annotations: { title: "Pick a colour", ...READ8 },
12031
14429
  coordinates: { in: [""] },
12032
14430
  handler: ({ x, y, layerId }) => c.pickColor(x, y, { layerId })
12033
14431
  });
@@ -12036,7 +14434,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12036
14434
  title: "Get the Mixer Brush load",
12037
14435
  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.",
12038
14436
  inputSchema: EMPTY3,
12039
- annotations: { title: "Get the Mixer Brush load", ...READ7 },
14437
+ annotations: { title: "Get the Mixer Brush load", ...READ8 },
12040
14438
  handler: () => loadResult(c.getState().mixerLoad)
12041
14439
  });
12042
14440
  add({
@@ -12044,7 +14442,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12044
14442
  title: "Load the Mixer Brush",
12045
14443
  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.",
12046
14444
  inputSchema: { type: "object", properties: { color: { ...COLOR3, description: "Colour #rrggbb (default the paint colour)." } }, additionalProperties: false },
12047
- annotations: { title: "Load the Mixer Brush", ...SET4 },
14445
+ annotations: { title: "Load the Mixer Brush", ...SET5 },
12048
14446
  handler: ({ color }) => loadResult(c.loadMixerBrush(color))
12049
14447
  });
12050
14448
  add({
@@ -12052,7 +14450,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12052
14450
  title: "Clean the Mixer Brush",
12053
14451
  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.",
12054
14452
  inputSchema: { type: "object", properties: { emptyReservoir: { type: "boolean", description: "Also drop the loaded paint (default false)." } }, additionalProperties: false },
12055
- annotations: { title: "Clean the Mixer Brush", ...SET4 },
14453
+ annotations: { title: "Clean the Mixer Brush", ...SET5 },
12056
14454
  handler: ({ emptyReservoir }) => loadResult(c.cleanMixerBrush(emptyReservoir === true))
12057
14455
  });
12058
14456
  add({
@@ -12062,7 +14460,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12062
14460
  inputSchema: {
12063
14461
  type: "object",
12064
14462
  properties: {
12065
- points: POINTS,
14463
+ points: POINTS2,
12066
14464
  layerId: LAYER_ID4,
12067
14465
  brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
12068
14466
  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 },
@@ -12074,7 +14472,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12074
14472
  required: ["points"],
12075
14473
  additionalProperties: false
12076
14474
  },
12077
- annotations: { title: "Paint a brush stroke", ...WRITE6 },
14475
+ annotations: { title: "Paint a brush stroke", ...WRITE7 },
12078
14476
  coordinates: { in: ["points[]"], out: ["bounds"] },
12079
14477
  handler: async (a) => {
12080
14478
  await c.wetIdle();
@@ -12111,7 +14509,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12111
14509
  required: ["path"],
12112
14510
  additionalProperties: false
12113
14511
  },
12114
- annotations: { title: "Brush along a path", ...WRITE6 },
14512
+ annotations: { title: "Brush along a path", ...WRITE7 },
12115
14513
  coordinates: { out: ["bounds"] },
12116
14514
  handler: async (a) => {
12117
14515
  await c.wetIdle();
@@ -12165,7 +14563,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12165
14563
  properties: { limit: { type: "integer", minimum: 1, maximum: 5e3, description: "Most recent samples to return (default 500)." } },
12166
14564
  additionalProperties: false
12167
14565
  },
12168
- annotations: { title: "Get the stroke being painted", ...READ7 },
14566
+ annotations: { title: "Get the stroke being painted", ...READ8 },
12169
14567
  handler: (args) => {
12170
14568
  const live = c.getLiveStroke();
12171
14569
  if (!live) return { live: false };
@@ -12187,7 +14585,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12187
14585
  },
12188
14586
  additionalProperties: false
12189
14587
  },
12190
- annotations: { title: "List recorded strokes", ...READ7 },
14588
+ annotations: { title: "List recorded strokes", ...READ8 },
12191
14589
  coordinates: { out: ["strokes[].bounds", "strokes[].points[]"] },
12192
14590
  handler: ({ ids, last, live, layerId }) => {
12193
14591
  if (layerId !== void 0 && !c.getLayer(layerId)) throw new Error(`Unknown layer "${layerId}". Call list_layers for the ids.`);
@@ -12216,7 +14614,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12216
14614
  required: ["x", "y"],
12217
14615
  additionalProperties: false
12218
14616
  },
12219
- annotations: { title: "Find the stroke at a point", ...READ7 },
14617
+ annotations: { title: "Find the stroke at a point", ...READ8 },
12220
14618
  coordinates: { in: [""], out: ["stroke.bounds"] },
12221
14619
  handler: ({ layerId, x, y }) => {
12222
14620
  const s = c.liveStrokeAt(layerId, x, y);
@@ -12240,7 +14638,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12240
14638
  required: ["id"],
12241
14639
  additionalProperties: false
12242
14640
  },
12243
- annotations: { title: "Change a stroke", ...WRITE6 },
14641
+ annotations: { title: "Change a stroke", ...WRITE7 },
12244
14642
  coordinates: { out: ["bounds", "stroke.bounds"] },
12245
14643
  handler: ({ id, ...update }) => {
12246
14644
  if (!Object.keys(update).length) throw new Error("Pass at least one change: brushId, brush, color, paint or seed.");
@@ -12261,7 +14659,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12261
14659
  type: "object",
12262
14660
  properties: {
12263
14661
  id: { type: "string", description: "Stroke id from list_strokes {live: true}." },
12264
- points: POINTS,
14662
+ points: POINTS2,
12265
14663
  transform: {
12266
14664
  type: "object",
12267
14665
  description: "A move, scale, rotation or flip of the whole stroke.",
@@ -12279,7 +14677,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12279
14677
  required: ["id"],
12280
14678
  additionalProperties: false
12281
14679
  },
12282
- annotations: { title: "Move or reshape a stroke", ...WRITE6 },
14680
+ annotations: { title: "Move or reshape a stroke", ...WRITE7 },
12283
14681
  coordinates: { in: ["points[]", "transform.origin"], out: ["bounds", "stroke.bounds", "stroke.points[]"] },
12284
14682
  handler: async ({ id, points, transform }) => {
12285
14683
  await c.prepareStrokeEdit(id, { points, transform });
@@ -12297,7 +14695,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12297
14695
  required: ["id", "index"],
12298
14696
  additionalProperties: false
12299
14697
  },
12300
- annotations: { title: "Move a stroke up or down", ...WRITE6 },
14698
+ annotations: { title: "Move a stroke up or down", ...WRITE7 },
12301
14699
  handler: ({ id, index }) => ({ order: c.reorderStroke(id, index) })
12302
14700
  });
12303
14701
  add({
@@ -12319,7 +14717,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12319
14717
  title: "Make a layer a stroke layer",
12320
14718
  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.',
12321
14719
  inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers (default the active layer)." } }, additionalProperties: false },
12322
- annotations: { title: "Make a layer a stroke layer", ...WRITE6 },
14720
+ annotations: { title: "Make a layer a stroke layer", ...WRITE7 },
12323
14721
  coordinates: { out: ["bounds"] },
12324
14722
  handler: ({ layerId }) => c.convertToLiveStrokes(layerId)
12325
14723
  });
@@ -12361,7 +14759,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12361
14759
  required: ["strokeIds"],
12362
14760
  additionalProperties: false
12363
14761
  },
12364
- annotations: { title: "Replay strokes, varied", ...WRITE6 },
14762
+ annotations: { title: "Replay strokes, varied", ...WRITE7 },
12365
14763
  coordinates: { in: ["origin"], out: ["bounds"] },
12366
14764
  handler: async (a) => {
12367
14765
  const replay = a;
@@ -12383,7 +14781,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12383
14781
  },
12384
14782
  additionalProperties: false
12385
14783
  },
12386
- annotations: { title: "Rotate, mirror or zoom the view", ...SET4 },
14784
+ annotations: { title: "Rotate, mirror or zoom the view", ...SET5 },
12387
14785
  handler: ({ reset, ...v }) => {
12388
14786
  if (reset) c.resetView();
12389
14787
  return c.setView(v);
@@ -12394,7 +14792,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12394
14792
  title: "Set the drawing hand",
12395
14793
  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.",
12396
14794
  inputSchema: { type: "object", properties: { hand: { type: "string", enum: ["right", "left"], description: "right or left." } }, required: ["hand"], additionalProperties: false },
12397
- annotations: { title: "Set the drawing hand", ...SET4 },
14795
+ annotations: { title: "Set the drawing hand", ...SET5 },
12398
14796
  handler: ({ hand }) => ({ hand: c.setHand(hand) })
12399
14797
  });
12400
14798
  add({
@@ -12402,7 +14800,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12402
14800
  title: "Undo",
12403
14801
  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.",
12404
14802
  inputSchema: EMPTY3,
12405
- annotations: { title: "Undo", ...WRITE6 },
14803
+ annotations: { title: "Undo", ...WRITE7 },
12406
14804
  handler: () => {
12407
14805
  const undone = c.undo();
12408
14806
  const s = c.getState();
@@ -12414,7 +14812,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12414
14812
  title: "Redo",
12415
14813
  description: "Redoes the last undone step. Returns the label redone (null when there was nothing) and what can be undone and redone next.",
12416
14814
  inputSchema: EMPTY3,
12417
- annotations: { title: "Redo", ...WRITE6 },
14815
+ annotations: { title: "Redo", ...WRITE7 },
12418
14816
  handler: () => {
12419
14817
  const redone = c.redo();
12420
14818
  const s = c.getState();
@@ -12434,7 +14832,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12434
14832
  },
12435
14833
  additionalProperties: false
12436
14834
  },
12437
- annotations: { title: "Render a preview", ...READ7 },
14835
+ annotations: { title: "Render a preview", ...READ8 },
12438
14836
  handler: ({ maxSide, layerIds, paper }) => {
12439
14837
  if (layerIds) {
12440
14838
  for (const id of layerIds) if (!c.getLayer(id)) throw new Error(`Unknown layer "${id}". Call list_layers for the ids.`);
@@ -12458,7 +14856,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12458
14856
  },
12459
14857
  additionalProperties: false
12460
14858
  },
12461
- annotations: { title: "Export the painting", ...READ7 },
14859
+ annotations: { title: "Export the painting", ...READ8 },
12462
14860
  handler: async ({ format, quality, scale, paper, time }) => {
12463
14861
  const run = () => c.exportImage({ format, quality, scale, paper });
12464
14862
  const bytes = time === void 0 ? await run() : await c.withEffectTime(time, run);
@@ -12470,7 +14868,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12470
14868
  title: "Export as PSD",
12471
14869
  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).',
12472
14870
  inputSchema: { type: "object", properties: { time: TIME }, additionalProperties: false },
12473
- annotations: { title: "Export as PSD", ...READ7 },
14871
+ annotations: { title: "Export as PSD", ...READ8 },
12474
14872
  handler: async ({ time }) => {
12475
14873
  const r = time === void 0 ? c.exportPsd() : await c.withEffectTime(time, () => c.exportPsd());
12476
14874
  return { data: bytesToBase64(r.bytes), bytes: r.bytes.length, psb: r.psb, warnings: r.warnings };
@@ -12482,6 +14880,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
12482
14880
  tools.push(...createPaintReplayTools(c));
12483
14881
  tools.push(...createPaintEvolutionTools(c));
12484
14882
  tools.push(...createPaintStickerTools(c));
14883
+ tools.push(...createMutationTools(c, { director: options.director, actor: options.actor }));
12485
14884
  tools.push(...createPigmentTools());
12486
14885
  tools.push(...createStickerTools({ provider: () => c.stickersProvider }));
12487
14886
  tools.push(
@@ -13097,6 +15496,53 @@ var LIGHT_SLIDERS = [
13097
15496
  { path: "elevation", label: "Light height", min: 5, max: 90, step: 1 },
13098
15497
  { path: "relief", label: "Relief", min: 0, max: 1, step: 0.01 }
13099
15498
  ];
15499
+ var UV_LIGHT_SLIDERS = [
15500
+ { path: "strength", label: "UV strength", min: 0, max: 2, step: 0.05 },
15501
+ { path: "ambient", label: "Ambient light", min: 0, max: 1, step: 0.01 }
15502
+ ];
15503
+ var VIEW_TILT_SLIDERS = [
15504
+ { path: "x", label: "View tilt X", min: -1, max: 1, step: 0.01 },
15505
+ { path: "y", label: "View tilt Y", min: -1, max: 1, step: 0.01 }
15506
+ ];
15507
+ function deviceOrientationToViewTilt(betaOrEvent, gammaOrRest, rest = { beta: 45, gamma: 0 }) {
15508
+ const isObj = typeof betaOrEvent === "object" && betaOrEvent !== null;
15509
+ const r = isObj && typeof gammaOrRest === "object" && gammaOrRest !== null ? gammaOrRest : rest;
15510
+ const rawBeta = isObj ? betaOrEvent.beta : betaOrEvent;
15511
+ const rawGamma = isObj ? betaOrEvent.gamma : typeof gammaOrRest === "number" ? gammaOrRest : void 0;
15512
+ const b = typeof rawBeta === "number" && Number.isFinite(rawBeta) ? rawBeta : r.beta;
15513
+ const g = typeof rawGamma === "number" && Number.isFinite(rawGamma) ? rawGamma : r.gamma;
15514
+ const clamp2 = (v) => Math.round(Math.max(-1, Math.min(1, v)) * 100) / 100;
15515
+ return { x: clamp2((g - r.gamma) / 35), y: clamp2((b - r.beta) / 35) };
15516
+ }
15517
+ async function requestDeviceTiltPermission() {
15518
+ const g = globalThis;
15519
+ if (!g.DeviceOrientationEvent) return "unsupported";
15520
+ if (typeof g.DeviceOrientationEvent.requestPermission === "function") {
15521
+ try {
15522
+ return await g.DeviceOrientationEvent.requestPermission() === "granted" ? "granted" : "denied";
15523
+ } catch {
15524
+ return "denied";
15525
+ }
15526
+ }
15527
+ return "granted";
15528
+ }
15529
+ function startDeviceTilt(controller) {
15530
+ const g = globalThis;
15531
+ if (typeof g.addEventListener !== "function") return () => {
15532
+ };
15533
+ let rest = null;
15534
+ const onOrientation = (e) => {
15535
+ if (typeof e.beta !== "number" || typeof e.gamma !== "number") return;
15536
+ if (!rest) rest = { beta: e.beta, gamma: e.gamma };
15537
+ const next = deviceOrientationToViewTilt(e.beta, e.gamma, rest);
15538
+ const cur = controller.getViewTilt();
15539
+ if (next.x !== cur.x || next.y !== cur.y) controller.setViewTilt(next);
15540
+ };
15541
+ g.addEventListener("deviceorientation", onOrientation);
15542
+ return () => {
15543
+ g.removeEventListener?.("deviceorientation", onOrientation);
15544
+ };
15545
+ }
13100
15546
  var PARTICLE_KIND_LABELS = { string: "Strings", spring: "Springs", orbit: "Orbits", flow: "Flow", elastic: "Elastic", fuzzy: "Fuzzy" };
13101
15547
  var PARTICLE_RENDER_LABELS = { dot: "Dots", line: "Lines", sequence: "Beads", ribbon: "Ribbon" };
13102
15548
  var RIBBON_PAIR_LABELS = { adjacent: "Neighbours", "first-last": "Outermost" };
@@ -13427,8 +15873,8 @@ function createPaintEditorDocument(controller, options = {}) {
13427
15873
  }
13428
15874
 
13429
15875
  // src/paint-editor/collaboration-tools.ts
13430
- var READ8 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13431
- var WRITE7 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
15876
+ var READ9 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
15877
+ var WRITE8 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13432
15878
  var proposalIdArg = {
13433
15879
  type: "object",
13434
15880
  properties: { proposalId: { type: "string", minLength: 1, description: "Id of a pending paint proposal, from propose_paint or list_paint_proposals." } },
@@ -13506,7 +15952,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
13506
15952
  required: ["title", "ops"],
13507
15953
  additionalProperties: false
13508
15954
  },
13509
- annotations: { title: "Propose paint", ...WRITE7 },
15955
+ annotations: { title: "Propose paint", ...WRITE8 },
13510
15956
  handler: (args, context) => {
13511
15957
  const origin = context.actor?.id ? `agent:${context.actor.id}` : options.fallbackOrigin;
13512
15958
  const proposal = proposals.propose({ ...args, ...origin ? { origin } : {} });
@@ -13519,7 +15965,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
13519
15965
  title: "List paint proposals",
13520
15966
  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.",
13521
15967
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
13522
- annotations: { title: "List paint proposals", ...READ8 },
15968
+ annotations: { title: "List paint proposals", ...READ9 },
13523
15969
  handler: () => ({ proposals: proposals.getState().proposals.map(describe), readOnly: readOnlyReason() })
13524
15970
  };
13525
15971
  const acceptTool = {
@@ -13527,7 +15973,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
13527
15973
  title: "Accept a paint proposal",
13528
15974
  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.",
13529
15975
  inputSchema: proposalIdArg,
13530
- annotations: { title: "Accept a paint proposal", ...WRITE7 },
15976
+ annotations: { title: "Accept a paint proposal", ...WRITE8 },
13531
15977
  handler: (args) => {
13532
15978
  const reason = readOnlyReason();
13533
15979
  if (reason) throw new Error(reason);
@@ -13539,7 +15985,7 @@ function createPaintProposalToolProvider(proposals, options = {}) {
13539
15985
  title: "Reject a paint proposal",
13540
15986
  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.",
13541
15987
  inputSchema: proposalIdArg,
13542
- annotations: { title: "Reject a paint proposal", ...WRITE7 },
15988
+ annotations: { title: "Reject a paint proposal", ...WRITE8 },
13543
15989
  handler: (args) => {
13544
15990
  const p = proposals.reject(args.proposalId);
13545
15991
  return { proposalId: p.id, title: p.title, rejected: true };
@@ -13611,6 +16057,35 @@ var PAINT_DOCUMENT_JSON_SCHEMA = {
13611
16057
  },
13612
16058
  required: ["id", "layerId", "points", "brush"]
13613
16059
  }
16060
+ },
16061
+ fluor: {
16062
+ type: "object",
16063
+ description: "Per-layer UV-fluorescent ink channels (layerId \u2192 bounds + PNG data URL).",
16064
+ additionalProperties: { type: "object" }
16065
+ },
16066
+ thermo: {
16067
+ type: "object",
16068
+ description: "Per-layer thermochromic ink channels (layerId \u2192 bounds + PNG data URL).",
16069
+ additionalProperties: { type: "object" }
16070
+ },
16071
+ heat: {
16072
+ type: "object",
16073
+ description: "Coarse document heat field (width, height, png) when the canvas is warm.",
16074
+ properties: {
16075
+ width: { type: "integer", minimum: 1, description: "Heat grid width in cells (1/4 canvas resolution)." },
16076
+ height: { type: "integer", minimum: 1, description: "Heat grid height in cells (1/4 canvas resolution)." },
16077
+ png: { type: "string", description: "Heat grid encoded as an RGBA PNG data URL." }
16078
+ },
16079
+ required: ["width", "height", "png"]
16080
+ },
16081
+ uvLight: {
16082
+ type: "object",
16083
+ description: "Virtual UV blacklight settings when non-default.",
16084
+ properties: {
16085
+ on: { type: "boolean", description: "Whether the UV blacklight is switched on." },
16086
+ strength: { type: "number", minimum: 0, maximum: 2, description: "UV emission and spill strength (0..2)." },
16087
+ ambient: { type: "number", minimum: 0, maximum: 1, description: "Remaining daylight ambient level (0..1)." }
16088
+ }
13614
16089
  }
13615
16090
  },
13616
16091
  required: ["format", "version", "width", "height", "dpi", "paper", "layers", "assets", "activeLayerId"]
@@ -13633,7 +16108,7 @@ function uiTables() {
13633
16108
  brushOptions: BRUSH_OPTION_CONTROLS,
13634
16109
  blendMenu: BLEND_MENU_GROUPS,
13635
16110
  layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
13636
- sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, ghost: GHOST_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
16111
+ sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, uvLight: UV_LIGHT_SLIDERS, viewTilt: VIEW_TILT_SLIDERS, ghost: GHOST_SLIDERS, mutation: MUTATION_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
13637
16112
  pathStrokeTapers: PATH_STROKE_TAPERS,
13638
16113
  padSampleSizes: PAD_SAMPLE_SIZES,
13639
16114
  wetGpu: WET_GPU_OPTIONS,
@@ -13710,11 +16185,14 @@ export {
13710
16185
  DEFAULT_DPI,
13711
16186
  DEFAULT_DYNAMICS,
13712
16187
  DEFAULT_FLOW,
16188
+ DEFAULT_FLUORESCENCE,
13713
16189
  DEFAULT_GHOST,
13714
16190
  DEFAULT_IMPASTO,
16191
+ DEFAULT_IRIDESCENCE,
13715
16192
  DEFAULT_LIGHT,
13716
16193
  DEFAULT_LIGHTING,
13717
16194
  DEFAULT_MULTI_COLOR,
16195
+ DEFAULT_MUTATION,
13718
16196
  DEFAULT_MUTATION_RATES,
13719
16197
  DEFAULT_PAINT_GRADIENT,
13720
16198
  DEFAULT_PAINT_GRADIENT_MAPPING,
@@ -13723,8 +16201,11 @@ export {
13723
16201
  DEFAULT_SPRAY,
13724
16202
  DEFAULT_STICKER,
13725
16203
  DEFAULT_SWATCHES,
16204
+ DEFAULT_THERMOCHROMIC,
13726
16205
  DEFAULT_TIP_GRAIN,
16206
+ DEFAULT_UV_LIGHT,
13727
16207
  DEFAULT_VIEW,
16208
+ DEFAULT_VIEW_TILT,
13728
16209
  DEFAULT_WET_BRUSH,
13729
16210
  DEFAULT_WET_PARAMS,
13730
16211
  DOT_ROTATIONS,
@@ -13737,15 +16218,22 @@ export {
13737
16218
  EVOLUTION_BRUSH_FACETS,
13738
16219
  EVOLUTION_MUTATION_SCHEMA,
13739
16220
  EVOLUTION_SLIDERS,
16221
+ FilmBuffer,
13740
16222
  GHOST_SLIDERS,
16223
+ GHOST_TRIGGERS,
13741
16224
  GPU_LEAD,
13742
16225
  GRAIN_PATTERNS,
13743
16226
  GRAIN_TILE,
13744
16227
  HARMONY_KINDS,
16228
+ HEAT_COOLING,
16229
+ HEAT_DIFFUSION,
16230
+ HEAT_DRY_THRESHOLD,
16231
+ HEAT_SCALE,
13745
16232
  IDENTITY_AFFINE,
13746
16233
  IDENTITY_COPY,
13747
16234
  IMPASTO_TOOLS,
13748
16235
  IMPASTO_TOOL_LABELS,
16236
+ IRIDESCENCE_SLIDERS,
13749
16237
  LIGHT_SLIDERS,
13750
16238
  LIVE_STROKE_ENGINES,
13751
16239
  LitCache,
@@ -13764,6 +16252,7 @@ export {
13764
16252
  MIXING_PAD_HEIGHT,
13765
16253
  MIXING_PAD_WIDTH,
13766
16254
  MULTI_COLOR_MODES,
16255
+ MUTATION_SLIDERS,
13767
16256
  MixingPad,
13768
16257
  PAD_SAMPLE_SIZES,
13769
16258
  PAINTERLY_BLEND_MODES,
@@ -13836,9 +16325,13 @@ export {
13836
16325
  StickerSpray,
13837
16326
  TIP_GRAIN_MODES,
13838
16327
  TRACING_NODE_ID,
16328
+ ThermoCache,
13839
16329
  ThickStrokeDriver,
13840
16330
  TileableNoise,
13841
16331
  TipGrain,
16332
+ UV_LIGHT_SLIDERS,
16333
+ UvCache,
16334
+ VIEW_TILT_SLIDERS,
13842
16335
  WET_FLOW_KINDS,
13843
16336
  WET_GPU_MODES,
13844
16337
  WET_GPU_OPTIONS,
@@ -13855,6 +16348,7 @@ export {
13855
16348
  WetStrokeDriver,
13856
16349
  addPaintGradientStop,
13857
16350
  addPolygonArea,
16351
+ advanceHeatField,
13858
16352
  affineCopy,
13859
16353
  applyAffine,
13860
16354
  backlightOf,
@@ -13866,11 +16360,16 @@ export {
13866
16360
  buildTimeLapse,
13867
16361
  checkEvolutionStrokes,
13868
16362
  checkPaintEffectPlace,
16363
+ clearHeatField,
16364
+ cloneHeatField,
13869
16365
  clonePaper,
13870
16366
  colorHarmony2 as colorHarmony,
13871
16367
  composeAffine,
16368
+ computeUvSpill,
13872
16369
  contactSheet,
13873
16370
  createEvolutionTools,
16371
+ createHeatField,
16372
+ createMutationTools,
13874
16373
  createPaintAgentStrokes,
13875
16374
  createPaintBrushLibraryToolProvider,
13876
16375
  createPaintChangeFeed,
@@ -13899,6 +16398,7 @@ export {
13899
16398
  describeGenomeDiff,
13900
16399
  deserializePaintDocument,
13901
16400
  deserializePaintDocumentAsync,
16401
+ deviceOrientationToViewTilt,
13902
16402
  documentExtensions,
13903
16403
  documentToScreen,
13904
16404
  downloadBytes,
@@ -13912,19 +16412,30 @@ export {
13912
16412
  effectPaintKey,
13913
16413
  effectPaintSize,
13914
16414
  emptiedLayers,
16415
+ emptyFluor,
16416
+ emptyThermo,
13915
16417
  emptyThickChannels,
13916
16418
  engineBrushSourceIds,
13917
16419
  exportProposalContactSheetPng,
13918
16420
  fillRegionMask,
16421
+ finalizeThermoStroke,
13919
16422
  findUnfilledRegionsMask,
13920
16423
  fitZoom,
16424
+ fluorFromJSON,
16425
+ fluorToJSON,
16426
+ fluorTwinBrush,
13921
16427
  grainCatch,
13922
16428
  grainHeights,
13923
16429
  guideOverlay,
13924
16430
  handlePaintShortcut,
16431
+ hasThickChannels,
13925
16432
  hash2,
16433
+ heatFromJSON,
16434
+ heatStats,
16435
+ heatToJSON,
13926
16436
  hexToPaintRgb,
13927
16437
  hexToRgb8,
16438
+ hotCellsBounds,
13928
16439
  hslToRgb,
13929
16440
  hsvToRgb,
13930
16441
  hueToWheel,
@@ -13943,6 +16454,7 @@ export {
13943
16454
  liveStrokeBounds,
13944
16455
  liveStrokeRefusal,
13945
16456
  mapPenPoint,
16457
+ mergeFourChannel,
13946
16458
  mix32,
13947
16459
  mixColorOklch,
13948
16460
  mixPixels,
@@ -13955,6 +16467,7 @@ export {
13955
16467
  multiColorToPaint,
13956
16468
  mutateGenome,
13957
16469
  mutateStroke,
16470
+ mutationBrushRefusal,
13958
16471
  newPaintLayerId,
13959
16472
  nextPaperEffectVersion,
13960
16473
  normalizeBehavior,
@@ -13963,12 +16476,14 @@ export {
13963
16476
  normalizeCanvas,
13964
16477
  normalizeDynamics,
13965
16478
  normalizeFlow,
16479
+ normalizeFluorescence,
13966
16480
  normalizeGhost,
13967
16481
  normalizeHexColor,
13968
16482
  normalizeImpasto,
13969
16483
  normalizeLight,
13970
16484
  normalizeLighting,
13971
16485
  normalizeMultiColor,
16486
+ normalizeMutation,
13972
16487
  normalizeMutationRates,
13973
16488
  normalizePaintBrush,
13974
16489
  normalizePaper,
@@ -13979,7 +16494,10 @@ export {
13979
16494
  normalizeStickerSettings,
13980
16495
  normalizeStickerUpdate,
13981
16496
  normalizeSymmetry,
16497
+ normalizeThermochromic,
16498
+ normalizeUvLight,
13982
16499
  normalizeView,
16500
+ normalizeViewTilt,
13983
16501
  normalizeWetBrush,
13984
16502
  normalizeWetParams,
13985
16503
  overPixels,
@@ -13988,6 +16506,7 @@ export {
13988
16506
  paintBrushPreviewRenderer,
13989
16507
  paintContracts,
13990
16508
  paintGradientMappingFor,
16509
+ paintHeatField,
13991
16510
  paintPlaybackPanel,
13992
16511
  paintProposalGhost,
13993
16512
  paintShortcutFor,
@@ -14030,6 +16549,7 @@ export {
14030
16549
  replayFrame,
14031
16550
  replayItems,
14032
16551
  replayPainting,
16552
+ requestDeviceTiltPermission,
14033
16553
  rerollStrokeSeed,
14034
16554
  resampleStrokePoints,
14035
16555
  resolvePaintGradient,
@@ -14040,8 +16560,10 @@ export {
14040
16560
  sampleArt,
14041
16561
  sampleEffectAt,
14042
16562
  sampleEffectField,
16563
+ sampleHeatAt,
14043
16564
  samplePath,
14044
16565
  samplePressure,
16566
+ scaleFluor,
14045
16567
  scalePaintGradient,
14046
16568
  scaleStencil,
14047
16569
  screenToDocument,
@@ -14049,10 +16571,12 @@ export {
14049
16571
  seededStrokeVariation,
14050
16572
  serializePaintDocument,
14051
16573
  serializePaintDocumentExternal,
16574
+ shiftHeatField,
14052
16575
  solidPaint,
14053
16576
  squaredDistanceTransform,
14054
16577
  stampCoverage,
14055
16578
  standardBlendMode,
16579
+ startDeviceTilt,
14056
16580
  startWetClock,
14057
16581
  stencilMask,
14058
16582
  stencilOutline,
@@ -14070,8 +16594,12 @@ export {
14070
16594
  symmetryAxisAngles,
14071
16595
  symmetryAxisSegments,
14072
16596
  symmetryCopies,
16597
+ thermoFromJSON,
16598
+ thermoToJSON,
16599
+ thermoTwinBrush,
14073
16600
  thickFromJSON,
14074
16601
  thickToJSON,
16602
+ thinFilmRgb,
14075
16603
  thumbnailStroke,
14076
16604
  tiltToOrientation,
14077
16605
  timeLapseVideo,
@@ -14083,11 +16611,14 @@ export {
14083
16611
  translateStencil,
14084
16612
  updatePaintGradientStop,
14085
16613
  usesPaintExchange,
16614
+ uvLightKey,
16615
+ uvPaper,
14086
16616
  variationAffine,
14087
16617
  varyStroke,
14088
16618
  viewCssTransform,
14089
16619
  viewMatrix,
14090
16620
  viewPenToDocument,
16621
+ viewTiltKey,
14091
16622
  wetEngineSummary,
14092
16623
  wetPaperInput,
14093
16624
  wetPigmentOf,